comp/comp

386 lines
14 KiB
Plaintext
Raw Normal View History

2017-03-20 14:49:11 +01:00
#!/usr/bin/env python3
2017-04-03 15:52:59 +02:00
2017-03-26 10:26:37 +02:00
# comp - Curses Online Media Player
# Copyright (C) 2017 Nguyễn Gia Phong
2017-03-26 10:26:37 +02:00
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
2017-03-20 14:49:11 +01:00
import curses
import json
from argparse import ArgumentParser
from configparser import ConfigParser
from curses.ascii import ctrl
from datetime import datetime
from gettext import bindtextdomain, gettext, textdomain
from io import StringIO
2017-04-03 15:52:59 +02:00
from itertools import cycle
from os import linesep, makedirs
from os.path import abspath, dirname, expanduser, isfile
2017-04-03 15:52:59 +02:00
from random import choice
2017-03-30 06:14:51 +02:00
from time import gmtime, strftime
2017-04-03 15:52:59 +02:00
from threading import Thread
2017-03-20 14:49:11 +01:00
2017-04-08 15:47:16 +02:00
from youtube_dl import YoutubeDL
2017-03-30 06:14:51 +02:00
from mpv import MPV
2017-03-20 14:49:11 +01:00
# Init gettext
textdomain('comp')
_ = gettext
SYSTEM_CONFIG = '/etc/comp/settings.ini'
USER_CONFIG = expanduser('~/.config/comp/settings.ini')
MPV_LOG = expanduser('~/.cache/comp/mpv.log')
2017-04-03 15:52:59 +02:00
MODES = ('play-current', 'play-all', 'play-selected', 'repeat-current',
'repeat-all', 'repeat-selected', 'shuffle-all', 'shuffle-selected')
MODE_STR_LEN = max(len(_(mode)) for mode in MODES)
def mpv_logger(loglevel, component, message):
mpv_log = '{} [{}] {}: {}{}'.format(datetime.isoformat(datetime.now()),
loglevel, component, message, linesep)
with open(MPV_LOG, 'a') as f:
f.write(mpv_log)
2017-04-03 15:52:59 +02:00
2017-03-20 14:49:11 +01:00
def setno(entries, keys):
"""Set all keys of each entry in entries to False."""
2017-03-30 06:14:51 +02:00
for key in keys:
for entry in entries:
entry[key] = False
def getlink(entry):
2017-04-08 15:47:16 +02:00
"""Return an URL from the given entry."""
with YoutubeDL({'quiet': True}) as ytdl:
return ytdl.extract_info(entry['url'], download=False,
ie_key=entry.get('ie_key')).get('webpage_url')
2017-03-30 06:14:51 +02:00
def choose_from(mode1):
if mode1 == 'all': return entries
else: return [entry for entry in entries if entry.setdefault(mode1, False)]
2017-04-03 15:52:59 +02:00
def playlist(mode):
"""Return a generator of entries to be played."""
action = mode.split('-')[0]
entries2play = choose_from(mode.split('-')[1])
2017-04-03 15:52:59 +02:00
# Somehow yield have to be used instead of returning a generator
if action == 'play':
for entry in entries2play: yield entry
2017-04-03 15:52:59 +02:00
elif action == 'repeat':
for entry in cycle(entries2play): yield entry
elif entries2play:
while True: yield choice(entries2play)
2017-04-03 15:52:59 +02:00
def play():
for entry in playlist(mode):
setno(entries, ['playing'])
reprint(stdscr, entries[start : start+curses.LINES-3])
entries[entries.index(entry)]['playing'] = True
reprint(stdscr, entries[start : start+curses.LINES-3])
2017-04-08 15:47:16 +02:00
mp.play(getlink(entry))
2017-04-03 15:52:59 +02:00
mp.wait_for_playback()
2017-03-30 06:14:51 +02:00
def secpair2hhmmss(pos, duration):
"""Quick hack to convert a pair of seconds to HHMMSS / HHMMSS
string as MPV.get_property_osd_string isn't available.
"""
if pos is None: return ''
2017-03-30 06:14:51 +02:00
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))
2017-04-08 15:47:16 +02:00
def update_status(stdscr, mp, message='', msgattr=curses.A_NORMAL):
2017-04-03 15:52:59 +02:00
left = ' ' + secpair2hhmmss(mp._get_property('time-pos', int),
mp._get_property('duration', int))
right = ' {} {}{} '.format(_(mode),
2017-04-03 15:52:59 +02:00
' ' if mp._get_property('mute', bool) else 'A',
' ' if mp._get_property('vid') == 'no' else 'V')
if left != ' ':
left += ' | ' if mp._get_property('pause', bool) else ' > '
stdscr.addstr(curses.LINES - 2, 0, left, curses.color_pair(14))
2017-04-03 15:52:59 +02:00
title_len = curses.COLS - len(left + right)
center = mp._get_property('media-title').ljust(title_len)[:title_len]
stdscr.addstr(curses.LINES - 2, len(left), center,
curses.color_pair(14) | curses.A_BOLD)
2017-04-03 15:52:59 +02:00
stdscr.addstr(curses.LINES - 2, len(left + center), right,
curses.color_pair(14))
2017-04-03 15:52:59 +02:00
else:
stdscr.addstr(curses.LINES - 2, 0, right.rjust(curses.COLS),
curses.color_pair(14))
stdscr.move(curses.LINES - 1, 0)
stdscr.clrtoeol()
2017-04-08 15:47:16 +02:00
stdscr.addstr(curses.LINES - 1, 0, message, msgattr)
2017-03-24 15:57:19 +01:00
stdscr.refresh()
def reattr(stdscr, y, entry):
invert = 8 if entry.setdefault('current', False) else 0
if entry.setdefault('error', False):
2017-04-03 15:52:59 +02:00
stdscr.chgat(y, 0, curses.color_pair(1 + invert) | curses.A_BOLD)
elif entry.setdefault('playing', False):
2017-04-03 15:52:59 +02:00
stdscr.chgat(y, 0, curses.color_pair(3 + invert) | curses.A_BOLD)
elif entry.setdefault('selected', False):
2017-04-03 15:52:59 +02:00
stdscr.chgat(y, 0, curses.color_pair(5 + invert) | curses.A_BOLD)
elif invert:
stdscr.chgat(y, 0, curses.color_pair(12) | curses.A_BOLD)
else:
2017-04-08 15:47:16 +02:00
stdscr.chgat(y, 0, curses.color_pair(0))
2017-04-03 15:52:59 +02:00
def reprint(stdscr, entries2print):
2017-03-30 06:14:51 +02:00
stdscr.clear()
stdscr.addstr(0, 1, _('Title'))
sitenamelen = max(max(len(entry['ie_key']) for entry in entries), 6)
stdscr.addstr(0, curses.COLS - sitenamelen - 1, _('Source'))
2017-03-30 06:14:51 +02:00
stdscr.chgat(0, 0, curses.color_pair(10) | curses.A_BOLD)
for i, entry in enumerate(entries2print):
2017-03-30 06:14:51 +02:00
y = i + 1
stdscr.addstr(y, 0, entry['ie_key'].rjust(curses.COLS - 1))
stdscr.addstr(y, 1, entry['title'][:curses.COLS-sitenamelen-3])
reattr(stdscr, y, entry)
2017-04-08 15:47:16 +02:00
update_status(stdscr, mp)
2017-03-30 06:14:51 +02:00
def initprint(stdscr, entries):
"""Print initial content."""
global start, y
start, y = 0, 1
if not entries:
return
setno(entries, ['current', 'error', 'playing', 'selected'])
entries[0]['current'] = True
reprint(stdscr, entries[:curses.LINES-3])
def move(stdscr, entries, y, delta):
2017-03-24 15:57:19 +01:00
global start
if start + y + delta < 1:
2017-04-03 15:52:59 +02:00
if start + y == 1:
return 1
setno(entries, ['current'])
start = 0
entries[0]['current'] = True
reprint(stdscr, entries[:curses.LINES-3])
2017-03-24 15:57:19 +01:00
return 1
elif start + y + delta > len(entries):
start = max(len(entries) - curses.LINES + 3, 0)
y = min(curses.LINES - 3, len(entries))
setno(entries, ['current'])
entries[-1]['current'] = True
reprint(stdscr, entries[-curses.LINES+3:])
2017-03-20 14:49:11 +01:00
return y
2017-04-03 15:52:59 +02:00
if y + delta < 1:
2017-03-24 15:57:19 +01:00
start += y + delta - 1
y = 1
setno(entries, ['current'])
entries[start]['current'] = True
reprint(stdscr, entries[start : start+curses.LINES-3])
2017-04-03 15:52:59 +02:00
elif y + delta > curses.LINES - 3:
2017-03-24 15:57:19 +01:00
start += y + delta - curses.LINES + 3
y = curses.LINES - 3
setno(entries, ['current'])
entries[start + curses.LINES - 4]['current'] = True
reprint(stdscr, entries[start : start+curses.LINES-3])
2017-04-03 15:52:59 +02:00
else:
entries[start + y - 1]['current'] = False
reattr(stdscr, y, entries[start + y - 1])
2017-04-03 15:52:59 +02:00
y = y + delta
entries[start + y - 1]['current'] = True
reattr(stdscr, y, entries[start + y - 1])
2017-04-03 15:52:59 +02:00
stdscr.refresh()
2017-03-24 15:57:19 +01:00
return y
2017-03-20 14:49:11 +01:00
parser = ArgumentParser(description=_("Curses Online Media Player"))
2017-03-20 14:49:11 +01:00
parser.add_argument('-j', '--json-playlist', required=False,
help=_('path to playlist in JSON format'))
parser.add_argument('-y', '--youtube-playlist', required=False,
help=_('URL to an playlist on Youtube'))
2017-03-20 14:49:11 +01:00
args = parser.parse_args()
config = ConfigParser()
config.read(USER_CONFIG if isfile(USER_CONFIG) else SYSTEM_CONFIG)
mode = config.get('comp', 'play-mode', fallback='play-current')
2017-04-03 15:52:59 +02:00
video = config.get('mpv', 'video', fallback='auto')
video_output = config.get('mpv', 'video-output', fallback=None)
2017-04-08 15:47:16 +02:00
ytdl_opts = {'format': config.get('youtube-dl', 'format', fallback='best')}
if args.json_playlist:
json_file = args.json_playlist
with open(json_file) as f:
entries = json.load(f)
elif args.youtube_playlist:
2017-04-08 15:47:16 +02:00
with YoutubeDL({'extract_flat': 'in_playlist', 'quiet': True}) as ytdl:
info = ytdl.extract_info(args.youtube_playlist, download=False)
entries = info.get('entries', {})
json_file = ''
else:
entries = []
json_file = ''
2017-03-24 15:57:19 +01:00
# Init curses screen
2017-03-24 15:57:19 +01:00
stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
stdscr.keypad(True)
curses.curs_set(False)
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, 1, -1)
curses.init_pair(2, 2, -1)
curses.init_pair(3, 3, -1)
curses.init_pair(4, 4, -1)
curses.init_pair(5, 5, -1)
curses.init_pair(6, 6, -1)
curses.init_pair(7, 7, -1)
curses.init_pair(8, -1, 7)
curses.init_pair(9, -1, 1)
curses.init_pair(10, -1, 2)
curses.init_pair(11, -1, 3)
curses.init_pair(12, -1, 4)
curses.init_pair(13, -1, 5)
curses.init_pair(14, -1, 6)
# Init mpv
makedirs(expanduser(dirname(MPV_LOG)), exist_ok=True)
2017-04-03 15:52:59 +02:00
mp = MPV(input_default_bindings=True, input_vo_keyboard=True,
2017-04-08 15:47:16 +02:00
log_handler=mpv_logger, ytdl=True, ytdl_format=ytdl_opts['format'])
2017-04-03 15:52:59 +02:00
if video_output: mp['vo'] = video_output
mp._set_property('vid', video)
2017-04-08 15:47:16 +02:00
mp.observe_property('mute', lambda foo: update_status(stdscr, mp))
mp.observe_property('pause', lambda foo: update_status(stdscr, mp))
mp.observe_property('time-pos', lambda foo: update_status(stdscr, mp))
mp.observe_property('vid', lambda foo: update_status(stdscr, mp))
2017-03-30 06:14:51 +02:00
initprint(stdscr, entries)
2017-03-24 15:57:19 +01:00
c = stdscr.getch()
while c != 113: # letter q
2017-04-08 15:47:16 +02:00
if c == 10: # curses.KEY_ENTER doesn't work
if not entries: continue
2017-04-03 15:52:59 +02:00
mp._set_property('pause', False, bool)
2017-04-08 15:47:16 +02:00
play_thread = Thread(target=play, daemon=True)
2017-04-03 15:52:59 +02:00
play_thread.start()
elif c == 32: # space
mp._toggle_property('pause')
2017-04-04 06:37:29 +02:00
elif c == 65: # letter A
mp._toggle_property('mute')
2017-04-08 15:47:16 +02:00
elif c == 77: # letter M
mode = MODES[(MODES.index(mode) - 1) % 8]
update_status(stdscr, mp)
2017-04-04 06:37:29 +02:00
elif c == 86: # letter V
mp._set_property('vid',
'auto' if mp._get_property('vid') == 'no' else 'no')
2017-04-08 15:47:16 +02:00
elif c == 87: # letter W
if not entries: continue
prompt = _('Save playlist to [{}]:').format(json_file)
stdscr.addstr(curses.LINES - 1, 0, prompt)
curses.curs_set(True)
curses.echo()
s = stdscr.getstr(curses.LINES - 1, len(prompt) + 1).decode()
if s: json_file = s
curses.curs_set(False)
curses.noecho()
try:
makedirs(dirname(abspath(json_file)), exist_ok=True)
with open(json_file, 'w') as f:
json.dump(entries, f)
except:
2017-04-08 15:47:16 +02:00
update_status(stdscr, mp,
_("'{}': Can't open file for writing").format(json_file),
curses.color_pair(1))
else:
2017-04-08 15:47:16 +02:00
update_status(stdscr, mp,
_("'{}' written").format(json_file))
2017-04-08 15:47:16 +02:00
elif c == 99: # letter c
if not entries: continue
2017-04-08 15:47:16 +02:00
i = start + y - 1
entries[i]['selected'] = not entries[i].setdefault('selected', False)
y = move(stdscr, entries, y, 1)
elif c == 100: # letter d
if not entries: continue
i = start + y - 1
if i + 1 < len(entries):
entries.pop(i)
entries[i]['current'] = True
elif len(entries) > 1:
entries.pop(i)
entries[i - 1]['current'] = True
else:
entries = []
reprint(stdscr, entries[start : start+curses.LINES-3])
2017-04-08 15:47:16 +02:00
elif c == 109: # letter m
mode = MODES[(MODES.index(mode) + 1) % 8]
update_status(stdscr, mp)
elif c == 119: # letter w
if not entries: continue
with YoutubeDL({'format': ytdlf}) as ytdl:
ytdl.download([getlink(entry) for entry in choose_from(mode)])
2017-04-08 15:47:16 +02:00
elif c in (curses.KEY_UP, 107): # up arrow or letter k
if not entries: continue
2017-04-08 15:47:16 +02:00
y = move(stdscr, entries, y, -1)
elif c in (curses.KEY_DOWN, 106): # down arrow or letter j
if not entries: continue
2017-04-08 15:47:16 +02:00
y = move(stdscr, entries, y, 1)
elif c in (curses.KEY_LEFT, 104): # left arrow or letter h
if mp._get_property('duration', int):
mp.seek(-2.5)
elif c in (curses.KEY_RIGHT, 108): # right arrow or letter l
if mp._get_property('duration', int):
mp.seek(2.5)
elif c == curses.KEY_HOME: # home
if not entries: continue
2017-04-08 15:47:16 +02:00
y = move(stdscr, entries, y, -len(entries))
elif c == curses.KEY_END: # end
if not entries: continue
2017-04-08 15:47:16 +02:00
y = move(stdscr, entries, y, len(entries))
elif c == curses.KEY_NPAGE: # page down
if not entries: continue
2017-04-08 15:47:16 +02:00
y = move(stdscr, entries, y, curses.LINES - 4)
elif c == curses.KEY_PPAGE: # page up
if not entries: continue
2017-04-08 15:47:16 +02:00
y = move(stdscr, entries, y, 4 - curses.LINES)
elif c == curses.KEY_F5: # F5
reprint(stdscr, entries[start : start+curses.LINES-3])
elif c == curses.KEY_RESIZE:
curses.update_lines_cols()
if curses.COLS < MODE_STR_LEN + 42 or curses.LINES < 4:
stdscr.clear()
sizeerr = _('Current size: {}x{}. Minimum size: {}x4.').format(
curses.COLS,
curses.LINES,
MODE_STR_LEN + 42
)
stdscr.addstr(0, 0, sizeerr[:curses.LINES*curses.COLS])
else:
start += y - 1
y = 1
reprint(stdscr, entries[start : start+curses.LINES-3])
2017-03-24 15:57:19 +01:00
c = stdscr.getch()
curses.nocbreak()
stdscr.keypad(False)
curses.echo()
curses.endwin()
2017-03-30 06:14:51 +02:00
del mp