Add search function
This commit is contained in:
parent
1cd22a5ce9
commit
a038e355e7
53
README.rst
53
README.rst
|
@ -38,16 +38,29 @@ Usage
|
||||||
::
|
::
|
||||||
|
|
||||||
$ comp --help
|
$ comp --help
|
||||||
usage: comp [-h] [-j JSON_PLAYLIST]
|
usage: comp [-h] [-c CONFIG] [--vid {ID,auto,no}] [--vo DRIVER]
|
||||||
|
[-f YTDL_FORMAT] [-u URL] [-j JSON_PLAYLIST]
|
||||||
|
|
||||||
Curses Online Media Player
|
Curses Online Media Player
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
-j JSON_PLAYLIST, --json-playlist JSON_PLAYLIST
|
-c CONFIG, --config CONFIG
|
||||||
path to playlist in JSON format
|
location of the configuration file; either the path
|
||||||
-y YOUTUBE_PLAYLIST, --youtube-playlist YOUTUBE_PLAYLIST
|
to the config or its containing directory
|
||||||
URL to an playlist on Youtube
|
--vid {ID,auto,no} 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) man page for details
|
||||||
|
and descriptions of available drivers
|
||||||
|
-f YTDL_FORMAT, --format YTDL_FORMAT
|
||||||
|
video format/quality to be passed to youtube-dl
|
||||||
|
-u URL, --online-playlist URL
|
||||||
|
URL to an playlist on Youtube
|
||||||
|
-j JSON_PLAYLIST, --json JSON_PLAYLIST
|
||||||
|
path to playlist in JSON format. If
|
||||||
|
--online-playlist is already specified, this will be
|
||||||
|
used as the default file to save the playlist
|
||||||
|
|
||||||
Keyboard control
|
Keyboard control
|
||||||
^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^
|
||||||
|
@ -59,19 +72,25 @@ Keyboard control
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
| Space | Select the current track |
|
| Space | Select the current track |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
|
| ``/``, ``?`` | Search forward/backward for a pattern |
|
||||||
|
+--------------+---------------------------------------------+
|
||||||
| ``<``, ``>`` | Go forward/backward in the playlist |
|
| ``<``, ``>`` | Go forward/backward in the playlist |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
| ``A`` | Toggle mute |
|
| ``A`` | Toggle mute |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
|
| ``N`` | Repeat previous search in reverse direction |
|
||||||
|
+--------------+---------------------------------------------+
|
||||||
| ``U`` | Open online playlist |
|
| ``U`` | Open online playlist |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
| ``V`` | Toggle video |
|
| ``V`` | Toggle video |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
| ``W`` | Save the current playlist under JSON format |
|
| ``W`` | Save the current playlist under JSON format |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
|
| ``d`` | Delete current entry |
|
||||||
|
+--------------+---------------------------------------------+
|
||||||
| ``m``, ``M`` | Cycle through playing modes |
|
| ``m``, ``M`` | Cycle through playing modes |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
| ``d`` | Delete current entry |
|
| ``n`` | Repeat previous search |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
| ``p`` | Toggle pause |
|
| ``p`` | Toggle pause |
|
||||||
+--------------+---------------------------------------------+
|
+--------------+---------------------------------------------+
|
||||||
|
@ -104,17 +123,19 @@ user-specific one is ``~/.config/mpv/settings.ini``. Default configurations
|
||||||
are listed below::
|
are listed below::
|
||||||
|
|
||||||
[comp]
|
[comp]
|
||||||
# Supported 8 modes: play-current, play-all, play-selected, repeat-current,
|
# Initial playing mode, which can be one of these 8 modes: play-current,
|
||||||
# repeat-all, repeat-selected, shuffle-all and shuffle-selected.
|
# play-all, play-selected, repeat-current, repeat-all, repeat-selected,
|
||||||
|
# shuffle-all and shuffle-selected.
|
||||||
play-mode = play-current
|
play-mode = play-current
|
||||||
|
|
||||||
[mpv]
|
[mpv]
|
||||||
# Set if video should be download and play, I only know 2 possible values:
|
# Initial video channel. auto selects the default, no disables video.
|
||||||
# auto and no. This can be changed later interactively.
|
|
||||||
video = auto
|
video = auto
|
||||||
# Read more on VIDEO OUTPUT DRIVERS section in mpv man page.
|
# 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 =
|
video-output =
|
||||||
|
|
||||||
[youtube-dl]
|
[youtube-dl]
|
||||||
# Read more on FORMAT SELECTION section in youtube-dl man page.
|
# 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
|
format = best
|
||||||
|
|
455
comp
455
comp
|
@ -18,7 +18,9 @@
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
from collections import deque
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
from curses.ascii import ctrl
|
from curses.ascii import ctrl
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
@ -26,7 +28,7 @@ from functools import reduce
|
||||||
from gettext import gettext as _, textdomain
|
from gettext import gettext as _, textdomain
|
||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
from os import linesep, makedirs
|
from os import linesep, makedirs
|
||||||
from os.path import abspath, dirname, expanduser, isfile
|
from os.path import abspath, dirname, expanduser, isdir, isfile, join
|
||||||
from random import choice
|
from random import choice
|
||||||
from time import gmtime, strftime
|
from time import gmtime, strftime
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
@ -42,9 +44,10 @@ textdomain('comp')
|
||||||
SYSTEM_CONFIG = '/etc/comp/settings.ini'
|
SYSTEM_CONFIG = '/etc/comp/settings.ini'
|
||||||
USER_CONFIG = expanduser('~/.config/comp/settings.ini')
|
USER_CONFIG = expanduser('~/.config/comp/settings.ini')
|
||||||
MPV_LOG = expanduser('~/.cache/comp/mpv.log')
|
MPV_LOG = expanduser('~/.cache/comp/mpv.log')
|
||||||
MODES = ('play-current', 'play-all', 'play-selected', 'repeat-current',
|
MODES = ("play-current", "play-all", "play-selected", "repeat-current",
|
||||||
'repeat-all', 'repeat-selected', 'shuffle-all', 'shuffle-selected')
|
"repeat-all", "repeat-selected", "shuffle-all", "shuffle-selected")
|
||||||
MODE_STR_LEN = max(len(_(mode)) for mode in MODES)
|
MODE_STR_LEN = max(len(_(mode)) for mode in MODES)
|
||||||
|
DURATION_COL_LEN = max(len(_("Duration")), 8)
|
||||||
|
|
||||||
|
|
||||||
def mpv_logger(loglevel, component, message):
|
def mpv_logger(loglevel, component, message):
|
||||||
|
@ -54,12 +57,18 @@ def mpv_logger(loglevel, component, message):
|
||||||
f.write(mpv_log)
|
f.write(mpv_log)
|
||||||
|
|
||||||
|
|
||||||
|
def justified(s, width):
|
||||||
|
"""Return s left-justified of length width."""
|
||||||
|
return s.ljust(width)[:width]
|
||||||
|
|
||||||
|
|
||||||
class Comp(object):
|
class Comp(object):
|
||||||
"""Meta object for drawing and playing.
|
"""Meta object for drawing and playing.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
active (bool): flag show if anything is being played
|
active (bool): flag show if anything is being played
|
||||||
entries (list): list of all tracks
|
entries (list): list of all tracks
|
||||||
|
json_file (str): path to save JSON playlist
|
||||||
mode (str): the mode to pick and play tracks
|
mode (str): the mode to pick and play tracks
|
||||||
mp (MPV): an mpv instance
|
mp (MPV): an mpv instance
|
||||||
play_backward (bool): flag show if to play the previous track
|
play_backward (bool): flag show if to play the previous track
|
||||||
|
@ -68,90 +77,82 @@ class Comp(object):
|
||||||
playing (int): index of playing track in played
|
playing (int): index of playing track in played
|
||||||
playlist (iterator): iterator of tracks according to mode
|
playlist (iterator): iterator of tracks according to mode
|
||||||
reading (bool): flag show if user input is being read
|
reading (bool): flag show if user input is being read
|
||||||
start (int): index of the first track to be printed on screen
|
search_res (iterator): title-searched results
|
||||||
scr (curses WindowObject): curses window object
|
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
|
vid (str): flag show if video output is enabled
|
||||||
y (int): the current y-coordinate
|
y (int): the current y-coordinate
|
||||||
"""
|
"""
|
||||||
def __new__(cls, entries, mode, mpv_vo, mpv_vid, ytdlf):
|
def __new__(cls, entries, json_file, mode, mpv_vo, mpv_vid, ytdlf):
|
||||||
self = super(Comp, cls).__new__(cls)
|
self = super(Comp, cls).__new__(cls)
|
||||||
self.mode, self.vid = mode, mpv_vid
|
|
||||||
self.active, self.play_backward, self.reading = False, False, False
|
self.active, self.play_backward, self.reading = False, False, False
|
||||||
self.playing, self.start, self.y = -1, 0, 1
|
self.playing, self.start, self.y = -1, 0, 1
|
||||||
self.entries, self.playlist, self.played = entries, iter(()), []
|
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,
|
self.mp = MPV(input_default_bindings=True, input_vo_keyboard=True,
|
||||||
log_handler=mpv_logger, ytdl=True, ytdl_format=ytdlf)
|
log_handler=mpv_logger, ytdl=True, ytdl_format=ytdlf)
|
||||||
self.scr = curses.initscr()
|
self.scr = curses.initscr()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def update_status(self, message='', msgattr=curses.A_NORMAL):
|
||||||
|
"""Update the status lines at the bottom of the screen."""
|
||||||
|
def adds(s, a, y=curses.LINES-2, x=0):
|
||||||
|
if not self.reading: self.scr.addstr(y, x, s, a)
|
||||||
|
|
||||||
|
right = ' {} {}{} '.format(_(self.mode), ' ' if self.mp.mute else 'A',
|
||||||
|
' ' if self.vid == 'no' else 'V')
|
||||||
|
adds(right.rjust(curses.COLS), curses.color_pair(12))
|
||||||
|
try:
|
||||||
|
timestr = '%M:%S' if int(self.mp.duration) < 3600 else '%H:%M:%S'
|
||||||
|
left = ' {} / {} {} '.format(
|
||||||
|
strftime(timestr, gmtime(int(self.mp.time_pos))),
|
||||||
|
strftime(timestr, gmtime(int(self.mp.duration))),
|
||||||
|
'|' if self.mp.pause else '>')
|
||||||
|
title_len = curses.COLS - len(left + right)
|
||||||
|
center = justified(self.mp.media_title, title_len)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
adds(left, curses.color_pair(12))
|
||||||
|
adds(center, curses.color_pair(12) | curses.A_BOLD, x=len(left))
|
||||||
|
if message:
|
||||||
|
adds(justified(message, curses.COLS - 1), msgattr,
|
||||||
|
y=curses.LINES-1, x=0)
|
||||||
|
self.scr.refresh()
|
||||||
|
|
||||||
|
def getlink(self, entry):
|
||||||
|
"""Return an URL from the given entry."""
|
||||||
|
if 'webpage_url' not in entry:
|
||||||
|
with YoutubeDL({'quiet': True}) as ytdl:
|
||||||
|
entry.update(ytdl.extract_info(entry['url'], download=False,
|
||||||
|
ie_key=entry.get('ie_key')))
|
||||||
|
self.uniform(entry)
|
||||||
|
return entry['webpage_url']
|
||||||
|
|
||||||
def setno(self, *keys):
|
def setno(self, *keys):
|
||||||
"""Set all keys of each entry in entries to False."""
|
"""Set all keys of each entry in entries to False."""
|
||||||
for entry in self.entries:
|
for entry in self.entries:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
entry[key] = False
|
entry[key] = False
|
||||||
|
|
||||||
def update_status(self, message='', msgattr=curses.A_NORMAL):
|
|
||||||
"""Update the status lines at the bottom of the screen."""
|
|
||||||
def adds(s, a, y=curses.LINES-2, x=0):
|
|
||||||
if not self.reading: self.scr.addstr(y, x, s, a)
|
|
||||||
|
|
||||||
def sectoosd(pos, duration):
|
|
||||||
"""Quick hack to convert a pair of seconds to HHMMSS/HHMMSS
|
|
||||||
string as MPV.get_property_osd_string isn't available.
|
|
||||||
"""
|
|
||||||
postime, durationtime = gmtime(pos), gmtime(duration)
|
|
||||||
# Let's hope media durations are shorter than a day
|
|
||||||
timestr = '%M:%S' if duration < 3600 else '%H:%M:%S'
|
|
||||||
return '{} / {}'.format(strftime(timestr, postime),
|
|
||||||
strftime(timestr, durationtime))
|
|
||||||
|
|
||||||
right = ' {} {}{} '.format(
|
|
||||||
_(self.mode), ' ' if self.mp._get_property('mute', bool) else 'A',
|
|
||||||
' ' if self.vid == 'no' else 'V')
|
|
||||||
adds(right.rjust(curses.COLS), curses.color_pair(12))
|
|
||||||
try:
|
|
||||||
left = ' {} {} '.format(
|
|
||||||
sectoosd(self.mp._get_property('time-pos', int),
|
|
||||||
self.mp._get_property('duration', int)),
|
|
||||||
'|' if self.mp._get_property('pause', bool) else '>')
|
|
||||||
title_len = curses.COLS - len(left + right)
|
|
||||||
center = self.mp._get_property('media-title').ljust(title_len)[:title_len]
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
adds(left, curses.color_pair(12))
|
|
||||||
adds(center, curses.color_pair(12) | curses.A_BOLD, x=len(left))
|
|
||||||
if message:
|
|
||||||
self.scr.move(curses.LINES - 1, 0)
|
|
||||||
self.scr.clrtoeol()
|
|
||||||
adds(message.ljust(curses.COLS)[:curses.COLS-1], msgattr,
|
|
||||||
y=curses.LINES-1, x=0)
|
|
||||||
self.scr.refresh()
|
|
||||||
|
|
||||||
def getlink(self, entry):
|
|
||||||
"""Return an URL from the given entry."""
|
|
||||||
with YoutubeDL({'quiet': True}) as ytdl:
|
|
||||||
return entry.setdefault('webpage_url', ytdl.extract_info(
|
|
||||||
entry['url'], download=False, ie_key=entry.get('ie_key')
|
|
||||||
).get('webpage_url'))
|
|
||||||
|
|
||||||
def play(self, force=False):
|
def play(self, force=False):
|
||||||
"""Play the next track."""
|
"""Play the next track."""
|
||||||
def mpv_play(entry, force):
|
def mpv_play(entry, force):
|
||||||
self.active = True
|
self.active = True
|
||||||
self.setno('playing')
|
self.setno('playing')
|
||||||
entry['playing'] = True
|
entry['playing'] = True
|
||||||
self.redraw()
|
self.mp.vid = self.vid
|
||||||
self.mp._set_property('vid', self.vid)
|
|
||||||
try:
|
try:
|
||||||
self.mp.play(self.getlink(entry))
|
self.mp.play(self.getlink(entry))
|
||||||
except:
|
except:
|
||||||
entry['error'] = True
|
entry['error'] = True
|
||||||
if force: self.mp._set_property('pause', False, bool)
|
self.print(entry)
|
||||||
|
if force: self.mp.pause = False
|
||||||
self.mp.wait_for_playback()
|
self.mp.wait_for_playback()
|
||||||
self.active = False
|
self.active = False
|
||||||
entry['playing'] = False
|
entry['playing'] = False
|
||||||
self.redraw()
|
self.print(entry)
|
||||||
|
|
||||||
if self.play_backward and -self.playing < len(self.played):
|
if self.play_backward and -self.playing < len(self.played):
|
||||||
self.playing -= 1
|
self.playing -= 1
|
||||||
|
@ -171,48 +172,66 @@ class Comp(object):
|
||||||
play_thread = Thread(target=mpv_play, args=t, daemon=True)
|
play_thread = Thread(target=mpv_play, args=t, daemon=True)
|
||||||
play_thread.start()
|
play_thread.start()
|
||||||
|
|
||||||
def generic_event_handler(self, event):
|
def uniform(self, entry):
|
||||||
"""Reprint status line and play next entry if the last one is
|
"""Standardize data format."""
|
||||||
ended without caring about the event.
|
for i in 'error', 'playing', 'selected': entry.setdefault(i, False)
|
||||||
"""
|
entry.setdefault('ie_key', entry.get('extractor'))
|
||||||
self.update_status()
|
entry.setdefault('duration', 0)
|
||||||
if not self.active: self.play()
|
if 'title' not in entry: # or 'webpage_url' not in entry:
|
||||||
|
with YoutubeDL({'quiet': True}) as ytdl:
|
||||||
|
entry.update(ytdl.extract_info(entry['url'], download=False,
|
||||||
|
ie_key=entry.get('ie_key')))
|
||||||
|
for i in entry.copy():
|
||||||
|
if i not in ('duration', 'error', 'playing', 'selected',
|
||||||
|
'ie_key', 'title', 'url', 'webpage_url'):
|
||||||
|
entry.pop(i)
|
||||||
|
|
||||||
def reattr(self, y=None):
|
def _writeln(self, y, title, duration, attr):
|
||||||
"""Set the attributes of line y, if y is None the current line
|
title_len = curses.COLS-DURATION_COL_LEN-3
|
||||||
will be picked."""
|
title = justified(title, title_len)
|
||||||
if y is None: y = self.y
|
duration = duration.ljust(DURATION_COL_LEN)
|
||||||
entry = self.entries[self.start + y - 1]
|
self.scr.addstr(y, 0, ' {} {} '.format(title, duration), 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
|
||||||
|
|
||||||
|
self.uniform(entry)
|
||||||
c = {'error': 1, 'playing': 3, 'selected': 5}
|
c = {'error': 1, 'playing': 3, 'selected': 5}
|
||||||
color = ((8 if entry is self.current() else 0)
|
color = ((8 if entry is self.current() else 0)
|
||||||
| reduce(int.__xor__, (c.get(i, 0) for i in entry if entry[i])))
|
| reduce(int.__xor__, (c.get(i, 0) for i in entry if entry[i])))
|
||||||
|
duration = strftime('%H:%M:%S', gmtime(entry['duration']))
|
||||||
if color:
|
if color:
|
||||||
self.scr.chgat(y, 0, curses.color_pair(color) | curses.A_BOLD)
|
self._writeln(y, entry['title'], duration,
|
||||||
|
curses.color_pair(color) | curses.A_BOLD)
|
||||||
else:
|
else:
|
||||||
self.scr.chgat(y, 0, curses.A_NORMAL)
|
self._writeln(y, entry['title'], duration,
|
||||||
|
curses.A_NORMAL)
|
||||||
|
|
||||||
def redraw(self):
|
def redraw(self):
|
||||||
"""Redraw the whole screen."""
|
"""Redraw the whole screen."""
|
||||||
def _max(a): return max(a) if a else 0
|
self._writeln(0, _("Title"), _("Duration"),
|
||||||
self.scr.clear()
|
curses.color_pair(10) | curses.A_BOLD)
|
||||||
self.scr.addstr(0, 1, _('Title'))
|
|
||||||
sitenamelen = max(_max([len(entry['ie_key']) for entry in self.entries]), 6)
|
|
||||||
self.scr.addstr(0, curses.COLS - sitenamelen - 1, _('Source'))
|
|
||||||
self.scr.chgat(0, 0, curses.color_pair(10) | curses.A_BOLD)
|
|
||||||
for i, entry in enumerate(self.entries[self.start:][:curses.LINES-3]):
|
for i, entry in enumerate(self.entries[self.start:][:curses.LINES-3]):
|
||||||
self.scr.addstr(i + 1, 0, entry['ie_key'].rjust(curses.COLS - 1))
|
self.print(entry, i + 1)
|
||||||
self.scr.addstr(i + 1, 1, entry.get('title', '')[:curses.COLS-sitenamelen-3])
|
self.scr.clrtobot()
|
||||||
self.reattr(i + 1)
|
|
||||||
self.update_status()
|
self.update_status()
|
||||||
|
|
||||||
def __init__(self, entries, mode, mpv_vo, mpv_vid, ytdlf):
|
def __init__(self, json_file, entries, mode, mpv_vo, mpv_vid, ytdlf):
|
||||||
self.setno('error', 'playing', 'selected')
|
if mpv_vo is not None: self.mp['vo'] = mpv_vo
|
||||||
|
self.mp.observe_property('mute', lambda x: self.update_status())
|
||||||
if mpv_vo: self.mp['vo'] = mpv_vo
|
self.mp.observe_property('pause', lambda x: self.update_status())
|
||||||
self.mp.observe_property('mute', lambda event: self.update_status())
|
self.mp.observe_property('time-pos', lambda x: self.update_status())
|
||||||
self.mp.observe_property('pause', lambda event: self.update_status())
|
self.mp.observe_property('duration', lambda x: None if self.active
|
||||||
self.mp.observe_property('time-pos', self.generic_event_handler)
|
else self.play())
|
||||||
|
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.cbreak()
|
curses.cbreak()
|
||||||
self.scr.keypad(True)
|
self.scr.keypad(True)
|
||||||
|
@ -226,9 +245,11 @@ class Comp(object):
|
||||||
|
|
||||||
def __enter__(self): return self
|
def __enter__(self): return self
|
||||||
|
|
||||||
def idx(self):
|
def idx(self, entry=None):
|
||||||
"""Return the index of the current entry."""
|
"""Return the index of the current entry."""
|
||||||
return self.start + self.y - 1
|
if entry is None:
|
||||||
|
return self.start + self.y - 1
|
||||||
|
return self.entries.index(entry)
|
||||||
|
|
||||||
def current(self):
|
def current(self):
|
||||||
"""Return the current entry."""
|
"""Return the current entry."""
|
||||||
|
@ -242,9 +263,10 @@ class Comp(object):
|
||||||
if pick == 'current':
|
if pick == 'current':
|
||||||
self.play_list = [self.current()]
|
self.play_list = [self.current()]
|
||||||
elif pick == 'all':
|
elif pick == 'all':
|
||||||
self.play_list = self.entries
|
self.play_list = deque(self.entries)
|
||||||
|
self.play_list.rotate(-self.idx())
|
||||||
else:
|
else:
|
||||||
self.play_list = [i for i in self.entries if i['selected']]
|
self.play_list = [i for i in self.entries if i.get('selected')]
|
||||||
|
|
||||||
def update_playlist(self):
|
def update_playlist(self):
|
||||||
"""Update the playlist to be used by play function."""
|
"""Update the playlist to be used by play function."""
|
||||||
|
@ -256,12 +278,48 @@ class Comp(object):
|
||||||
self.playlist = cycle(self.play_list)
|
self.playlist = cycle(self.play_list)
|
||||||
else:
|
else:
|
||||||
self.playlist = iter(lambda: choice(self.play_list), None)
|
self.playlist = iter(lambda: choice(self.play_list), None)
|
||||||
self.played = self.played[:self.playing]
|
if self.playing < -1: self.played = self.played[:self.playing+1]
|
||||||
|
|
||||||
|
def gets(self, prompt):
|
||||||
|
"""Print the prompt string at the bottom of the screen then read
|
||||||
|
from standard input.
|
||||||
|
"""
|
||||||
|
self.scr.addstr(curses.LINES - 1, 0,
|
||||||
|
justified(prompt, curses.COLS - 1))
|
||||||
|
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 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):
|
||||||
|
comp.play_backward = backward
|
||||||
|
if self.active:
|
||||||
|
self.seek(100, 'absolute-percent')
|
||||||
|
if force: self.mp.pause = False
|
||||||
|
else:
|
||||||
|
self.play(force)
|
||||||
|
|
||||||
|
def download(self):
|
||||||
|
with YoutubeDL({'quiet': True}) as ytdl:
|
||||||
|
ytdl.download([self.getlink(i) for i in self.play_list])
|
||||||
|
|
||||||
def move(self, delta):
|
def move(self, delta):
|
||||||
"""Move to the relatively next delta entry."""
|
"""Move to the relatively next delta entry."""
|
||||||
if not (self.entries and delta): return
|
if not (self.entries and delta): return
|
||||||
start, y = self.start, self.y
|
start, prev_entry = self.start, self.current()
|
||||||
maxy = min(len(self.entries), curses.LINES - 3)
|
maxy = min(len(self.entries), curses.LINES - 3)
|
||||||
|
|
||||||
if self.idx() + delta <= 0:
|
if self.idx() + delta <= 0:
|
||||||
|
@ -278,104 +336,158 @@ class Comp(object):
|
||||||
self.y += delta
|
self.y += delta
|
||||||
|
|
||||||
if self.start == start:
|
if self.start == start:
|
||||||
self.reattr(y)
|
self.print(prev_entry)
|
||||||
self.reattr()
|
self.print()
|
||||||
self.scr.refresh()
|
|
||||||
else:
|
else:
|
||||||
self.redraw()
|
self.redraw()
|
||||||
|
|
||||||
def gets(self, prompt):
|
def search(self, backward=False):
|
||||||
"""Print the prompt string at the bottom of the screen then read
|
"""Prompt then search for a pattern."""
|
||||||
from standard input.
|
p = re.compile(self.gets('/'), re.IGNORECASE)
|
||||||
"""
|
entries = deque(self.entries)
|
||||||
self.scr.addstr(curses.LINES - 1, 0, prompt)
|
entries.rotate(-self.idx())
|
||||||
self.reading = True
|
self.search_res = deque(filter(
|
||||||
curses.curs_set(True)
|
lambda entry: p.search(entry['title']) is not None, entries))
|
||||||
curses.echo()
|
if backward: self.search_res.reverse()
|
||||||
b = self.scr.getstr(curses.LINES - 1, len(prompt))
|
if self.search_res:
|
||||||
self.reading = False
|
self.move(self.idx(self.search_res[0]) - self.idx())
|
||||||
curses.curs_set(False)
|
else:
|
||||||
curses.noecho()
|
self.update_status(_("Pattern not found"), curses.color_pair(1))
|
||||||
return b.decode()
|
|
||||||
|
|
||||||
def seek(self, amount, reference='relative'):
|
def next_search(self, backward=False):
|
||||||
"""Quick hack to fix MPV seek-double bug."""
|
"""Repeat previous search."""
|
||||||
if reference == 'relative': amount /= 2
|
if self.search_res:
|
||||||
try:
|
self.search_res.rotate(1 if backward else -1)
|
||||||
self.mp.seek(amount, reference)
|
self.move(self.idx(self.search_res[0]) - self.idx())
|
||||||
except:
|
else:
|
||||||
pass
|
self.update_status(_("Pattern not found"), curses.color_pair(1))
|
||||||
|
|
||||||
|
def resize(self):
|
||||||
|
curses.update_lines_cols()
|
||||||
|
l = curses.LINES - 3
|
||||||
|
if curses.COLS < MODE_STR_LEN + 42 or l < 1:
|
||||||
|
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])
|
||||||
|
elif self.y > l:
|
||||||
|
self.start += self.y - l
|
||||||
|
self.y = l
|
||||||
|
self.redraw()
|
||||||
|
elif 0 < self.start > len(self.entries) - l:
|
||||||
|
idx, self.start = self.idx(), min(0, len(entries) - l)
|
||||||
|
self.y = idx - self.start + 1
|
||||||
|
if self.y > l:
|
||||||
|
self.start += self.y - l
|
||||||
|
self.y = l
|
||||||
|
self.redraw()
|
||||||
|
else:
|
||||||
|
self.redraw()
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
curses.nocbreak()
|
curses.nocbreak()
|
||||||
self.scr.keypad(False)
|
self.scr.keypad(False)
|
||||||
curses.echo()
|
curses.echo()
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
self.mp.terminate()
|
self.mp.quit()
|
||||||
|
|
||||||
|
|
||||||
parser = ArgumentParser(description=_("Curses Online Media Player"))
|
parser = ArgumentParser(description=_("Curses Online Media Player"))
|
||||||
parser.add_argument('-c', '--config', required=False,
|
parser.add_argument('-c', '--config', required=False,
|
||||||
help=_('path to custom config file'))
|
help=_("location of the configuration file; either the\
|
||||||
|
path to the config or its containing directory"))
|
||||||
|
parser.add_argument('--vid', choices=('ID', 'auto', 'no'), 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) man page 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"))
|
||||||
parser.add_argument('-u', '--online-playlist', required=False, metavar='URL',
|
parser.add_argument('-u', '--online-playlist', required=False, metavar='URL',
|
||||||
help=_('URL to an playlist on Youtube'))
|
help=_("URL to an playlist on Youtube"))
|
||||||
parser.add_argument('-j', '--json-playlist', required=False, metavar='path',
|
parser.add_argument('-j', '--json', required=False, metavar='JSON_PLAYLIST',
|
||||||
help=_('path to playlist in JSON format'))
|
help=_("path to playlist in JSON format. If\
|
||||||
|
--online-playlist is already specified, this will\
|
||||||
|
be used as the default file to save the playlist"))
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
config = ConfigParser()
|
config = ConfigParser()
|
||||||
config.read(USER_CONFIG if isfile(USER_CONFIG) else SYSTEM_CONFIG)
|
if args.config is not None:
|
||||||
|
if isfile(args.config):
|
||||||
|
config_file = args.config
|
||||||
|
else: #isdir(args.config_location):
|
||||||
|
config_file = join(args.config, 'settings.ini')
|
||||||
|
elif isfile(USER_CONFIG):
|
||||||
|
config_file = USER_CONFIG
|
||||||
|
else:
|
||||||
|
config_file = SYSTEM_CONFIG
|
||||||
|
config.read(config_file)
|
||||||
|
|
||||||
if args.json_playlist:
|
if args.json is not None:
|
||||||
json_file = args.json_playlist
|
json_file = args.json
|
||||||
with open(json_file) as f:
|
else:
|
||||||
entries = json.load(f)
|
json_file = ''
|
||||||
elif args.online_playlist:
|
|
||||||
|
if args.online_playlist is not None:
|
||||||
with YoutubeDL({'extract_flat': 'in_playlist', 'quiet': True}) as ytdl:
|
with YoutubeDL({'extract_flat': 'in_playlist', 'quiet': True}) as ytdl:
|
||||||
info = ytdl.extract_info(args.online_playlist, download=False)
|
info = ytdl.extract_info(args.online_playlist, download=False)
|
||||||
entries = info.get('entries', [])
|
entries = info.get('entries', [info])
|
||||||
json_file = ''
|
|
||||||
else:
|
else:
|
||||||
entries = []
|
try:
|
||||||
json_file = ''
|
with open(json_file) as f: entries = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
if args.vid is not None:
|
||||||
|
vid = args.vid
|
||||||
|
else:
|
||||||
|
vid = config.get('mpv', 'video', fallback='auto')
|
||||||
|
|
||||||
|
if args.vo is not None:
|
||||||
|
vo = args.vo
|
||||||
|
else:
|
||||||
|
vo = config.get('mpv', 'video-output', fallback=None)
|
||||||
|
|
||||||
|
mode = config.get('comp', 'play-mode', fallback='play-current')
|
||||||
|
|
||||||
|
if args.format is not None:
|
||||||
|
ytdlf = args.format
|
||||||
|
else:
|
||||||
|
ytdlf = config.get('youtube-dl', 'format', fallback='best')
|
||||||
|
|
||||||
makedirs(dirname(MPV_LOG), exist_ok=True)
|
makedirs(dirname(MPV_LOG), exist_ok=True)
|
||||||
|
|
||||||
with Comp(
|
with Comp(entries, json_file, mode, vo, vid, ytdlf) as comp:
|
||||||
entries,
|
|
||||||
config.get('comp', 'play-mode', fallback='play-current'),
|
|
||||||
config.get('mpv', 'video-output', fallback=None),
|
|
||||||
config.get('mpv', 'video', fallback='auto'),
|
|
||||||
config.get('youtube-dl', 'format', fallback='best')
|
|
||||||
) as comp:
|
|
||||||
c = comp.scr.getch()
|
c = comp.scr.getch()
|
||||||
while c != 113: # letter q
|
while c != 113: # letter q
|
||||||
if c == 10: # curses.KEY_ENTER doesn't work
|
if c == 10: # curses.KEY_ENTER doesn't work
|
||||||
comp.update_playlist()
|
comp.update_playlist()
|
||||||
if comp.active:
|
comp.next(force=True)
|
||||||
comp.seek(100, 'absolute-percent')
|
|
||||||
comp.mp._set_property('pause', False, bool)
|
|
||||||
else:
|
|
||||||
comp.play(force=True)
|
|
||||||
elif c == 32: # space
|
elif c == 32: # space
|
||||||
comp.current()['selected'] = not comp.current()['selected']
|
comp.current()['selected'] = not comp.current().get('selected')
|
||||||
comp.move(1)
|
comp.move(1)
|
||||||
|
elif c == 47: # /
|
||||||
|
comp.search()
|
||||||
elif c == 60: # <
|
elif c == 60: # <
|
||||||
try:
|
try:
|
||||||
if comp.mp._get_property('time-pos', int) < 5:
|
if comp.mp._get_property('time-pos', float) < 1:
|
||||||
comp.play_backward = True
|
comp.next(backward=True)
|
||||||
comp.seek(100, 'absolute-percent')
|
|
||||||
else:
|
else:
|
||||||
comp.seek(0, 'absolute')
|
comp.seek(0, 'absolute')
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
elif c == 62: # >
|
elif c == 62: # >
|
||||||
comp.seek(100, 'absolute-percent')
|
comp.next()
|
||||||
|
elif c == 63: # ?
|
||||||
|
comp.search(backward=True)
|
||||||
elif c == 65: # letter A
|
elif c == 65: # letter A
|
||||||
comp.mp._toggle_property('mute')
|
comp.mp._toggle_property('mute')
|
||||||
elif c == 77: # letter M
|
elif c == 77: # 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 == 78: # letter N
|
||||||
|
comp.next_search(backward=True)
|
||||||
elif c == 85: # letter U
|
elif c == 85: # letter U
|
||||||
with YoutubeDL({'extract_flat': True, 'quiet': True}) as ytdl:
|
with YoutubeDL({'extract_flat': True, 'quiet': True}) as ytdl:
|
||||||
try:
|
try:
|
||||||
|
@ -384,30 +496,26 @@ with Comp(
|
||||||
except:
|
except:
|
||||||
comp.redraw()
|
comp.redraw()
|
||||||
else:
|
else:
|
||||||
comp.entries = info.get('entries', {})
|
comp.entries = info.get('entries', [info])
|
||||||
comp.start, comp.y = 0, 1
|
comp.start, comp.y = 0, 1
|
||||||
comp.setno('error', 'playing', 'selected')
|
|
||||||
comp.redraw()
|
comp.redraw()
|
||||||
elif c == 86: # letter V
|
elif c == 86: # letter V
|
||||||
comp.vid = 'auto' if comp.vid == 'no' else 'no'
|
comp.vid = 'auto' if comp.vid == 'no' else 'no'
|
||||||
comp.mp._set_property('vid', comp.vid)
|
comp.mp.vid = comp.vid
|
||||||
comp.update_status()
|
comp.update_status()
|
||||||
elif c == 87: # letter W
|
elif c == 87: # letter W
|
||||||
if not comp.entries: continue
|
s = comp.gets(_("Save playlist to [{}]: ").format(comp.json_file))
|
||||||
s = comp.gets(_('Save playlist to [{}]: ').format(json_file))
|
if s: comp.json_file = s
|
||||||
if s: json_file = s
|
|
||||||
try:
|
try:
|
||||||
makedirs(dirname(abspath(json_file)), exist_ok=True)
|
makedirs(dirname(abspath(comp.json_file)), exist_ok=True)
|
||||||
with open(json_file, 'w') as f:
|
with open(comp.json_file, 'w') as f: json.dump(comp.entries, f)
|
||||||
json.dump(entries, f)
|
|
||||||
except:
|
except:
|
||||||
comp.update_status(
|
errmsg = _("'{}': Can't open file for writing").format(
|
||||||
_("'{}': Can't open file for writing").format(json_file),
|
comp.json_file)
|
||||||
curses.color_pair(1))
|
comp.update_status(errmsg, curses.color_pair(1))
|
||||||
else:
|
else:
|
||||||
comp.update_status(_("'{}' written").format(json_file))
|
comp.update_status(_("'{}' written").format(comp.json_file))
|
||||||
elif c == 100: # letter d
|
elif c == 100: # letter d
|
||||||
if not comp.entries: continue
|
|
||||||
i = comp.idx()
|
i = comp.idx()
|
||||||
if i + 1 < len(entries):
|
if i + 1 < len(entries):
|
||||||
comp.entries.pop(i)
|
comp.entries.pop(i)
|
||||||
|
@ -421,18 +529,20 @@ with Comp(
|
||||||
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
|
||||||
|
comp.next_search()
|
||||||
elif c == 119: # letter w
|
elif c == 119: # letter w
|
||||||
comp.update_play_list(comp.mode.split('-')[1])
|
comp.update_play_list(comp.mode.split('-')[1])
|
||||||
with YoutubeDL({'quiet': True}) as ytdl:
|
play_thread = Thread(target=comp.download, daemon=True)
|
||||||
ytdl.download([comp.getlink(i) for i in comp.play_list])
|
play_thread.start()
|
||||||
elif c in (curses.KEY_UP, 107): # up arrow or letter k
|
elif c in (curses.KEY_UP, 107): # up arrow or letter k
|
||||||
comp.move(-1)
|
comp.move(-1)
|
||||||
elif c in (curses.KEY_DOWN, 106): # down arrow or letter j
|
elif c in (curses.KEY_DOWN, 106): # down arrow or letter j
|
||||||
comp.move(1)
|
comp.move(1)
|
||||||
elif c in (curses.KEY_LEFT, 104): # left arrow or letter h
|
elif c in (curses.KEY_LEFT, 104): # left arrow or letter h
|
||||||
comp.seek(-5)
|
comp.seek(-5, precision='exact')
|
||||||
elif c in (curses.KEY_RIGHT, 108): # right arrow or letter l
|
elif c in (curses.KEY_RIGHT, 108): # right arrow or letter l
|
||||||
comp.seek(5)
|
comp.seek(5, precision='exact')
|
||||||
elif c == curses.KEY_HOME: # home
|
elif c == curses.KEY_HOME: # home
|
||||||
comp.move(-len(comp.entries))
|
comp.move(-len(comp.entries))
|
||||||
elif c == curses.KEY_END: # end
|
elif c == curses.KEY_END: # end
|
||||||
|
@ -441,17 +551,6 @@ with Comp(
|
||||||
comp.move(curses.LINES - 4)
|
comp.move(curses.LINES - 4)
|
||||||
elif c == curses.KEY_PPAGE: # page up
|
elif c == curses.KEY_PPAGE: # page up
|
||||||
comp.move(4 - curses.LINES)
|
comp.move(4 - curses.LINES)
|
||||||
elif c == curses.KEY_F5: # F5
|
elif c in (curses.KEY_F5, curses.KEY_RESIZE):
|
||||||
comp.redraw()
|
comp.resize()
|
||||||
elif c == curses.KEY_RESIZE:
|
|
||||||
curses.update_lines_cols()
|
|
||||||
if curses.COLS < MODE_STR_LEN + 42 or curses.LINES < 4:
|
|
||||||
comp.scr.clear()
|
|
||||||
sizeerr = _('Current size: {}x{}. Minimum size: {}x4.').format(
|
|
||||||
curses.COLS, curses.LINES, MODE_STR_LEN + 42)
|
|
||||||
comp.scr.addstr(0, 0, sizeerr[:curses.LINES*curses.COLS])
|
|
||||||
else:
|
|
||||||
comp.start += comp.y - 1
|
|
||||||
comp.y = 1
|
|
||||||
comp.redraw()
|
|
||||||
c = comp.scr.getch()
|
c = comp.scr.getch()
|
||||||
|
|
14
settings.ini
14
settings.ini
|
@ -1,15 +1,17 @@
|
||||||
[comp]
|
[comp]
|
||||||
# Supported 8 modes: play-current, play-all, play-selected, repeat-current,
|
# Initial playing mode, which can be one of these 8 modes: play-current,
|
||||||
# repeat-all, repeat-selected, shuffle-all and shuffle-selected.
|
# play-all, play-selected, repeat-current, repeat-all, repeat-selected,
|
||||||
|
# shuffle-all and shuffle-selected.
|
||||||
play-mode = play-current
|
play-mode = play-current
|
||||||
|
|
||||||
[mpv]
|
[mpv]
|
||||||
# Set if video should be download and play, I only know 2 possible values:
|
# Initial video channel. auto selects the default, no disables video.
|
||||||
# auto and no. This can be changed later interactively.
|
|
||||||
video = auto
|
video = auto
|
||||||
# Read more on VIDEO OUTPUT DRIVERS section in mpv man page.
|
# 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 =
|
video-output =
|
||||||
|
|
||||||
[youtube-dl]
|
[youtube-dl]
|
||||||
# Read more on FORMAT SELECTION section in youtube-dl man page.
|
# 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
|
format = best
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -8,7 +8,7 @@ from sys import prefix
|
||||||
with open('README.rst') as f:
|
with open('README.rst') as f:
|
||||||
long_description = f.read()
|
long_description = f.read()
|
||||||
|
|
||||||
setup(name='comp', version='0.2.0a1',
|
setup(name='comp', version='0.2.1',
|
||||||
url='https://github.com/McSinyx/comp',
|
url='https://github.com/McSinyx/comp',
|
||||||
description=('Curses Online Media Player'),
|
description=('Curses Online Media Player'),
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
|
@ -19,7 +19,7 @@ setup(name='comp', version='0.2.0a1',
|
||||||
for i in walk('locale') if i[2]),
|
for i in walk('locale') if i[2]),
|
||||||
('/etc/comp', ['settings.ini'])
|
('/etc/comp', ['settings.ini'])
|
||||||
], classifiers=[
|
], classifiers=[
|
||||||
'Development Status :: 3 - Alpha',
|
'Development Status :: 4 - Beta',
|
||||||
'Environment :: Console :: Curses',
|
'Environment :: Console :: Curses',
|
||||||
'Intended Audience :: End Users/Desktop',
|
'Intended Audience :: End Users/Desktop',
|
||||||
'License :: OSI Approved :: GNU Affero General Public License v3',
|
'License :: OSI Approved :: GNU Affero General Public License v3',
|
||||||
|
|
Loading…
Reference in a new issue