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