Add Youtube playlist argument, seek function and patial support for translation using gettext

This commit is contained in:
Nguyễn Gia Phong 2017-04-05 11:39:04 +07:00
parent 36f73c36f1
commit c224c24b84
7 changed files with 219 additions and 284 deletions

View File

@ -25,22 +25,24 @@ this moment, I'd suggest you to use ``git`` to get the software::
git clone https://github.com/McSinyx/comp.git git clone https://github.com/McSinyx/comp.git
cd comp cd comp
./setup.py install --user sudo ./setup.py install --user
Usage Usage
----- -----
Command line arguments:: ::
$ comp -h $ comp --help
usage: comp [-h] [-j JSON_PLAYLIST] usage: comp [-h] [-j JSON_PLAYLIST]
console/curses online media mp 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 -j JSON_PLAYLIST, --json-playlist JSON_PLAYLIST
path to playlist in JSON format path to playlist in JSON format
-y YOUTUBE_PLAYLIST, --youtube-playlist YOUTUBE_PLAYLIST
URL to an playlist on Youtube
Keyboard control Keyboard control
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
@ -60,6 +62,10 @@ Keyboard control
+--------------+-------------------------------+ +--------------+-------------------------------+
| End | Move to the end of the list | | End | Move to the end of the list |
+--------------+-------------------------------+ +--------------+-------------------------------+
| Left | Seek backward 5 seconds |
+--------------+-------------------------------+
| Right | Seek forward 5 seconds |
+--------------+-------------------------------+
| ``c`` | Select the current track | | ``c`` | Select the current track |
+--------------+-------------------------------+ +--------------+-------------------------------+
| ``p`` | Start playing | | ``p`` | Start playing |
@ -73,24 +79,25 @@ Keyboard control
| ``V`` | Toggle video | | ``V`` | Toggle video |
+--------------+-------------------------------+ +--------------+-------------------------------+
Configurations Configuration files
-------------- -------------------
``comp`` uses INI format for its config file, placed in The system-wide configuration file is ``/etc/comp/settings.ini``, the
``~/.config/comp/settings.ini``:: user-specific one is ``~/.config/mpv/settings.ini``. Default configurations
are listed below::
[comp] [comp]
# Supported 8 modes: play-current, play-all, play-selected, repeat-current, # Supported 8 modes: play-current, play-all, play-selected, repeat-current,
# repeat-all, repeat-selected, shuffle-all and shuffle-selected # repeat-all, repeat-selected, shuffle-all and shuffle-selected.
play-mode = shuffle-selected play-mode = play-current
[mpv] [mpv]
# Set if video should be download and play, I only know 2 possible values: # Set if video should be download and play, I only know 2 possible values:
# auto and no. This can be changed later interactively. # auto and no. This can be changed later interactively.
video = no video = auto
# Read more on VIDEO OUTPUT DRIVERS section in mpv man page # Read more on VIDEO OUTPUT DRIVERS section in mpv man page.
video-output = xv video-output =
[youtube-dl] [youtube-dl]
# Read more on youtube-dl man page # Read more on FORMAT SELECTION section in youtube-dl man page.
format = best format = best

187
comp
View File

@ -18,32 +18,65 @@
import curses import curses
import json import json
import subprocess
from argparse import ArgumentParser from argparse import ArgumentParser
from configparser import ConfigParser from configparser import ConfigParser
from datetime import datetime
from gettext import bindtextdomain, gettext, textdomain
from io import StringIO
from itertools import cycle from itertools import cycle
from os.path import expanduser from os import linesep, makedirs
from os.path import dirname, expanduser, isfile
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
from mpv import MPV from mpv import MPV
# Init gettext
bindtextdomain('comp', 'locale')
textdomain('comp')
_ = gettext
GLOBAL_CONFIG = '/etc/comp/settings.ini'
USER_CONFIG = expanduser('~/.config/comp/settings.ini')
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')
# I ain't found the correct way to do this yet
_MODES = {'play-current': _('play-current'),
'play-all': _('play-all'),
'play-selected': _('play-selected'),
'repeat-current': _('repeat-current'),
'repeat-all': _('repeat-all'),
'repeat-selected': _('repeat-selected'),
'shuffle-all': _('shuffle-all'),
'shuffle-selected': _('shuffle-selected')}
MODE_STR_LEN = max(len(mode) for mode in _MODES.values())
def setno(data, keys): def mpv_logger(loglevel, component, message):
"""Set all keys of each track in data to False.""" mpv_log = '{} [{}] {}: {}{}'.format(datetime.isoformat(datetime.now()),
loglevel, component, message, linesep)
with open(MPV_LOG, 'a') as f:
f.write(mpv_log)
def setno(entries, keys):
"""Set all keys of each track in entries to False."""
for key in keys: for key in keys:
for track in data: for track in entries:
track[key] = False track[key] = False
def playlist(mode): def playlist(mode):
"""Return a generator of tracks to be played.""" """Return a generator of tracks to be played."""
action, choose_from = mode.split('-') action, choose_from = mode.split('-')
if choose_from == 'all': tracks = data if choose_from == 'all':
else: tracks = [track for track in data if track[choose_from]] tracks = entries
else:
tracks = [track for track in entries
if track.setdefault(choose_from, False)]
# Somehow yield have to be used instead of returning a generator # Somehow yield have to be used instead of returning a generator
if action == 'play': if action == 'play':
for track in tracks: yield track for track in tracks: yield track
@ -55,11 +88,13 @@ def playlist(mode):
def play(): def play():
for track in playlist(mode): for track in playlist(mode):
setno(data, ['playing']) entries[entries.index(track)]['playing'] = True
data[data.index(track)]['playing'] = True reprint(stdscr, entries[start : start+curses.LINES-3])
reprint(stdscr, data[start : start+curses.LINES-3]) # Gross hack
mp.play('https://youtu.be/' + track['url']) mp.play('https://youtu.be/' + track['url'])
mp.wait_for_playback() mp.wait_for_playback()
entries[entries.index(track)]['playing'] = False
reprint(stdscr, entries[start : start+curses.LINES-3])
def secpair2hhmmss(pos, duration): def secpair2hhmmss(pos, duration):
@ -77,7 +112,7 @@ def secpair2hhmmss(pos, duration):
def update_status_line(stdscr, mp): def update_status_line(stdscr, mp):
left = ' ' + secpair2hhmmss(mp._get_property('time-pos', int), left = ' ' + secpair2hhmmss(mp._get_property('time-pos', int),
mp._get_property('duration', int)) mp._get_property('duration', int))
right = ' {} {}{} '.format(mode, right = ' {} {}{} '.format(_MODES[mode],
' ' if mp._get_property('mute', bool) else 'A', ' ' if mp._get_property('mute', bool) else 'A',
' ' if mp._get_property('vid') == 'no' else 'V') ' ' if mp._get_property('vid') == 'no' else 'V')
if left != ' ': if left != ' ':
@ -96,12 +131,12 @@ def update_status_line(stdscr, mp):
def reattr(stdscr, y, track): def reattr(stdscr, y, track):
invert = 8 if track['current'] else 0 invert = 8 if track.setdefault('current', False) else 0
if track['error']: if track.setdefault('error', False):
stdscr.chgat(y, 0, curses.color_pair(1 + invert) | curses.A_BOLD) stdscr.chgat(y, 0, curses.color_pair(1 + invert) | curses.A_BOLD)
elif track['playing']: elif track.setdefault('playing', False):
stdscr.chgat(y, 0, curses.color_pair(3 + invert) | curses.A_BOLD) stdscr.chgat(y, 0, curses.color_pair(3 + invert) | curses.A_BOLD)
elif track['selected']: elif track.setdefault('selected', False):
stdscr.chgat(y, 0, curses.color_pair(5 + invert) | curses.A_BOLD) stdscr.chgat(y, 0, curses.color_pair(5 + invert) | curses.A_BOLD)
elif invert: elif invert:
stdscr.chgat(y, 0, curses.color_pair(12) | curses.A_BOLD) stdscr.chgat(y, 0, curses.color_pair(12) | curses.A_BOLD)
@ -109,12 +144,12 @@ def reattr(stdscr, y, track):
stdscr.chgat(y, 0, curses.color_pair(0) | curses.A_NORMAL) stdscr.chgat(y, 0, curses.color_pair(0) | curses.A_NORMAL)
def reprint(stdscr, data2print): def reprint(stdscr, tracks2print):
stdscr.clear() stdscr.clear()
stdscr.addstr(0, curses.COLS-12, 'URL') stdscr.addstr(0, curses.COLS-12, _('URL'))
stdscr.addstr(0, 1, 'Title') stdscr.addstr(0, 1, _('Title'))
stdscr.chgat(0, 0, curses.color_pair(10) | curses.A_BOLD) stdscr.chgat(0, 0, curses.color_pair(10) | curses.A_BOLD)
for i, track in enumerate(data2print): for i, track in enumerate(tracks2print):
y = i + 1 y = i + 1
stdscr.addstr(y, 0, track['url'].rjust(curses.COLS - 1)) stdscr.addstr(y, 0, track['url'].rjust(curses.COLS - 1))
stdscr.addstr(y, 1, track['title'][:curses.COLS-14]) stdscr.addstr(y, 1, track['title'][:curses.COLS-14])
@ -122,64 +157,74 @@ def reprint(stdscr, data2print):
update_status_line(stdscr, mp) update_status_line(stdscr, mp)
def move(stdscr, data, y, delta): def move(stdscr, entries, y, delta):
global start global start
if start + y + delta < 1: if start + y + delta < 1:
if start + y == 1: if start + y == 1:
return 1 return 1
start = 0 start = 0
setno(data, ['current']) setno(entries, ['current'])
data[0]['current'] = True entries[0]['current'] = True
reprint(stdscr, data[:curses.LINES-3]) reprint(stdscr, entries[:curses.LINES-3])
return 1 return 1
elif start + y + delta > len(data): elif start + y + delta > len(entries):
if start + y == len(data): if start + y == len(entries):
return curses.LINES - 3 return curses.LINES - 3
start = len(data) - curses.LINES + 3 start = len(entries) - curses.LINES + 3
y = curses.LINES - 3 y = curses.LINES - 3
setno(data, ['current']) setno(entries, ['current'])
data[-1]['current'] = True entries[-1]['current'] = True
reprint(stdscr, data[-curses.LINES+3:]) reprint(stdscr, entries[-curses.LINES+3:])
return y return y
if y + delta < 1: if y + delta < 1:
start += y + delta - 1 start += y + delta - 1
y = 1 y = 1
setno(data, ['current']) setno(entries, ['current'])
data[start]['current'] = True entries[start]['current'] = True
reprint(stdscr, data[start : start+curses.LINES-3]) reprint(stdscr, entries[start : start+curses.LINES-3])
elif y + delta > curses.LINES - 3: elif y + delta > curses.LINES - 3:
start += y + delta - curses.LINES + 3 start += y + delta - curses.LINES + 3
y = curses.LINES - 3 y = curses.LINES - 3
setno(data, ['current']) setno(entries, ['current'])
data[start + curses.LINES - 4]['current'] = True entries[start + curses.LINES - 4]['current'] = True
reprint(stdscr, data[start : start+curses.LINES-3]) reprint(stdscr, entries[start : start+curses.LINES-3])
else: else:
data[start + y - 1]['current'] = False entries[start + y - 1]['current'] = False
reattr(stdscr, y, data[start + y - 1]) reattr(stdscr, y, entries[start + y - 1])
y = y + delta y = y + delta
data[start + y - 1]['current'] = True entries[start + y - 1]['current'] = True
reattr(stdscr, y, data[start + y - 1]) reattr(stdscr, y, entries[start + y - 1])
stdscr.refresh() stdscr.refresh()
return y return y
parser = ArgumentParser(description="console/curses online media mp") parser = ArgumentParser(description="Curses Online Media Player")
parser.add_argument('-j', '--json-playlist', required=False, parser.add_argument('-j', '--json-playlist', required=False,
help='path to playlist in JSON format') help=_('path to playlist in JSON format'))
parser.add_argument('-y', '--youtube-playlist', required=False,
help=_('URL to an playlist on Youtube'))
args = parser.parse_args() args = parser.parse_args()
config = ConfigParser() config = ConfigParser()
config.read(expanduser('~/.config/comp/settings.ini')) config.read(USER_CONFIG if isfile(USER_CONFIG) else GLOBAL_CONFIG)
mode = config.get('comp', 'play-mode', fallback='play-all') mode = config.get('comp', 'play-mode', fallback='play-current')
video = config.get('mpv', 'video', fallback='auto') video = config.get('mpv', 'video', fallback='auto')
video_output = config.get('mpv', 'video-output', fallback='') video_output = config.get('mpv', 'video-output', fallback=None)
ytdlf = config.get('youtube-dl', 'format', fallback='best') ytdlf = config.get('youtube-dl', 'format', fallback='best')
with open(args.json_playlist) as f: if args.json_playlist:
data = json.load(f) with open(args.json_playlist) as f:
setno(data, ['error', 'playing', 'selected', 'current']) entries = json.load(f)['entries']
elif args.youtube_playlist:
# Extremely gross hack
raw_json = subprocess.run(['youtube-dl', '--flat-playlist',
'--dump-single-json', args.youtube_playlist],
stdout=subprocess.PIPE).stdout
entries = json.load(StringIO(raw_json.decode()))['entries']
#setno(entries, ['error', 'playing', 'selected', 'current'])
# Init curses screen
stdscr = curses.initscr() stdscr = curses.initscr()
curses.noecho() curses.noecho()
curses.cbreak() curses.cbreak()
@ -202,8 +247,10 @@ curses.init_pair(12, -1, 4)
curses.init_pair(13, -1, 5) curses.init_pair(13, -1, 5)
curses.init_pair(14, -1, 6) curses.init_pair(14, -1, 6)
# Init mpv
makedirs(expanduser(dirname(MPV_LOG)), exist_ok=True)
mp = MPV(input_default_bindings=True, input_vo_keyboard=True, mp = MPV(input_default_bindings=True, input_vo_keyboard=True,
ytdl=True, ytdl_format=ytdlf) log_handler=mpv_logger, ytdl=True, ytdl_format=ytdlf)
if video_output: mp['vo'] = video_output if video_output: mp['vo'] = video_output
mp._set_property('vid', video) mp._set_property('vid', video)
mp.observe_property('mute', lambda foo: update_status_line(stdscr, mp)) mp.observe_property('mute', lambda foo: update_status_line(stdscr, mp))
@ -214,33 +261,46 @@ mp.observe_property('vid', lambda foo: update_status_line(stdscr, mp))
# Print initial content # Print initial content
start = 0 start = 0
y = 1 y = 1
data[0]['current'] = True entries[0]['current'] = True
reprint(stdscr, data[:curses.LINES-3]) reprint(stdscr, entries[:curses.LINES-3])
# mpv keys: []{}<>.,qQ/*90m-#fTweoPOvjJxzlLVrtsSIdA
# yuighkcbn
c = stdscr.getch() c = stdscr.getch()
while c != 113: # letter q while c != 113: # letter q
if c == curses.KEY_RESIZE: if c == curses.KEY_RESIZE:
curses.update_lines_cols() curses.update_lines_cols()
start += y - 1 if curses.COLS < MODE_STR_LEN + 42 or curses.LINES < 4:
y = 1 stdscr.clear()
reprint(stdscr, data[start : start+curses.LINES-3]) scr_size_warn = 'Current size: {}x{}. Minimum size: {}x4.'.format(
curses.COLS,
curses.LINES,
MODE_STR_LEN + 42
)
stdscr.addstr(0, 0, scr_size_warn)
else:
start += y - 1
y = 1
reprint(stdscr, entries[start : start+curses.LINES-3])
elif c in (107, curses.KEY_UP): # letter k or up arrow elif c in (107, curses.KEY_UP): # letter k or up arrow
y = move(stdscr, data, y, -1) y = move(stdscr, entries, y, -1)
elif c in (106, curses.KEY_DOWN): # letter j or down arrow elif c in (106, curses.KEY_DOWN): # letter j or down arrow
y = move(stdscr, data, y, 1) y = move(stdscr, entries, y, 1)
elif c == curses.KEY_PPAGE: # page up elif c == curses.KEY_PPAGE: # page up
y = move(stdscr, data, y, 4 - curses.LINES) y = move(stdscr, entries, y, 4 - curses.LINES)
elif c == curses.KEY_NPAGE: # page down elif c == curses.KEY_NPAGE: # page down
y = move(stdscr, data, y, curses.LINES - 4) y = move(stdscr, entries, y, curses.LINES - 4)
elif c == curses.KEY_HOME: # home elif c == curses.KEY_HOME: # home
y = move(stdscr, data, y, -len(data)) y = move(stdscr, entries, y, -len(entries))
elif c == curses.KEY_END: # end elif c == curses.KEY_END: # end
y = move(stdscr, data, y, len(data)) y = move(stdscr, entries, y, len(entries))
elif c == curses.KEY_LEFT: # left arrow
if mp._get_property('duration', int):
mp.seek(-2.5)
elif c == curses.KEY_RIGHT: # right arrow
if mp._get_property('duration', int):
mp.seek(2.5)
elif c == 99: # letter c elif c == 99: # letter c
data[start + y - 1]['selected'] = not data[start + y - 1]['selected'] entries[start + y - 1]['selected'] = not entries[start + y - 1]['selected']
y = move(stdscr, data, y, 1) y = move(stdscr, entries, y, 1)
elif c == 112: # letter p elif c == 112: # letter p
mp._set_property('pause', False, bool) mp._set_property('pause', False, bool)
play_thread = Thread(target=play) play_thread = Thread(target=play)
@ -257,7 +317,8 @@ while c != 113: # letter q
elif c == 65: # letter A elif c == 65: # letter A
mp._set_property('mute', not mp._get_property('mute', bool), bool) mp._set_property('mute', not mp._get_property('mute', bool), bool)
elif c == 86: # letter V elif c == 86: # letter V
mp._set_property('vid', 'auto' if mp._get_property('vid') == 'no' else 'no') mp._set_property('vid',
'auto' if mp._get_property('vid') == 'no' else 'no')
c = stdscr.getch() c = stdscr.getch()
curses.nocbreak() curses.nocbreak()

Binary file not shown.

View File

@ -0,0 +1,66 @@
# 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-05 11:05+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"
#: comp:42
msgid "play-current"
msgstr "chơi-một"
#: comp:43
msgid "play-all"
msgstr "chơi-tất-cả"
#: comp:44
msgid "play-selected"
msgstr "chơi-đã-chọn"
#: comp:45
msgid "repeat-current"
msgstr "lặp-một"
#: comp:46
msgid "repeat-all"
msgstr "lặp-tất-cả"
#: comp:47
msgid "repeat-selected"
msgstr "lặp-đã-chọn"
#: comp:48
msgid "shuffle-all"
msgstr "ngẫu-nhiên-tất-cả"
#: comp:49
msgid "shuffle-selected"
msgstr "ngẫu-nhiên-đã-chọn"
#: comp:144
msgid "URL"
msgstr "URL"
#: comp:145
msgid "Title"
msgstr "Tiêu đề"
#: comp:199
msgid "path to playlist in JSON format"
msgstr "đường dẫn đến playlist ở định dạng JSON"
#: comp:201
msgid "URL to an playlist on Youtube"
msgstr "URL của playlist trên Youtube"

View File

@ -1,15 +1,15 @@
[comp] [comp]
# Supported 8 modes: play-current, play-all, play-selected, repeat-current, # Supported 8 modes: play-current, play-all, play-selected, repeat-current,
# repeat-all, repeat-selected, shuffle-all and shuffle-selected # repeat-all, repeat-selected, shuffle-all and shuffle-selected.
play-mode = shuffle-selected play-mode = play-current
[mpv] [mpv]
# Set if video should be download and play, I only know 2 possible values: # Set if video should be download and play, I only know 2 possible values:
# auto and no. This can be changed later interactively. # auto and no. This can be changed later interactively.
video = no video = auto
# Read more on VIDEO OUTPUT DRIVERS section in mpv man page # Read more on VIDEO OUTPUT DRIVERS section in mpv man page.
video-output = xv video-output =
[youtube-dl] [youtube-dl]
# Read more on youtube-dl man page # Read more on FORMAT SELECTION section in youtube-dl man page.
format = best format = best

View File

@ -12,7 +12,7 @@ setup(name = 'comp', version = '0.1.1a1',
long_description=long_description, long_description=long_description,
author = 'McSinyx', author_email = 'vn.mcsinyx@gmail.com', author = 'McSinyx', author_email = 'vn.mcsinyx@gmail.com',
py_modules = ['mpv'], scripts=['comp'], py_modules = ['mpv'], scripts=['comp'],
data_files=[(expanduser('~/.config/comp'), ['settings.ini'])], data_files=[('/etc/comp', ['settings.ini'])],
classifiers = [ classifiers = [
'Development Status :: 3 - Alpha', 'Development Status :: 3 - Alpha',
'Environment :: Console :: Curses', 'Environment :: Console :: Curses',

201
test.json

File diff suppressed because one or more lines are too long