├── mps_youtube ├── players │ ├── __init__.py │ ├── vlc.py │ ├── GenericPlayer.py │ ├── mplayer.py │ └── mpv.py ├── __init__.py ├── listview │ ├── base.py │ ├── livestream.py │ ├── user.py │ ├── songtitle.py │ └── __init__.py ├── c.py ├── test │ └── test_main.py ├── playlist.py ├── commands │ ├── __init__.py │ ├── lastfm.py │ ├── config.py │ ├── generate_playlist.py │ ├── play.py │ ├── local_playlist.py │ ├── songlist.py │ ├── spotify_playlist.py │ ├── album_search.py │ └── misc.py ├── cache.py ├── history.py ├── paths.py ├── screen.py ├── contentquery.py ├── terminalsize.py ├── main.py ├── description_parser.py ├── playlists.py ├── streams.py ├── g.py ├── content.py ├── init.py └── player.py ├── mpsyt ├── VERSION ├── MANIFEST.in ├── doc ├── modules.rst ├── mps_youtube.c.rst ├── mps_youtube.g.rst ├── mps_youtube.init.rst ├── mps_youtube.main.rst ├── mps_youtube.util.rst ├── mps_youtube.cache.rst ├── mps_youtube.mpris.rst ├── mps_youtube.paths.rst ├── mps_youtube.config.rst ├── mps_youtube.content.rst ├── mps_youtube.history.rst ├── mps_youtube.player.rst ├── mps_youtube.screen.rst ├── mps_youtube.streams.rst ├── mps_youtube.helptext.rst ├── mps_youtube.playlist.rst ├── mps_youtube.playlists.rst ├── mps_youtube.terminalsize.rst ├── mps_youtube.commands.misc.rst ├── mps_youtube.commands.play.rst ├── mps_youtube.commands.config.rst ├── mps_youtube.commands.search.rst ├── mps_youtube.commands.download.rst ├── mps_youtube.commands.songlist.rst ├── mps_youtube.commands.album_search.rst ├── mps_youtube.commands.local_playlist.rst ├── index.rst ├── mps_youtube.commands.rst ├── mps_youtube.rst └── conf.py ├── .gitignore ├── mps-youtube.desktop ├── ISSUE_TEMPLATE.md ├── Dockerfile ├── CONTRIBUTING.md ├── RELEASING.md ├── setup.py └── README.rst /mps_youtube/players/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mpsyt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import mps_youtube.main 3 | mps_youtube.main.main() 4 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | # This file is used by clients to check for updates 2 | 3 | version 0.2.8 4 | 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include mps-youtube.desktop 2 | include LICENSE 3 | include README.rst 4 | include CHANGELOG 5 | -------------------------------------------------------------------------------- /doc/modules.rst: -------------------------------------------------------------------------------- 1 | mps_youtube 2 | =========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | mps_youtube 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.c.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.c module 2 | ==================== 3 | 4 | .. automodule:: mps_youtube.c 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.g.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.g module 2 | ==================== 3 | 4 | .. automodule:: mps_youtube.g 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.init.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.init module 2 | ======================= 3 | 4 | .. automodule:: mps_youtube.init 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.main.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.main module 2 | ======================= 3 | 4 | .. automodule:: mps_youtube.main 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.util.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.util module 2 | ======================= 3 | 4 | .. automodule:: mps_youtube.util 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.cache.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.cache module 2 | ======================== 3 | 4 | .. automodule:: mps_youtube.cache 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.mpris.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.mpris module 2 | ======================== 3 | 4 | .. automodule:: mps_youtube.mpris 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.paths.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.paths module 2 | ======================== 3 | 4 | .. automodule:: mps_youtube.paths 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.config.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.config module 2 | ========================= 3 | 4 | .. automodule:: mps_youtube.config 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.content.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.content module 2 | ========================== 3 | 4 | .. automodule:: mps_youtube.content 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.history.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.history module 2 | ========================== 3 | 4 | .. automodule:: mps_youtube.history 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.player.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.player module 2 | ========================= 3 | 4 | .. automodule:: mps_youtube.player 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.screen.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.screen module 2 | ========================= 3 | 4 | .. automodule:: mps_youtube.screen 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.streams.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.streams module 2 | ========================== 3 | 4 | .. automodule:: mps_youtube.streams 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.helptext.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.helptext module 2 | =========================== 3 | 4 | .. automodule:: mps_youtube.helptext 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.playlist.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.playlist module 2 | =========================== 3 | 4 | .. automodule:: mps_youtube.playlist 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.playlists.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.playlists module 2 | ============================ 3 | 4 | .. automodule:: mps_youtube.playlists 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.terminalsize.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.terminalsize module 2 | =============================== 3 | 4 | .. automodule:: mps_youtube.terminalsize 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.misc.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands.misc module 2 | ================================ 3 | 4 | .. automodule:: mps_youtube.commands.misc 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.play.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands.play module 2 | ================================ 3 | 4 | .. automodule:: mps_youtube.commands.play 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | otherstuff/ 3 | pafy.py 4 | pafy 5 | vi.py 6 | MANIFEST 7 | .dev 8 | .vscode 9 | # Packages 10 | *.egg 11 | *.egg-info 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | tags 19 | .env 20 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.config.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands.config module 2 | ================================== 3 | 4 | .. automodule:: mps_youtube.commands.config 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.search.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands.search module 2 | ================================== 3 | 4 | .. automodule:: mps_youtube.commands.search 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.download.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands.download module 2 | ==================================== 3 | 4 | .. automodule:: mps_youtube.commands.download 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.songlist.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands.songlist module 2 | ==================================== 3 | 4 | .. automodule:: mps_youtube.commands.songlist 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.album_search.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands.album_search module 2 | ======================================== 3 | 4 | .. automodule:: mps_youtube.commands.album_search 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.local_playlist.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands.local_playlist module 2 | ========================================== 3 | 4 | .. automodule:: mps_youtube.commands.local_playlist 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /mps_youtube/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.8" 2 | __notes__ = "released 17 February 2018" 3 | __author__ = "np1" 4 | __license__ = "GPLv3" 5 | __url__ = "https://github.com/mps-youtube/mps-youtube" 6 | 7 | from . import init 8 | init.init() 9 | from . import main 10 | -------------------------------------------------------------------------------- /mps-youtube.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=mps-youtube 3 | GenericName=Music Player 4 | Keywords=Audio;Song;Podcast;Playlist;youtube.com; 5 | Exec=mpsyt %U 6 | Terminal=true 7 | Icon=terminal 8 | Type=Application 9 | Categories=AudioVideo;Audio;Player; 10 | StartupNotify=true 11 | NoDisplay=true 12 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. mps_youtube documentation master file, created by 2 | sphinx-quickstart on Mon Apr 18 17:35:31 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | API Documentation for mps_youtube 7 | ================================= 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | mps_youtube 13 | 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | 22 | -------------------------------------------------------------------------------- /mps_youtube/listview/base.py: -------------------------------------------------------------------------------- 1 | class ListViewItem: 2 | """ Base class for items 3 | Used by Listview 4 | """ 5 | data = None 6 | 7 | def __init__(self, data): 8 | self.data = data 9 | 10 | def __getattr__(self, key): 11 | return self.data[key] if key in self.data.keys() else None 12 | 13 | def length(self, _=0): 14 | """ Returns length of ListViewItem 15 | A LVI has to return something for length 16 | even if the item does not have one. 17 | """ 18 | return 0 19 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Issue / Suggestion 4 | 5 | 6 | 7 | ## Your Environment 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /doc/mps_youtube.commands.rst: -------------------------------------------------------------------------------- 1 | mps_youtube.commands package 2 | ============================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | 9 | mps_youtube.commands.album_search 10 | mps_youtube.commands.config 11 | mps_youtube.commands.download 12 | mps_youtube.commands.local_playlist 13 | mps_youtube.commands.misc 14 | mps_youtube.commands.play 15 | mps_youtube.commands.search 16 | mps_youtube.commands.songlist 17 | 18 | Module contents 19 | --------------- 20 | 21 | .. automodule:: mps_youtube.commands 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-stretch 2 | 3 | LABEL maintainer="Justin Garrison " \ 4 | org.label-schema.schema-version="1.0" \ 5 | org.label-schema.name="mps-youtube" \ 6 | org.label-schema.description="Terminal based YouTube player and downloader " \ 7 | org.label-schema.url="https://github.com/mps-youtube/mps-youtube/wiki" \ 8 | org.label-schema.vcs-url="https://github.com/mps-youtube/mps-youtube" \ 9 | org.label-schema.docker.cmd="docker run -v /dev/snd:/dev/snd -it --rm --privileged --name mpsyt mpsyt" 10 | 11 | RUN DEBIAN_FRONTEND=noninteractive && \ 12 | apt-get update && \ 13 | apt-get install -y mplayer mpv && \ 14 | rm -rf /var/lib/apt/lists/* && \ 15 | apt-get clean && apt-get purge 16 | 17 | RUN pip install mps-youtube youtube-dl 18 | 19 | ENTRYPOINT ["mpsyt"] 20 | -------------------------------------------------------------------------------- /mps_youtube/c.py: -------------------------------------------------------------------------------- 1 | """ Module for holding colour code values. """ 2 | 3 | import os 4 | import re 5 | import sys 6 | 7 | 8 | if sys.stdout.isatty(): 9 | white = "\x1b[%sm" % 0 10 | ul = "\x1b[%sm" * 3 % (2, 4, 33) 11 | cols = ["\x1b[%sm" % n for n in range(91, 96)] 12 | red, green, yellow, blue, pink = cols 13 | else: 14 | ul = red = green = yellow = blue = pink = white = "" 15 | 16 | r, g, y, b, p, w = red, green, yellow, blue, pink, white 17 | 18 | ansirx = re.compile(r'\x1b\[\d*m', re.UNICODE) 19 | 20 | def c(colour, text): 21 | """ Return coloured text. """ 22 | colours = {'r': r, 'g': g, 'y': y, 'b':b, 'p':p} 23 | return colours[colour] + text + w 24 | 25 | def charcount(s): 26 | """ Return number of characters in string, with ANSI color codes excluded. """ 27 | return len(ansirx.sub('', s)) 28 | -------------------------------------------------------------------------------- /doc/mps_youtube.rst: -------------------------------------------------------------------------------- 1 | mps_youtube package 2 | =================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | mps_youtube.commands 10 | 11 | Submodules 12 | ---------- 13 | 14 | .. toctree:: 15 | 16 | mps_youtube.c 17 | mps_youtube.cache 18 | mps_youtube.config 19 | mps_youtube.content 20 | mps_youtube.g 21 | mps_youtube.helptext 22 | mps_youtube.history 23 | mps_youtube.init 24 | mps_youtube.main 25 | mps_youtube.mpris 26 | mps_youtube.paths 27 | mps_youtube.player 28 | mps_youtube.playlist 29 | mps_youtube.playlists 30 | mps_youtube.screen 31 | mps_youtube.streams 32 | mps_youtube.terminalsize 33 | mps_youtube.util 34 | 35 | Module contents 36 | --------------- 37 | 38 | .. automodule:: mps_youtube 39 | :members: 40 | :undoc-members: 41 | :show-inheritance: 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing for mps-youtube 2 | 3 | Contributions are very much appreciated! 4 | 5 | * Pull requests should be based on and submitted to the "develop" branch. 6 | 7 | * Please raise an issue to discuss what you plan to implement or change before 8 | you start if it is going to involve a lot of work on your part. 9 | 10 | * Please keep pull requests specific, do not make many disparate changes or 11 | new features in one request. A separate pull request for each feature change 12 | is preferred. 13 | 14 | * Please ensure your changes work in Python 3.3+ and Windows. 15 | 16 | 17 | ## Code conventions 18 | 19 | * Maximum line length is 80 characters 20 | 21 | * Follow the line-spacing style that is already in place. 22 | 23 | * Ensure all functions and classes have a PEP257 compliant docstring and the 24 | code is PEP8 compliant. 25 | -------------------------------------------------------------------------------- /mps_youtube/players/vlc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from .. import config, util, g 5 | 6 | from ..player import CmdPlayer 7 | 8 | 9 | class vlc(CmdPlayer): 10 | def __init__(self, player): 11 | self.player = player 12 | 13 | def _generate_real_playerargs(self): 14 | args = config.PLAYERARGS.get.strip().split() 15 | 16 | pd = g.playerargs_defaults['vlc'] 17 | args.extend((pd["title"], '"{0}"'.format(self.song.title))) 18 | 19 | util.list_update("--play-and-exit", args) 20 | 21 | return [self.player] + args + [self.stream['url']] 22 | 23 | def clean_up(self): 24 | pass 25 | 26 | def launch_player(self, cmd): 27 | with open(os.devnull, "w") as devnull: 28 | self.p = subprocess.Popen(cmd, shell=False, stderr=devnull) 29 | self.p.wait() 30 | self.next() 31 | 32 | def _help(self, short=True): 33 | pass 34 | -------------------------------------------------------------------------------- /mps_youtube/listview/livestream.py: -------------------------------------------------------------------------------- 1 | from .base import ListViewItem 2 | from .. import util 3 | 4 | 5 | class ListLiveStream(ListViewItem): 6 | """ Class exposing necessary components of a live stream """ 7 | # pylint: disable=unused-argument 8 | def ytid(self, lngt=10): 9 | """ Exposes ytid(string) """ 10 | return self.data.get("id").get("videoId") 11 | 12 | def ret(self): 13 | """ Returns content.video compatible tuple """ 14 | return (self.ytid(), self.title(), self.length()) 15 | 16 | def title(self, lngt=10): 17 | """ exposes title """ 18 | return util.uea_pad(lngt, self.data.get("snippet").get("title")) 19 | def description(self, lngt=10): 20 | """ exposes description """ 21 | return util.uea_pad(lngt, self.data.get("snippet").get("description")) 22 | 23 | @staticmethod 24 | def return_field(): 25 | """ ret """ 26 | return "ret" 27 | -------------------------------------------------------------------------------- /mps_youtube/test/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import mps_youtube.main as mps 3 | 4 | class TestMain(unittest.TestCase): 5 | 6 | def test_fmt_time(self): 7 | self.assertEqual(mps.fmt_time(0), '00:00') 8 | self.assertEqual(mps.fmt_time(59), '00:59') 9 | self.assertEqual(mps.fmt_time(100), '01:40') 10 | self.assertEqual(mps.fmt_time(1000), '16:40') 11 | self.assertEqual(mps.fmt_time(5000), '83:20') 12 | self.assertEqual(mps.fmt_time(6500), '1:48:20') 13 | 14 | def test_num_repr(self): 15 | self.assertEqual(mps.num_repr(0), '0') 16 | self.assertEqual(mps.num_repr(1001), '1001') 17 | self.assertEqual(mps.num_repr(10001), '10k') 18 | self.assertEqual(mps.num_repr(100001), '100k') 19 | self.assertEqual(mps.num_repr(1000001), '1.0m') 20 | self.assertEqual(mps.num_repr(10000001), '10m') 21 | self.assertEqual(mps.num_repr(100000001), '100m') 22 | self.assertEqual(mps.num_repr(1000000001), '1.0B') 23 | 24 | if __name__ == '__main__': 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /mps_youtube/listview/user.py: -------------------------------------------------------------------------------- 1 | from .base import ListViewItem 2 | from .. import util as u 3 | 4 | class ListUser(ListViewItem): 5 | """ Describes a user 6 | """ 7 | # pylint: disable=unused-argument 8 | def id(self, length=0): 9 | """ Returns YTID """ 10 | return self.data.get("id").get("channelId") 11 | 12 | def name(self, length=10): 13 | """ Returns channel name """ 14 | return u.uea_pad(length, self.data.get("snippet").get("title")) 15 | 16 | def description(self, length=10): 17 | """ Channel description""" 18 | return u.uea_pad(length, self.data.get("snippet").get("description")) 19 | 20 | def kind(self, length=10): 21 | """ Returns the youtube datatype 22 | Example: youtube#channel, youtube#video 23 | """ 24 | return self.data.get("id").get("kind") 25 | 26 | def ret(self): 27 | """ Used in the ListView play function """ 28 | return (self.data.get("snippet").get("title"), self.id(), "") 29 | 30 | @staticmethod 31 | def return_field(): 32 | """ Determines which function will be called on selected items """ 33 | return "ret" 34 | -------------------------------------------------------------------------------- /mps_youtube/playlist.py: -------------------------------------------------------------------------------- 1 | class Playlist: 2 | 3 | """ Representation of a playist, has list of songs. """ 4 | 5 | def __init__(self, name=None, songs=None): 6 | """ class members. """ 7 | self.name = name 8 | self.songs = songs or [] 9 | 10 | def __len__(self): 11 | """ Return number of tracks. """ 12 | return len(self.songs) 13 | 14 | def __getitem__(self, sliced): 15 | return self.songs[sliced] 16 | 17 | def __setitem__(self, position, item): 18 | self.songs[position] = item 19 | 20 | def __iter__(self): 21 | for i in self.songs: 22 | yield i 23 | 24 | @property 25 | def duration(self): 26 | """ Sum duration of the playlist. """ 27 | duration = sum(s.length for s in self.songs) 28 | mins, secs = divmod(duration, 60) 29 | hours, mins = divmod(mins, 60) 30 | duration = '{H:02}:{M:02}:{S:02}'.format(H=hours, M=mins, S=secs) 31 | return duration 32 | 33 | 34 | class Video: 35 | 36 | """ Class to represent a YouTube video. """ 37 | description = "" 38 | def __init__(self, ytid, title, length): 39 | """ class members. """ 40 | self.ytid = ytid 41 | self.title = title 42 | self.length = int(length) 43 | -------------------------------------------------------------------------------- /mps_youtube/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import re 3 | 4 | from .. import g 5 | from ..main import completer 6 | 7 | Command = collections.namedtuple('Command', 'regex category usage function') 8 | 9 | # input types 10 | WORD = r'[^\W\d][-\w\s]{,100}' 11 | RS = r'(?:(?:repeat|shuffle|-[avfw])\s*)' 12 | PL = r'\S*?((?:RD|PL|LL|UU|FL|OL)[-_0-9a-zA-Z]+)\s*' 13 | 14 | ## @command decorator 15 | ## 16 | ## The @command decorator takes a single regex followed by one or more 17 | ## strings that corresponds to command names that will be added to 18 | ## the tab completion. 19 | ## 20 | ## If your command has short-forms, only register the longer 21 | ## forms. 22 | ## If you use several functions and regexes for the same command but different 23 | ## arguments, append the completion string on EACH function, not only 24 | ## the first time you register it. 25 | def command(regex, *commands): 26 | """ Decorator to register an mps-youtube command. """ 27 | for command in commands: 28 | completer.add_cmd(command) 29 | def decorator(function): 30 | cmd = Command(re.compile(regex), None, None, function) 31 | g.commands.append(cmd) 32 | return function 33 | return decorator 34 | 35 | 36 | # Placed at bottom to deal with cyclic imports 37 | from . import download, search, album_search, spotify_playlist, misc, config, local_playlist 38 | from . import play, songlist, generate_playlist, lastfm 39 | -------------------------------------------------------------------------------- /mps_youtube/listview/songtitle.py: -------------------------------------------------------------------------------- 1 | from .base import ListViewItem 2 | from .. import util as u 3 | 4 | 5 | class ListSongtitle(ListViewItem): 6 | """ Describes a user 7 | """ 8 | # pylint: disable=unused-argument 9 | _checked = False 10 | _certainty = 1.0 11 | 12 | def __init__(self, data, certainty=1.0): 13 | self._checked = True 14 | self._certainty = certainty 15 | super(ListSongtitle, self).__init__(data) 16 | 17 | def artist(self, l=10): 18 | """ Get artist """ 19 | return u.uea_pad(l, self.data[0]) 20 | 21 | def title(self, l=10): 22 | """ Get title """ 23 | return u.uea_pad(l, self.data[1]) 24 | 25 | def checked(self, l=10): 26 | """ String from for checked """ 27 | return " X " if self._checked else " " 28 | 29 | def certainty(self): 30 | """ Float """ 31 | return self._certainty 32 | 33 | def is_checked(self): 34 | """ Returns true if checked """ 35 | return self._checked 36 | 37 | def toggle(self): 38 | """ Toggle checked status """ 39 | self._checked = not self._checked 40 | 41 | def ret(self): 42 | """ Used in the ListView play function """ 43 | return "%s - %s" % (self.artist().strip(), self.title().strip()) 44 | 45 | @staticmethod 46 | def return_field(): 47 | """ Determines which function will be called on selected items """ 48 | return "ret" 49 | -------------------------------------------------------------------------------- /mps_youtube/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | 4 | import pafy 5 | 6 | from . import g, c, streams 7 | from .util import dbg 8 | 9 | 10 | # Updated every time incompatible changes are made to cache format, 11 | # So old cache can be dropped 12 | CACHE_VERSION = 1 13 | 14 | def load(): 15 | """ Import cache file. """ 16 | if os.path.isfile(g.CACHEFILE): 17 | 18 | try: 19 | 20 | with open(g.CACHEFILE, "rb") as cf: 21 | cached = pickle.load(cf) 22 | 23 | # Note: will be none for mpsyt 0.2.5 or earlier 24 | version = cached.get('version') 25 | 26 | if 'streams' in cached: 27 | if version and version >= 1: 28 | g.streams = cached['streams'] 29 | g.username_query_cache = cached['userdata'] 30 | else: 31 | g.streams = cached 32 | 33 | if 'pafy' in cached: 34 | pafy.load_cache(cached['pafy']) 35 | 36 | dbg(c.g + "%s cached streams imported%s", str(len(g.streams)), c.w) 37 | 38 | except (EOFError, IOError): 39 | dbg(c.r + "Cache file failed to open" + c.w) 40 | 41 | streams.prune() 42 | 43 | 44 | def save(): 45 | """ Save stream cache. """ 46 | caches = dict( 47 | version=CACHE_VERSION, 48 | streams=g.streams, 49 | userdata=g.username_query_cache, 50 | pafy=pafy.dump_cache()) 51 | 52 | with open(g.CACHEFILE, "wb") as cf: 53 | pickle.dump(caches, cf, protocol=2) 54 | 55 | dbg(c.p + "saved cache file: " + g.CACHEFILE + c.w) 56 | -------------------------------------------------------------------------------- /mps_youtube/history.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | 4 | from . import g, c 5 | from .util import dbg 6 | from .playlist import Playlist 7 | from .playlists import read_m3u 8 | 9 | 10 | def add(song): 11 | """ Add song to history. """ 12 | if not g.userhist.get('history'): 13 | g.userhist['history'] = Playlist('history') 14 | 15 | g.userhist['history'].songs.append(song) 16 | 17 | save() 18 | 19 | 20 | def load(): 21 | """ Open history. Called once on script invocation. """ 22 | _convert_to_m3u() 23 | try: 24 | g.userhist['history'] = read_m3u(g.HISTFILE) 25 | 26 | except FileNotFoundError: 27 | # no playlist found, create a blank one 28 | if not os.path.isfile(g.HISTFILE): 29 | g.userhist = {} 30 | save() 31 | 32 | 33 | def save(): 34 | """ Save history. Called each time history is updated. """ 35 | with open(g.HISTFILE, 'w') as hf: 36 | hf.write('#EXTM3U\n\n') 37 | if 'history' in g.userhist: 38 | for song in g.userhist['history'].songs: 39 | hf.write('#EXTINF:%d,%s\n' % (song.length, song.title)) 40 | hf.write('https://www.youtube.com/watch?v=%s\n' % song.ytid) 41 | 42 | dbg(c.r + "History saved\n---" + c.w) 43 | 44 | def _convert_to_m3u(): 45 | """ Converts the play_history file to the m3u format. """ 46 | # Skip if m3u file already exists 47 | if os.path.isfile(g.HISTFILE): 48 | return 49 | 50 | elif not os.path.isfile(g.OLDHISTFILE): 51 | return 52 | 53 | with open(g.OLDHISTFILE, "rb") as hf: 54 | g.userhist = pickle.load(hf) 55 | 56 | save() 57 | -------------------------------------------------------------------------------- /mps_youtube/players/GenericPlayer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from .. import config 5 | 6 | from ..player import CmdPlayer 7 | 8 | # 9 | # This class can be used as a templete for new players 10 | # 11 | # NOTE: 12 | # If you're defining a new player donot forget 13 | # to name both the class and file the same as your player 14 | # 15 | 16 | 17 | class GenericPlayer(CmdPlayer): 18 | def __init__(self, player): 19 | self.player = player 20 | 21 | def _generate_real_playerargs(self): 22 | '''Generates player arguments to called using Popen 23 | 24 | ''' 25 | args = config.PLAYERARGS.get.strip().split() 26 | 27 | ############################################ 28 | # Define your arguments below this line 29 | 30 | ########################################### 31 | 32 | return [self.player] + args + [self.stream['url']] 33 | 34 | def clean_up(self): 35 | ''' Cleans up temp files after process exits. 36 | 37 | ''' 38 | pass 39 | 40 | def launch_player(self, cmd): 41 | 42 | ################################################## 43 | # Change this however you want 44 | 45 | with open(os.devnull, "w") as devnull: 46 | self.p = subprocess.Popen(cmd, shell=False, stderr=devnull) 47 | self.p.wait() 48 | 49 | ################################################## 50 | 51 | # Donot forget self.next() 52 | self.next() 53 | 54 | def _help(self, short=True): 55 | ''' Help keys shown when the song is played. 56 | 57 | See mpv.py for reference. 58 | 59 | ''' 60 | pass 61 | -------------------------------------------------------------------------------- /mps_youtube/paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | mswin = os.name == "nt" 4 | 5 | 6 | def get_default_ddir(): 7 | """ Get system default Download directory, append mps dir. """ 8 | user_home = os.path.expanduser("~") 9 | join, exists = os.path.join, os.path.exists 10 | 11 | if mswin: 12 | return join(user_home, "Downloads", "mps") 13 | 14 | USER_DIRS = join(user_home, ".config", "user-dirs.dirs") 15 | DOWNLOAD_HOME = join(user_home, "Downloads") 16 | 17 | # define ddir by (1) env var, (2) user-dirs.dirs file, 18 | # (3) existing ~/Downloads dir (4) ~ 19 | 20 | if 'XDG_DOWNLOAD_DIR' in os.environ: 21 | ddir = os.environ['XDG_DOWNLOAD_DIR'] 22 | 23 | elif exists(USER_DIRS): 24 | lines = open(USER_DIRS).readlines() 25 | defn = [x for x in lines if x.startswith("XDG_DOWNLOAD_DIR")] 26 | 27 | if len(defn) == 1: 28 | ddir = defn[0].split("=")[1].replace('"', '') 29 | ddir = ddir.replace("$HOME", user_home).strip() 30 | 31 | else: 32 | ddir = DOWNLOAD_HOME if exists(DOWNLOAD_HOME) else user_home 33 | 34 | else: 35 | ddir = DOWNLOAD_HOME if exists(DOWNLOAD_HOME) else user_home 36 | 37 | ddir = ddir 38 | return os.path.join(ddir, "mps") 39 | 40 | 41 | def get_config_dir(): 42 | """ Get user's configuration directory. Migrate to new mps name if old.""" 43 | if mswin: 44 | confdir = os.environ["APPDATA"] 45 | 46 | elif 'XDG_CONFIG_HOME' in os.environ: 47 | confdir = os.environ['XDG_CONFIG_HOME'] 48 | 49 | else: 50 | confdir = os.path.join(os.path.expanduser("~"), '.config') 51 | 52 | mps_confdir = os.path.join(confdir, "mps-youtube") 53 | 54 | os.makedirs(mps_confdir, exist_ok=True) 55 | 56 | return mps_confdir 57 | -------------------------------------------------------------------------------- /mps_youtube/screen.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import sys 4 | 5 | from . import g, content, config, util 6 | 7 | 8 | mswin = os.name == "nt" 9 | 10 | 11 | def update(fill_blank=True): 12 | """ Display content, show message, blank screen.""" 13 | clear() 14 | 15 | if isinstance(g.content, content.PaginatedContent): 16 | util.xprint(g.content.getPage(g.current_page)) 17 | g.rprompt = content.page_msg(g.current_page) 18 | elif g.content: 19 | util.xprint(g.content) 20 | g.content = False 21 | 22 | if g.message or g.rprompt: 23 | out = g.message or '' 24 | blanks = util.getxy().width - len(out) - len(g.rprompt or '') 25 | out += ' ' * blanks + (g.rprompt or '') 26 | util.xprint(out) 27 | 28 | elif fill_blank: 29 | util.xprint("") 30 | 31 | g.message = g.rprompt = False 32 | 33 | 34 | def clear(): 35 | """Clear all text from screen.""" 36 | if g.no_clear_screen: 37 | util.xprint('--\n') 38 | else: 39 | util.xprint('\n' * 200) 40 | 41 | 42 | def reset_terminal(): 43 | """ Reset terminal control character and modes for non Win OS's. """ 44 | if not mswin: 45 | subprocess.call(["tset", "-c"]) 46 | 47 | 48 | def writestatus(text, mute=False): 49 | """ Update status line. """ 50 | if not mute and config.SHOW_STATUS.get: 51 | _writeline(text) 52 | 53 | 54 | def _writeline(text): 55 | """ Print text on same line. """ 56 | width = util.getxy().width 57 | spaces = width - len(text) - 1 58 | if mswin: 59 | # Avoids creating new line every time it is run 60 | # TODO: Figure out why this is needed 61 | spaces =- 1 62 | text = text[:width - 3] 63 | sys.stdout.write(" " + text + (" " * spaces) + "\r") 64 | sys.stdout.flush() 65 | 66 | 67 | def msgexit(msg, code=0): 68 | """ Print a message and exit. """ 69 | util.xprint(msg) 70 | sys.exit(code) 71 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Release process for pafy and mps-youtube 2 | ======================================== 3 | 4 | Looking at the commits and Github releases for previous versions can provide an example. 5 | 6 | Version numbers 7 | --------------- 8 | ### pafy 9 | Bump the `__version__` in `__init__.py`. 10 | 11 | ### mps-youtube 12 | Bump the `version` in `VERSION`, `__version__` and `__notes__` in `__init__.py`, and `VERSION` in `setup.py`. 13 | 14 | Changelogs 15 | ---------- 16 | Update `CHANGELOG` with a summary of changes since the last release. `git shortlog` can be helpful to see what commits have occurred. 17 | 18 | ### mps-youtube 19 | Also update `New Features` in `helptext.py`. This help section has ended up falling out of date. If it isn't kept up to date, it should probably be removed. 20 | 21 | Github Release 22 | -------------- 23 | Create a release through the Github website, tagging the commit that should be released. The text from the `CHANGELOG` should be copied to the release. 24 | 25 | py2exe 26 | ------ 27 | For mps-youtube, a `.exe` file should be built with `python setup.py py2exe` under Windows. Make sure the correct pafy and youtube-dl versions are being used, since they will be embedded in the binary. Attach this file to the Github release. 28 | 29 | PyPI 30 | ---- 31 | Push the source, and a wheel build, to PyPI. Be careful that everything is correct at this point; PyPI does not allow replacing an uploaded file with a different one of the same name. 32 | 33 | GPG Signatures 34 | -------------- 35 | The `.tar.gz` signatures for `pafy` and `mps-youtube` also have GPG signatures attached to the release. Currently, they are signed with @ids1024's key, so only he can perform this step. 36 | 37 | Possible Simplifications to this Process 38 | ---------------------------------------- 39 | The New Features help text isn't really important, but it is genuinely nice to have if kept up to date. 40 | 41 | Perhaps the `CHANGELOG` file isn't really needed, if Github releases includes that information. 42 | -------------------------------------------------------------------------------- /mps_youtube/contentquery.py: -------------------------------------------------------------------------------- 1 | """ 2 | ContentQuery is an abstraction layer between the the pafy.call_gdata 3 | and the listViews. 4 | 5 | It lets you treat A query as a list of all the results, even though 6 | data is only queried when requested. 7 | """ 8 | import pafy 9 | 10 | from . import util 11 | 12 | 13 | class ContentQuery: 14 | """ A wrapper for pafy.call_gdata. I lets you treat a search as a list, 15 | but the results will only be fetched when needed. 16 | """ 17 | maxresults = 0 18 | pdata = [] 19 | nextpagetoken = None 20 | 21 | datatype = None 22 | queries = None 23 | api = None 24 | 25 | def __init__(self, datatype, api, qs): 26 | # Perform initial API call, setBoundaries 27 | # call parseData 28 | 29 | self.datatype = datatype 30 | self.queries = qs 31 | self.api = api 32 | 33 | self.pdata = [] 34 | 35 | self._perform_api_call() 36 | 37 | def __getitem__(self, iid): 38 | # Check if we already got the item or slice needed 39 | # Call and parse nextPage as long as you dont have the data 40 | # needed. 41 | last_id = iid.stop if iid.__class__ == slice else iid 42 | last_datapoint = min(last_id, self.maxresults) 43 | while len(self.pdata) < last_datapoint: 44 | self._perform_api_call() 45 | return self.pdata[iid] 46 | 47 | def count(self): 48 | """ Returns how many items are in the list """ 49 | return self.maxresults 50 | 51 | def __len__(self): 52 | return abs(self.count()) 53 | 54 | def _perform_api_call(self): 55 | # Include nextPageToken if it is set 56 | qry = dict( 57 | pageToken=self.nextpagetoken, 58 | **(self.queries) 59 | ) if self.nextpagetoken else self.queries 60 | 61 | # Run query 62 | util.dbg("CQ.query", qry) 63 | data = pafy.call_gdata(self.api, qry) 64 | 65 | self.maxresults = int(data.get("pageInfo").get("totalResults")) 66 | self.nextpagetoken = data.get("nextPageToken") 67 | 68 | for obj in data.get("items"): 69 | self.pdata.append(self.datatype(obj)) 70 | -------------------------------------------------------------------------------- /mps_youtube/commands/lastfm.py: -------------------------------------------------------------------------------- 1 | import time 2 | import datetime 3 | 4 | try: 5 | import pylast 6 | has_pylast = True 7 | except ImportError: 8 | has_pylast = False 9 | 10 | from .. import g, util, config 11 | from . import command 12 | 13 | @command(r'lastfm_connect', 'lastfm_connect') 14 | def init_network(verbose=True): 15 | """ Initialize the global pylast network variable """ 16 | if not has_pylast : 17 | if verbose: 18 | pylast_url = 'https://github.com/pylast/pylast' 19 | g.message = '"pylast" module not found\n see %s' % (pylast_url) 20 | return 21 | 22 | # TODO: Add option to read lastfm config from file or env variable 23 | key = config.LASTFM_API_KEY.get 24 | secret = config.LASTFM_API_SECRET.get 25 | password = config.LASTFM_PASSWORD.get # already hashed 26 | username = config.LASTFM_USERNAME.get 27 | 28 | if not (key and secret and password and username): 29 | if verbose: 30 | util.xprint("Not all Last.fm credentials were set.") 31 | return 32 | 33 | try: 34 | g.lastfm_network = pylast.LastFMNetwork(api_key=key, api_secret=secret, 35 | username=username, 36 | password_hash=password) 37 | if verbose: 38 | g.message = "Last.fm authentication successful!" 39 | except (pylast.WSError, pylast.MalformedResponseError, pylast.NetworkError) as e: 40 | if verbose: 41 | g.message = "Last.fm connection error: %s" % (str(e)) 42 | 43 | def scrobble_track(artist, album, track): 44 | """ Scrobble a track to the user's Last.fm account """ 45 | if not g.lastfm_network: 46 | return 47 | unix_timestamp = int(time.mktime(datetime.datetime.now().timetuple())) 48 | try: 49 | g.lastfm_network.scrobble(artist=artist, title=track, album=album, 50 | timestamp=unix_timestamp) 51 | except (pylast.WSError, pylast.MalformedResponseError, pylast.NetworkError): 52 | return 53 | 54 | def set_now_playing(artist, track): 55 | """ Set the current track as "now playing" on the user's Last.fm account """ 56 | if not g.lastfm_network: 57 | return 58 | try: 59 | g.lastfm_network.update_now_playing(artist=artist, title=track) 60 | except (pylast.WSError, pylast.MalformedResponseError, pylast.NetworkError): 61 | return 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """ setup.py for mps-youtube. 4 | 5 | https://np1.github.com/mps-youtube 6 | 7 | python setup.py sdist bdist_wheel 8 | """ 9 | 10 | import sys 11 | import os 12 | 13 | if sys.version_info < (3,0): 14 | sys.exit("Mps-youtube requires python 3.") 15 | 16 | from setuptools import setup 17 | 18 | VERSION = "0.2.8" 19 | 20 | options = dict( 21 | name="mps-youtube", 22 | version=VERSION, 23 | description="Terminal based YouTube player and downloader", 24 | keywords=["video", "music", "audio", "youtube", "stream", "download"], 25 | author="np1", 26 | author_email="np1nagev@gmail.com", 27 | url="https://github.com/mps-youtube/mps-youtube", 28 | download_url="https://github.com/mps-youtube/mps-youtube/archive/v%s.tar.gz" % VERSION, 29 | packages=['mps_youtube', 'mps_youtube.commands', 'mps_youtube.listview', 'mps_youtube.players'], 30 | entry_points={'console_scripts': ['mpsyt = mps_youtube:main.main']}, 31 | install_requires=['pafy >= 0.3.82, != 0.4.0, != 0.4.1, != 0.4.2'], 32 | classifiers=[ 33 | "Topic :: Utilities", 34 | "Topic :: Internet :: WWW/HTTP", 35 | "Topic :: Multimedia :: Sound/Audio :: Players", 36 | "Topic :: Multimedia :: Video", 37 | "Environment :: Console", 38 | "Environment :: Win32 (MS Windows)", 39 | "Environment :: MacOS X", 40 | "Operating System :: POSIX :: Linux", 41 | "Operating System :: MacOS", 42 | "Operating System :: MacOS :: MacOS 9", 43 | "Operating System :: MacOS :: MacOS X", 44 | "Operating System :: Microsoft", 45 | "Operating System :: Microsoft :: Windows :: Windows 7", 46 | "Operating System :: Microsoft :: Windows :: Windows XP", 47 | "Operating System :: Microsoft :: Windows :: Windows Vista", 48 | "Intended Audience :: End Users/Desktop", 49 | "Programming Language :: Python", 50 | "Programming Language :: Python :: 3", 51 | "Programming Language :: Python :: 3.3", 52 | "Programming Language :: Python :: 3.4", 53 | "Programming Language :: Python :: 3 :: Only", 54 | "Development Status :: 5 - Production/Stable", 55 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)" 56 | ], 57 | options={ 58 | "py2exe": { 59 | "excludes": ("readline, win32api, win32con, dbus, gi," 60 | " urllib.unquote_plus, urllib.urlencode," 61 | " PyQt4, gtk"), 62 | "bundle_files": 1 63 | } 64 | }, 65 | package_data={"": ["LICENSE", "README.rst", "CHANGELOG"]}, 66 | long_description=open("README.rst").read() 67 | ) 68 | 69 | if sys.platform.startswith('linux'): 70 | # Install desktop file. Required for mpris on Ubuntu 71 | options['data_files'] = [('share/applications/', ['mps-youtube.desktop'])] 72 | 73 | if os.name == "nt": 74 | try: 75 | import py2exe 76 | # Only setting these when py2exe imports successfully prevents warnings 77 | # in easy_install 78 | options['console'] = ['mpsyt'] 79 | options['zipfile'] = None 80 | except ImportError: 81 | pass 82 | 83 | setup(**options) 84 | -------------------------------------------------------------------------------- /mps_youtube/terminalsize.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/jtriley/1108174 2 | 3 | """ Terminal Size. """ 4 | 5 | import os 6 | import sys 7 | import shlex 8 | import shutil 9 | import struct 10 | import platform 11 | import subprocess 12 | 13 | 14 | def get_terminal_size(): 15 | """ getTerminalSize(). 16 | 17 | - get width and height of console 18 | - works on linux,os x,windows,cygwin(windows) 19 | originally retrieved from: 20 | http://stackoverflow.com/questions/566746/how-to-get-console-window-width-in-python 21 | """ 22 | 23 | if sys.version_info >= (3,3): 24 | return shutil.get_terminal_size() 25 | 26 | current_os = platform.system() 27 | tuple_xy = None 28 | 29 | if current_os == 'Windows': 30 | tuple_xy = _get_terminal_size_windows() 31 | 32 | if tuple_xy is None: 33 | tuple_xy = _get_terminal_size_tput() 34 | # needed for window's python in cygwin's xterm! 35 | 36 | else: 37 | tuple_xy = _get_terminal_size_linux() 38 | 39 | if tuple_xy is None: 40 | tuple_xy = (80, 25) # default value 41 | 42 | return tuple_xy 43 | 44 | 45 | def _get_terminal_size_windows(): 46 | """ Get terminal size on MS Windows. """ 47 | # pylint: disable=R0914 48 | # too many local variables 49 | try: 50 | from ctypes import windll, create_string_buffer 51 | # stdin handle is -10 52 | # stdout handle is -11 53 | # stderr handle is -12 54 | h = windll.kernel32.GetStdHandle(-12) 55 | csbi = create_string_buffer(22) 56 | res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi) 57 | 58 | if res: 59 | (bufx, bufy, curx, cury, wattr, 60 | left, top, right, bottom, 61 | maxx, maxy) = struct.unpack("hhhhHhhhhhh", csbi.raw) 62 | sizex = right - left + 1 63 | sizey = bottom - top + 1 64 | return sizex, sizey 65 | 66 | except: 67 | pass 68 | 69 | 70 | def _get_terminal_size_tput(): 71 | """ Get terminal size using tput. """ 72 | # src: http://stackoverflow.com/questions/263890/ 73 | # how-do-i-find-the-width-height-of-a-terminal-window 74 | try: 75 | cols = int(subprocess.check_call(shlex.split('tput cols'))) 76 | rows = int(subprocess.check_call(shlex.split('tput lines'))) 77 | return (cols, rows) 78 | except: 79 | pass 80 | 81 | 82 | def _get_terminal_size_linux(): 83 | """ Get terminal size Linux. """ 84 | def ioctl_GWINSZ(fd): 85 | """ ioctl_GWINSZ. """ 86 | try: 87 | import fcntl 88 | import termios 89 | cr = struct.unpack('hh', 90 | fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234')) 91 | return cr 92 | 93 | except: 94 | pass 95 | 96 | cr = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2) 97 | 98 | if not cr or cr == (0, 0): 99 | 100 | try: 101 | fd = os.open(os.ctermid(), os.O_RDONLY) 102 | cr = ioctl_GWINSZ(fd) 103 | os.close(fd) 104 | 105 | except: 106 | pass 107 | 108 | if not cr or cr == (0, 0): 109 | 110 | try: 111 | cr = (os.environ['LINES'], os.environ['COLUMNS']) 112 | 113 | except: 114 | return 115 | 116 | return int(cr[1]), int(cr[0]) 117 | -------------------------------------------------------------------------------- /mps_youtube/commands/config.py: -------------------------------------------------------------------------------- 1 | from .. import g, c, config, util 2 | from . import command 3 | 4 | 5 | @command(r'set|showconfig', 'set', 'showconfig') 6 | def showconfig(): 7 | """ Dump config data. """ 8 | width = util.getxy().width 9 | longest_key = 17 10 | longest_val = 0 11 | has_temps = False 12 | 13 | for setting in config: 14 | val = config[setting] 15 | longest_val = max(longest_val, len(str(val.display))) 16 | has_temps = has_temps or val.temp_value is not None 17 | 18 | width -= 27 19 | s = " %s%-{0}s%s : %-{1}s".format(longest_key, longest_val+1) 20 | 21 | if has_temps: 22 | width -= longest_val + 5 23 | out = " %s%-{}s %-{}s %s%s%s\n".format(longest_key, longest_val) % ( 24 | c.ul, "Key", "Value", "Temporary", " " * width, c.w) 25 | else: 26 | out = " %s%-{}s %s%s%s\n".format(longest_key) % (c.ul, "Key", "Value", " " * width, c.w) 27 | 28 | for setting in config: 29 | val = config[setting] 30 | 31 | # don't show player specific settings if unknown player 32 | if not util.is_known_player(config.PLAYER.get) and \ 33 | val.require_known_player: 34 | continue 35 | 36 | # don't show max_results if auto determined 37 | if g.detectable_size and setting == "MAX_RESULTS": 38 | continue 39 | 40 | if g.detectable_size and setting == "CONSOLE_WIDTH": 41 | continue 42 | 43 | out += s % (c.g, setting.lower(), c.w, val.display) 44 | 45 | if has_temps: 46 | out += "%s%s" % (c.w, val.display_temp) 47 | 48 | out += "\n" 49 | 50 | g.content = out 51 | g.message = "Enter %sset %s to change\n" % (c.g, c.w) 52 | g.message += "Enter %sset all default%s to reset all" % (c.g, c.w) 53 | 54 | 55 | @command(r'set\s+-t\s*([-\w]+)\s*(.*)') 56 | def setconfigtemp(key, val): 57 | setconfig(key, val, is_temp=True) 58 | 59 | 60 | @command(r'set\s+([-\w]+)\s*(.*)') 61 | def setconfig(key, val, is_temp=False): 62 | """ Set configuration variable. """ 63 | key = key.replace("-", "_") 64 | if key.upper() == "ALL" and val.upper() == "DEFAULT": 65 | 66 | for ci in config: 67 | config[ci].value = config[ci].default 68 | 69 | config.save() 70 | message = "Default configuration reinstated" 71 | 72 | elif not key.upper() in config: 73 | message = "Unknown config item: %s%s%s" % (c.r, key, c.w) 74 | 75 | elif val.upper() == "DEFAULT": 76 | att = config[key.upper()] 77 | att.value = att.default 78 | att.temp_value = None 79 | message = "%s%s%s set to %s%s%s (default)" 80 | dispval = att.display or "None" 81 | message = message % (c.y, key, c.w, c.y, dispval, c.w) 82 | config.save() 83 | 84 | else: 85 | # config.save() will be called by config.set() method 86 | message = config[key.upper()].set(val, is_temp=is_temp) 87 | 88 | showconfig() 89 | g.message = message 90 | 91 | 92 | @command(r'encoders?', 'encoder') 93 | def show_encs(): 94 | """ Display available encoding presets. """ 95 | out = "%sEncoding profiles:%s\n\n" % (c.ul, c.w) 96 | 97 | for x, e in enumerate(g.encoders): 98 | sel = " (%sselected%s)" % (c.y, c.w) if config.ENCODER.get == x else "" 99 | out += "%2d. %s%s\n" % (x, e['name'], sel) 100 | 101 | g.content = out 102 | message = "Enter %sset encoder %s to select an encoder" 103 | g.message = message % (c.g, c.w) 104 | -------------------------------------------------------------------------------- /mps_youtube/commands/generate_playlist.py: -------------------------------------------------------------------------------- 1 | """ 2 | Playlist Generation 3 | """ 4 | from os import path 5 | from random import choice 6 | import string 7 | import pafy 8 | 9 | from .. import content, g, playlists, screen, util, listview 10 | from ..playlist import Playlist 11 | from . import command, search, album_search 12 | 13 | 14 | @command(r'mkp\s*(.{1,100})', 'mkp') 15 | def generate_playlist(sourcefile): 16 | """Generate a playlist from video titles in sourcefile""" 17 | 18 | # Hooks into this, check if the argument --description is present 19 | if "--description" in sourcefile or "-d" in sourcefile: 20 | description_generator(sourcefile) 21 | return 22 | 23 | expanded_sourcefile = path.expanduser(sourcefile) 24 | if not check_sourcefile(expanded_sourcefile): 25 | g.message = util.F('mkp empty') % expanded_sourcefile 26 | else: 27 | queries = read_sourcefile(expanded_sourcefile) 28 | g.message = util.F('mkp parsed') % (len(queries), sourcefile) 29 | if queries: 30 | create_playlist(queries) 31 | g.message = util.F('pl help') 32 | g.content = content.playlists_display() 33 | 34 | 35 | def read_sourcefile(filename): 36 | """Read each line as a query from filename""" 37 | with open(filename) as srcfl: 38 | queries = list() 39 | for item in srcfl.readlines(): 40 | clean_item = str(item).strip() 41 | if not clean_item: 42 | continue 43 | queries.append(clean_item) 44 | return queries 45 | 46 | 47 | def check_sourcefile(filename): 48 | """Check if filename exists and has a non-zero size""" 49 | return path.isfile(filename) and path.getsize(filename) > 0 50 | 51 | 52 | def create_playlist(queries, title=''): 53 | """Add a new playlist 54 | 55 | Create playlist with a random name, get the first 56 | match for each title in queries and append it to the playlist 57 | """ 58 | plname = title.replace(" ", "-") or random_plname() 59 | if not g.userpl.get(plname): 60 | g.userpl[plname] = Playlist(plname) 61 | for query in queries: 62 | g.message = util.F('mkp finding') % query 63 | screen.update() 64 | qresult = find_best_match(query) 65 | if qresult: 66 | g.userpl[plname].songs.append(qresult) 67 | if g.userpl[plname]: 68 | playlists.save() 69 | 70 | 71 | def find_best_match(query): 72 | """Find the best(first)""" 73 | # This assumes that the first match is the best one 74 | qs = search.generate_search_qs(query) 75 | wdata = pafy.call_gdata('search', qs) 76 | results = search.get_tracks_from_json(wdata) 77 | if results: 78 | res, score = album_search._best_song_match( 79 | results, query, 0.1, 1.0, 0.0) 80 | return res 81 | 82 | 83 | def random_plname(): 84 | """Generates a random alphanumeric string of 6 characters""" 85 | n_chars = 6 86 | return ''.join(choice(string.ascii_lowercase + string.digits) 87 | for _ in range(n_chars)) 88 | 89 | 90 | def description_generator(text): 91 | """ Fetches a videos description and parses it for 92 | - combinations 93 | """ 94 | if not isinstance(g.model, Playlist): 95 | g.message = util.F("mkp desc unknown") 96 | return 97 | 98 | # Use only the first result, for now 99 | num = text.replace("--description", "") 100 | num = num.replace("-d", "") 101 | num = util.number_string_to_list(num)[0] 102 | 103 | query = {} 104 | query['id'] = g.model[num].ytid 105 | query['part'] = 'snippet' 106 | query['maxResults'] = '1' 107 | data = pafy.call_gdata('videos', query)['items'][0]['snippet'] 108 | title = "mkp %s" % data['title'] 109 | data = util.fetch_songs(data['description'], data['title']) 110 | 111 | columns = [ 112 | {"name": "idx", "size": 3, "heading": "Num"}, 113 | {"name": "artist", "size": 30, "heading": "Artist"}, 114 | {"name": "title", "size": "remaining", "heading": "Title"}, 115 | ] 116 | 117 | def run_m(idx): 118 | """ Create playlist based on the 119 | results selected 120 | """ 121 | create_playlist(idx, title) 122 | 123 | if data: 124 | data = [listview.ListSongtitle(x) for x in data] 125 | g.content = listview.ListView(columns, data, run_m) 126 | g.message = util.F("mkp desc which data") 127 | else: 128 | g.message = util.F("mkp no valid") 129 | 130 | return 131 | -------------------------------------------------------------------------------- /mps_youtube/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | mps-youtube. 3 | 4 | https://github.com/np1/mps-youtube 5 | 6 | Copyright (C) 2014, 2015 np1 and contributors 7 | 8 | This program is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | 18 | You should have received a copy of the GNU General Public License 19 | along with this program. If not, see . 20 | 21 | """ 22 | 23 | import traceback 24 | import locale 25 | import sys 26 | import os 27 | 28 | import pafy 29 | 30 | from . import g, c, commands, screen, history, util 31 | from . import __version__, playlists, content, listview 32 | from . import config 33 | 34 | completer = None 35 | try: 36 | import readline 37 | readline.set_history_length(2000) 38 | has_readline = True 39 | completer = util.CommandCompleter() 40 | readline.parse_and_bind('tab: complete') 41 | readline.set_completer(completer.complete_command) 42 | readline.set_completer_delims('') 43 | 44 | except ImportError: 45 | has_readline = False 46 | 47 | 48 | mswin = os.name == "nt" 49 | 50 | locale.setlocale(locale.LC_ALL, "") # for date formatting 51 | 52 | 53 | def matchfunction(func, regex, userinput): 54 | """ Match userinput against regex. 55 | 56 | Call func, return True if matches. 57 | 58 | """ 59 | # Not supported in python 3.3 or lower 60 | # match = regex.fullmatch(userinput) 61 | # if match: 62 | match = regex.match(userinput) 63 | if match and match.group(0) == userinput: 64 | matches = match.groups() 65 | util.dbg("input: %s", userinput) 66 | util.dbg("function call: %s", func.__name__) 67 | util.dbg("regx matches: %s", matches) 68 | 69 | try: 70 | func(*matches) 71 | 72 | except IndexError: 73 | if g.debug_mode: 74 | g.content = ''.join(traceback.format_exception( 75 | *sys.exc_info())) 76 | g.message = util.F('invalid range') 77 | g.content = g.content or content.generate_songlist_display() 78 | 79 | except (ValueError, IOError) as e: 80 | if g.debug_mode: 81 | g.content = ''.join(traceback.format_exception( 82 | *sys.exc_info())) 83 | g.message = util.F('cant get track') % str(e) 84 | g.content = g.content or\ 85 | content.generate_songlist_display(zeromsg=g.message) 86 | 87 | except pafy.GdataError as e: 88 | if g.debug_mode: 89 | g.content = ''.join(traceback.format_exception( 90 | *sys.exc_info())) 91 | g.message = util.F('no data') % e 92 | g.content = g.content 93 | 94 | return True 95 | 96 | 97 | def prompt_for_exit(): 98 | """ Ask for exit confirmation. """ 99 | g.message = c.r + "Press ctrl-c again to exit" + c.w 100 | g.content = content.generate_songlist_display() 101 | screen.update() 102 | 103 | try: 104 | userinput = input(c.r + " > " + c.w) 105 | 106 | except (KeyboardInterrupt, EOFError): 107 | commands.misc.quits(showlogo=False) 108 | 109 | return userinput 110 | 111 | 112 | def main(): 113 | """ Main control loop. """ 114 | if config.SET_TITLE.get: 115 | util.set_window_title("mpsyt") 116 | 117 | if not g.command_line: 118 | g.content = content.logo(col=c.g, version=__version__) + "\n\n" 119 | g.message = "Enter /search-term to search or [h]elp" 120 | screen.update() 121 | 122 | # open playlists from file 123 | playlists.load() 124 | 125 | # open history from file 126 | history.load() 127 | 128 | # setup scrobbling 129 | commands.lastfm.init_network(verbose=False) 130 | prev_model = [] 131 | scrobble_funcs = [commands.album_search.search_album] 132 | 133 | arg_inp = " ".join(g.argument_commands) 134 | 135 | prompt = "> " 136 | arg_inp = arg_inp.replace(r",,", "[mpsyt-comma]") 137 | arg_inp = arg_inp.split(",") 138 | 139 | while True: 140 | next_inp = "" 141 | 142 | if len(arg_inp): 143 | next_inp = arg_inp.pop(0).strip() 144 | next_inp = next_inp.replace("[mpsyt-comma]", ",") 145 | 146 | try: 147 | userinput = next_inp or input(prompt).strip() 148 | 149 | except (KeyboardInterrupt, EOFError): 150 | userinput = prompt_for_exit() 151 | 152 | for i in g.commands: 153 | if matchfunction(i.function, i.regex, userinput): 154 | if prev_model != g.model and not i.function in scrobble_funcs: 155 | g.scrobble = False 156 | prev_model = g.model 157 | break 158 | 159 | else: 160 | g.content = g.content or content.generate_songlist_display() 161 | 162 | if g.command_line: 163 | g.content = "" 164 | 165 | if userinput and not g.command_line: 166 | g.message = c.b + "Bad syntax. Enter h for help" + c.w 167 | 168 | elif userinput and g.command_line: 169 | sys.exit("Bad syntax") 170 | 171 | screen.update() 172 | -------------------------------------------------------------------------------- /mps_youtube/description_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for trying to parse and retrieve song data from descriptions 3 | """ 4 | import re 5 | import random 6 | import pafy 7 | 8 | 9 | def calculate_certainty(line): 10 | """ Determine if a line contains a """ 11 | certainty_indexes = [ 12 | {'regex': r"(?:\(?(?:\d{0,4}:)?\d{0,2}:\d{0,2}\)?(?: - )?){1,2}", 13 | 'weight': 1}, 14 | {'regex': r"(([\w&()\[\]'\.\/ ]+)([ ]?[-]+[ ]?)([\w&()\[\]'\.\/ ]+))+", 15 | 'weight': 0.75}, 16 | {'regex': r"^([\d]+[. ]+)", 17 | 'weight': 1} 18 | ] 19 | 20 | certainty = 0.0 21 | for method in certainty_indexes: 22 | if re.match(method['regex'], line): 23 | certainty += method['weight'] 24 | 25 | return certainty / len(certainty_indexes) 26 | 27 | 28 | def has_artist(text): 29 | """ Determine if the strìng has artist or not """ 30 | regex = r"(?:([\w&()\[\]'\.\/ ]+)(?:[ ]?[-]+[ ]?)([\w&()\[\]'\.\/ ]+))+" 31 | return not re.match(regex, text) 32 | 33 | 34 | def strip_string(text, single=False): 35 | """ Strip an artist-combo string """ 36 | # Removes timestamps 37 | ts_reg = r"(?:\(?(?:\d{0,4}:)?\d{1,2}:\d{1,2}\)?(?: - )?){1,2}" 38 | text = re.sub(ts_reg, "", text) 39 | 40 | # Removes Tracknumbers. 41 | text = re.sub(r"^([\d]+[. ]+)", "", text) 42 | 43 | # Removes starting with non words 44 | text = re.sub(r"^[^\w&()\[\]'\.\/]", "", text, flags=re.MULTILINE) 45 | 46 | artist, track = None, None 47 | if not single: 48 | rgex = r"(?:([\w&()\[\]'\.\/ ]+)(?:[ ]?[-]+[ ]?)([\w&()\[\]'\.\/ ]+))+" 49 | artist, track = (re.findall(rgex, text)[0]) 50 | else: 51 | track = text 52 | 53 | return artist, track 54 | 55 | 56 | def long_substr(data): 57 | """ https://stackoverflow.com/a/2894073 """ 58 | substr = '' 59 | if len(data) > 1 and len(data[0]) > 0: 60 | for i in range(len(data[0])): 61 | for j in range(len(data[0])-i+1): 62 | if j > len(substr) and is_substr(data[0][i:i+j], data): 63 | substr = data[0][i:i+j] 64 | return substr 65 | 66 | 67 | def is_substr(find, data): 68 | """ Check if is substring """ 69 | if len(data) < 1 and len(find) < 1: 70 | return False 71 | for i, _ in enumerate(data): 72 | if find not in data[i]: 73 | return False 74 | return True 75 | 76 | 77 | def artist_from_title(title): 78 | """ Try to determine an artist by doing a search on the video 79 | and try to find the most common element by n number of times looking 80 | for the most common substring in a subset of the results from youtube 81 | """ 82 | query = {} 83 | query['q'] = title 84 | query['type'] = 'video' 85 | query['fields'] = "items(snippet(title))" 86 | query['maxResults'] = 50 87 | query['part'] = "snippet" 88 | 89 | results = pafy.call_gdata('search', query)['items'] 90 | titles = [x['snippet']['title'].upper() for x in results] 91 | 92 | alts = {} 93 | for _ in range(100): 94 | random.shuffle(titles) 95 | subset = titles[:10] 96 | string = long_substr(subset).strip() 97 | if len(string) > 3: 98 | alts[string] = alts.get(string, 0) + 1 99 | 100 | best_string = None 101 | if len(alts) == 1: 102 | best_string = list(alts.keys())[0].capitalize() 103 | else: 104 | best_guess = 99999 105 | best_string = None 106 | 107 | for key in list(alts.keys()): 108 | current_guess = title.upper().find(key) 109 | if current_guess < best_guess: 110 | best_guess = current_guess 111 | best_string = key.capitalize() 112 | 113 | best_string = re.sub(r"([^\w]+)$", "", best_string) 114 | best_string = re.sub(r"^([^\w]+)", "", best_string) 115 | return best_string 116 | 117 | 118 | def parse(text, title="Unknown"): 119 | """ Main function""" 120 | 121 | # Determine a certainty index for each line 122 | lines = [] 123 | for line in text.split('\n'): 124 | lines.append((calculate_certainty(line), line)) 125 | 126 | # Get average from all strings 127 | certainty_average = sum([x[0] for x in lines]) / len(lines) 128 | 129 | # Single out lines with above average certainty index 130 | lines = filter(lambda a: a is not None, 131 | [x if x[0] > certainty_average else None for x in lines]) 132 | 133 | # Determine if they are artist combo strings or only title 134 | cmbs = [] 135 | for line in lines: 136 | is_ac = has_artist(line[1]) 137 | cmbs.append(strip_string(line[1], is_ac)) 138 | 139 | # No or very few tracklists will ommit aritsts or add artist information 140 | # on only a few select number of tracks, therefore we count entries with 141 | # and without artist, and remove the anomalities IF the number of 142 | # anomalities are small enough 143 | 144 | counters = {'has': 0, 'not': 0} 145 | for combo in cmbs: 146 | counters['has' if combo[0] else 'not'] += 1 147 | 148 | dominant = 'has' if counters['has'] > counters['not'] else 'not' 149 | 150 | diff = abs(counters['has'] - counters['not']) 151 | if diff > sum([counters['has'], counters['not']]): 152 | print("Too many anomalities detected") 153 | return [] 154 | 155 | if dominant == 'has': 156 | cmbs = filter(lambda a: a is not None, 157 | [x if x[0] is not None else None for x in cmbs]) 158 | else: 159 | arti = artist_from_title(title) 160 | cmbs = filter(lambda a: a is not None, 161 | [(arti, x[1]) if x[0] is None else None for x in cmbs]) 162 | return list(cmbs) 163 | -------------------------------------------------------------------------------- /mps_youtube/commands/play.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import webbrowser 3 | import random 4 | from urllib.error import HTTPError, URLError 5 | 6 | from .. import g, c, streams, util, content, config 7 | from . import command, WORD, RS 8 | from .songlist import plist 9 | from .search import yt_url, related 10 | 11 | 12 | @command(r'play\s+(%s|\d+)' % WORD, 'play') 13 | def play_pl(name): 14 | """ Play a playlist by name. """ 15 | if name.isdigit(): 16 | name = int(name) 17 | name = sorted(g.userpl)[name - 1] 18 | 19 | saved = g.userpl.get(name) 20 | 21 | if not saved: 22 | name = util.get_near_name(name, g.userpl) 23 | saved = g.userpl.get(name) 24 | 25 | if saved: 26 | g.model.songs = list(saved.songs) 27 | play_all("", "", "") 28 | 29 | else: 30 | g.message = util.F("pl not found") % name 31 | g.content = content.playlists_display() 32 | 33 | 34 | @command(r'(%s{0,3})([-,\d\s\[\]]{1,250})\s*(%s{0,3})$' % 35 | (RS, RS)) 36 | def play(pre, choice, post=""): 37 | """ Play choice. Use repeat/random if appears in pre/post. """ 38 | # pylint: disable=R0914 39 | # too many local variables 40 | 41 | # Im just highjacking this if g.content is a 42 | # content.Content class 43 | if isinstance(g.content, content.Content): 44 | play_call = getattr(g.content, "_play", None) 45 | if callable(play_call): 46 | play_call(pre, choice, post) 47 | return 48 | 49 | if g.browse_mode == "ytpl": 50 | 51 | if choice.isdigit(): 52 | return plist(g.ytpls[int(choice) - 1]['link']) 53 | else: 54 | g.message = "Invalid playlist selection: %s" % c.y + choice + c.w 55 | g.content = content.generate_songlist_display() 56 | return 57 | 58 | if not g.model: 59 | g.message = c.r + "There are no tracks to select" + c.w 60 | g.content = g.content or content.generate_songlist_display() 61 | 62 | else: 63 | shuffle = "shuffle" in pre + post 64 | repeat = "repeat" in pre + post 65 | novid = "-a" in pre + post 66 | fs = "-f" in pre + post 67 | nofs = "-w" in pre + post 68 | forcevid = "-v" in pre + post 69 | 70 | if ((novid and fs) or (novid and nofs) or (nofs and fs) 71 | or (novid and forcevid)): 72 | raise IOError("Conflicting override options specified") 73 | 74 | override = False 75 | override = "audio" if novid else override 76 | override = "fullscreen" if fs else override 77 | override = "window" if nofs else override 78 | 79 | if (not fs) and (not nofs): 80 | override = "forcevid" if forcevid else override 81 | 82 | selection = util.parse_multi(choice) 83 | songlist = [g.model[x - 1] for x in selection] 84 | 85 | # cache next result of displayed items 86 | # when selecting a single item 87 | if len(songlist) == 1: 88 | chosen = selection[0] - 1 89 | 90 | if len(g.model) > chosen + 1: 91 | streams.preload(g.model[chosen + 1], override=override) 92 | 93 | if g.scrobble: 94 | old_queue = g.scrobble_queue 95 | g.scrobble_queue = [g.scrobble_queue[x - 1] for x in selection] 96 | 97 | try: 98 | if not config.PLAYER.get or not util.has_exefile(config.PLAYER.get): 99 | g.message = "Player not configured! Enter %sset player "\ 100 | "%s to set a player" % (c.g, c.w) 101 | return 102 | g.PLAYER_OBJ.play(songlist, shuffle, repeat, override) 103 | except KeyboardInterrupt: 104 | return 105 | finally: 106 | g.content = content.generate_songlist_display() 107 | 108 | if g.scrobble: 109 | g.scrobble_queue = old_queue 110 | 111 | if config.AUTOPLAY.get: 112 | related(selection.pop()) 113 | play(pre, str(random.randint(1, 15)), post="") 114 | 115 | 116 | @command(r'(%s{0,3})(?:\*|all)\s*(%s{0,3})' % 117 | (RS, RS)) 118 | def play_all(pre, choice, post=""): 119 | """ Play all tracks in model (last displayed). shuffle/repeat if req'd.""" 120 | options = pre + choice + post 121 | play(options, "1-" + str(len(g.model))) 122 | 123 | 124 | @command(r'playurl\s(.*[-_a-zA-Z0-9]{11}[^\s]*)(\s-(?:f|a|w))?', 'playurl') 125 | def play_url(url, override): 126 | """ Open and play a youtube video url. """ 127 | override = override if override else "_" 128 | g.browse_mode = "normal" 129 | yt_url(url, print_title=1) 130 | 131 | if len(g.model) == 1: 132 | play(override, "1", "_") 133 | 134 | if g.command_line: 135 | sys.exit() 136 | 137 | 138 | @command(r'browserplay\s(\d{1,50})', 'browserplay') 139 | def browser_play(number): 140 | """Open a previously searched result in the browser.""" 141 | if (len(g.model) == 0): 142 | g.message = c.r + "No previous search." + c.w 143 | g.content = content.logo(c.r) 144 | return 145 | 146 | try: 147 | index = int(number) - 1 148 | 149 | if (0 <= index < len(g.model)): 150 | base_url = "https://www.youtube.com/watch?v=" 151 | video = g.model[index] 152 | url = base_url + video.ytid 153 | webbrowser.open(url) 154 | g.content = g.content or content.generate_songlist_display() 155 | 156 | else: 157 | g.message = c.r + "Out of range." + c.w 158 | g.content = g.content or content.generate_songlist_display() 159 | return 160 | 161 | except (HTTPError, URLError, Exception) as e: 162 | g.message = c.r + str(e) + c.w 163 | g.content = g.content or content.generate_songlist_display() 164 | return 165 | -------------------------------------------------------------------------------- /mps_youtube/playlists.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pickle 4 | 5 | from . import g, c, screen, util 6 | from .playlist import Playlist, Video 7 | from pafy.backend_shared import extract_video_id 8 | 9 | 10 | def save(): 11 | """ Save playlists. Called each time a playlist is saved or deleted. """ 12 | for pl in g.userpl: 13 | with open(os.path.join(g.PLFOLDER, pl+'.m3u'), 'w') as plf: 14 | plf.write('#EXTM3U\n\n') 15 | for song in g.userpl[pl].songs: 16 | plf.write('#EXTINF:%d,%s\n' % (song.length, song.title)) 17 | plf.write('https://www.youtube.com/watch?v=%s\n' % song.ytid) 18 | 19 | util.dbg(c.r + "Playlist saved\n---" + c.w) 20 | 21 | 22 | def load(): 23 | """ Open playlists. Called once on script invocation. """ 24 | _convert_playlist_to_v2() 25 | _convert_playlist_to_m3u() 26 | try: 27 | # Loop through all files ending in '.m3u' 28 | for m3u in [m3u for m3u in os.listdir(g.PLFOLDER) if m3u[-4:] == '.m3u']: 29 | g.userpl[m3u[:-4]] = read_m3u(os.path.join(g.PLFOLDER, m3u)) 30 | 31 | except FileNotFoundError: 32 | # No playlist folder, create an empty one 33 | if not os.path.isdir(g.PLFOLDER): 34 | g.userpl = {} 35 | os.mkdir(g.PLFOLDER) 36 | save() 37 | 38 | # remove any cached urls from playlist file, these are now 39 | # stored in a separate cache file 40 | 41 | do_save = False 42 | 43 | for k, v in g.userpl.items(): 44 | for song in v.songs: 45 | if hasattr(song, "urls"): 46 | util.dbg("remove %s: %s", k, song.urls) 47 | del song.urls 48 | do_save = True 49 | 50 | if do_save: 51 | save() 52 | 53 | 54 | def delete(name): 55 | """ Delete playlist, including m3u file. """ 56 | del g.userpl[name] 57 | os.remove(os.path.join(g.PLFOLDER, name + '.m3u')) 58 | 59 | 60 | def read_m3u(m3u): 61 | """ Processes an m3u file into a Playlist object. """ 62 | name = os.path.basename(m3u)[:-4] 63 | songs = [] 64 | expect_ytid = False 65 | 66 | with open(m3u, 'r') as plf: 67 | if plf.readline().startswith('#EXTM3U'): 68 | for line in plf: 69 | if line.startswith('#EXTINF:') and not expect_ytid: 70 | duration, title = line.replace('#EXTINF:', '').strip().split(',', 1) 71 | expect_ytid = True 72 | elif not line.startswith('\n') and not line.startswith('#') and expect_ytid: 73 | ytid = extract_video_id(line).strip() 74 | songs.append(Video(ytid, title, int(duration))) 75 | expect_ytid = False 76 | 77 | # Handles a simple m3u file which should just be a list of urls 78 | else: 79 | plf.seek(0) 80 | for line in plf: 81 | if not line.startswith('#'): 82 | try: 83 | p = util.get_pafy(line) 84 | songs.append(Video(p.videoid, p.title, p.length)) 85 | except (IOError, ValueError) as e: 86 | util.dbg(c.r + "Error loading video: " + str(e) + c.w) 87 | 88 | return Playlist(name, songs) 89 | 90 | 91 | def _convert_playlist_to_v2(): 92 | """ Convert previous playlist file to v2 playlist. """ 93 | # skip if previously done 94 | if os.path.isfile(g.PLFILE): 95 | return 96 | 97 | # skip if no playlist files exist 98 | elif not os.path.isfile(g.OLD_PLFILE): 99 | return 100 | 101 | try: 102 | with open(g.OLD_PLFILE, "rb") as plf: 103 | old_playlists = pickle.load(plf) 104 | 105 | except IOError: 106 | sys.exit("Couldn't open old playlist file") 107 | 108 | # rename old playlist file 109 | backup = g.OLD_PLFILE + "_v1_backup" 110 | 111 | if os.path.isfile(backup): 112 | sys.exit("Error, backup exists but new playlist exists not!") 113 | 114 | os.rename(g.OLD_PLFILE, backup) 115 | 116 | # do the conversion 117 | for plname, plitem in old_playlists.items(): 118 | 119 | songs = [] 120 | 121 | for video in plitem.songs: 122 | v = Video(video['link'], video['title'], video['duration']) 123 | songs.append(v) 124 | 125 | g.userpl[plname] = Playlist(plname, songs) 126 | 127 | # save as v2 128 | os.mkdir(g.PLFOLDER) 129 | save() 130 | 131 | 132 | def _convert_playlist_to_m3u(): 133 | """ Convert playlist_v2 file to the m3u format. 134 | This should create a .m3u playlist for each playlist in playlist_v2. """ 135 | # Skip if playlists folder exists 136 | if os.path.isdir(g.PLFOLDER): 137 | return 138 | 139 | # Skip if no playlist files exist 140 | elif not os.path.isfile(g.PLFILE): 141 | return 142 | 143 | try: 144 | with open(g.PLFILE, 'rb') as plf: 145 | old_playlists = pickle.load(plf) 146 | 147 | except AttributeError: 148 | # playlist is from a time when this module was __main__ 149 | # https://github.com/np1/mps-youtube/issues/214 150 | import __main__ 151 | __main__.Playlist = Playlist 152 | __main__.Video = Video 153 | 154 | from . import main 155 | main.Playlist = Playlist 156 | main.Video = Video 157 | 158 | with open(g.PLFILE, "rb") as plf: 159 | g.userpl = pickle.load(plf) 160 | 161 | os.mkdir(g.PLFOLDER) 162 | save() 163 | screen.msgexit("Updated playlist file. Please restart mpsyt", 1) 164 | 165 | except EOFError: 166 | screen.msgexit("Error opening playlists from %s" % g.PLFILE, 1) 167 | 168 | except IOError: 169 | sys.exit("Couldn't open old playlist file") 170 | 171 | for pl in old_playlists: 172 | songs = [] 173 | for song in old_playlists[pl]: 174 | songs.append(song) 175 | 176 | g.userpl[pl] = Playlist(pl, songs) 177 | 178 | os.mkdir(g.PLFOLDER) 179 | save() 180 | -------------------------------------------------------------------------------- /mps_youtube/listview/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DOCSTING COMES HERE 3 | """ 4 | import re 5 | import math 6 | 7 | from .. import c, g, util, content 8 | from .base import ListViewItem 9 | from .user import ListUser 10 | from .livestream import ListLiveStream 11 | from .songtitle import ListSongtitle 12 | 13 | 14 | class ListView(content.PaginatedContent): 15 | """ Content Agnostic Numbered List 16 | 17 | This class, using ListViewItems as abstractions you can 18 | give it a list of data and which columns to show and it will 19 | show it. 20 | 21 | Todo: 22 | Currently we rely on the commands/play code to send information 23 | about which elements are being picked. 24 | 25 | Attributes: 26 | func The function that will be run on the selected items 27 | objects List of objects(or a ContentQuery object) 28 | columns A list of Hashes containing information about which 29 | columns to show 30 | page Current Page 31 | 32 | Column format: 33 | {"name": "idx", "size": 3, "heading": "Num"} 34 | name: The method name that will be called from the ListViewItem 35 | size: How much size is allocated to the columns, 36 | see ListView.content for more information about 37 | the dynamic options 38 | heading: The text shown in the header 39 | 40 | "idx" is generated in the content function, not by the ListViewItem 41 | """ 42 | func = None 43 | objects = None 44 | columns = None 45 | page = 0 46 | 47 | def __init__(self, columns, objects, function_call=None): 48 | """ """ 49 | self.func = function_call 50 | self.objects = objects 51 | self.columns = columns 52 | self.object_type = None 53 | 54 | # Ensure single type of object 55 | types = len(set([obj.__class__ for obj in objects])) 56 | if types == 0: 57 | raise BaseException("No objects in list") 58 | if types > 1: 59 | raise BaseException("More than one kind of objects in list") 60 | 61 | self.object_type = [obj.__class__ for obj in objects][0] 62 | 63 | def numPages(self): 64 | """ Returns # of pages """ 65 | return max(1, math.ceil(len(self.objects) / self.views_per_page())) 66 | 67 | def getPage(self, page): 68 | self.page = page 69 | return self.content() 70 | 71 | def _page_slice(self): 72 | chgt = self.views_per_page() 73 | return slice(self.page * chgt, (self.page+1) * chgt) 74 | 75 | def content(self): 76 | """ Generates content 77 | 78 | =============== 79 | Dynamic fields 80 | =============== 81 | 82 | Column.size may instead of an integer be a string 83 | containing either "length" or "remaining". 84 | 85 | Length is for time formats like 20:40 86 | Remaining will allocate all remaining space to that 87 | column. 88 | 89 | TODO: Make it so set columns can set "remaining" ? 90 | """ 91 | # Sums all ints, deal with strings later 92 | remaining = (util.getxy().width) - sum(1 + (x['size'] if x['size'] and x['size'].__class__ == int else 0) for x in self.columns) - (len(self.columns)) 93 | lengthsize = 0 94 | if "length" in [x['size'] for x in self.columns]: 95 | max_l = max((getattr(x, "length")() for x in self.objects)) 96 | lengthsize = 8 if max_l > 35999 else 7 97 | lengthsize = 6 if max_l < 6000 else lengthsize 98 | 99 | for col in self.columns: 100 | if col['size'] == "remaining": 101 | col['size'] = remaining - lengthsize 102 | if col['size'] == "length": 103 | col['size'] = lengthsize 104 | 105 | for num, column in enumerate(self.columns): 106 | column['idx'] = num 107 | column['sign'] = "-" if not column['name'] == "length" else "" 108 | 109 | fmt = ["%{}{}s ".format(x['sign'], x['size']) for x in self.columns] 110 | fmtrow = fmt[0:1] + ["%s "] + fmt[2:] 111 | fmt, fmtrow = "".join(fmt).strip(), "".join(fmtrow).strip() 112 | titles = tuple([x['heading'][:x['size']] for x in self.columns]) 113 | out = "\n" + (c.ul + fmt % titles + c.w) + "\n" 114 | 115 | for num, obj in enumerate(self.objects[self._page_slice()]): 116 | col = (c.r if num % 2 == 0 else c.p) 117 | idx = num + (self.views_per_page() * self.page) + 1 118 | 119 | line = '' 120 | for column in self.columns: 121 | fieldsize, field = column['size'], column['name'] 122 | direction = "<" if column['sign'] == "-" else ">" 123 | 124 | if field == "idx": 125 | field = "%2d" % idx 126 | 127 | else: 128 | field = getattr(obj, field)(fieldsize) 129 | field = str(field) if field.__class__ != str else field 130 | 131 | line += util.uea_pad(fieldsize, field, direction) 132 | 133 | if column != self.columns[-1]: 134 | line += " " 135 | 136 | line = col + line + c.w 137 | out += line + "\n" 138 | 139 | return out 140 | 141 | def _play(self, _, choice, __): # pre, choice, post 142 | """ Handles what happends when a user selects something from the list 143 | Currently this functions hooks into commands/play 144 | """ 145 | 146 | uids = [] 147 | for splitted_choice in choice.split(","): 148 | cho = splitted_choice.strip() 149 | if cho.isdigit(): 150 | uids.append(int(cho) - 1) 151 | else: 152 | cho = cho.split("-") 153 | if cho[0].isdigit() and cho[1].isdigit(): 154 | uids += list(range(int(cho[0]) - 1, int(cho[1]))) 155 | 156 | var = getattr(self.object_type, "return_field")() 157 | self.func([getattr(self.objects[x], var)() for x in uids]) 158 | 159 | def views_per_page(self): 160 | """ Determines how many views can be per page 161 | """ 162 | return util.getxy().max_results 163 | -------------------------------------------------------------------------------- /mps_youtube/commands/local_playlist.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .. import g, c, playlists, content, util 4 | from ..playlist import Playlist 5 | from . import command, WORD 6 | from .songlist import paginatesongs, songlist_rm_add 7 | 8 | 9 | @command(r'rmp\s*(\d+|%s)' % WORD, 'rmp') 10 | def playlist_remove(name): 11 | """ Delete a saved playlist by name - or purge working playlist if *all.""" 12 | if name.isdigit() or g.userpl.get(name): 13 | 14 | if name.isdigit(): 15 | name = int(name) - 1 16 | name = sorted(g.userpl)[name] 17 | 18 | playlists.delete(name) 19 | g.message = "Deleted playlist %s%s%s" % (c.y, name, c.w) 20 | g.content = content.playlists_display() 21 | #playlists.save() 22 | 23 | else: 24 | g.message = util.F('pl not found advise ls') % name 25 | g.content = content.playlists_display() 26 | 27 | 28 | @command(r'add\s*(-?\d[-,\d\s]{1,250})(%s)' % WORD, 'add') 29 | def playlist_add(nums, playlist): 30 | """ Add selected song nums to saved playlist. """ 31 | nums = util.parse_multi(nums) 32 | # Replacing spaces with hyphens before checking if playlist already exist. 33 | # See https://github.com/mps-youtube/mps-youtube/issues/1046. 34 | playlist = playlist.replace(" ", "-") 35 | 36 | if not g.userpl.get(playlist): 37 | g.userpl[playlist] = Playlist(playlist) 38 | 39 | for songnum in nums: 40 | g.userpl[playlist].songs.append(g.model[songnum - 1]) 41 | dur = g.userpl[playlist].duration 42 | f = (len(nums), playlist, len(g.userpl[playlist]), dur) 43 | g.message = util.F('added to saved pl') % f 44 | 45 | if nums: 46 | playlists.save() 47 | 48 | g.content = content.generate_songlist_display() 49 | 50 | 51 | @command(r'mv\s*(\d{1,3})\s*(%s)' % WORD, 'mv') 52 | def playlist_rename_idx(_id, name): 53 | """ Rename a playlist by ID. """ 54 | _id = int(_id) - 1 55 | playlist_rename(sorted(g.userpl)[_id] + " " + name) 56 | 57 | 58 | @command(r'mv\s*(%s\s+%s)' % (WORD, WORD), 'mv') 59 | def playlist_rename(playlists_): 60 | """ Rename a playlist using mv command. """ 61 | # Deal with old playlist names that permitted spaces 62 | a, b = "", playlists_.split(" ") 63 | while a not in g.userpl: 64 | a = (a + " " + (b.pop(0))).strip() 65 | if not b and a not in g.userpl: 66 | g.message = util.F('no pl match for rename') 67 | g.content = g.content or content.playlists_display() 68 | return 69 | 70 | b = "-".join(b) 71 | g.userpl[b] = Playlist(b) 72 | g.userpl[b].songs = list(g.userpl[a].songs) 73 | playlist_remove(a) 74 | g.message = util.F('pl renamed') % (a, b) 75 | playlists.save() 76 | 77 | 78 | @command(r'(rm|add)\s(?:\*|all)', 'rm', 'add') 79 | def add_rm_all(action): 80 | """ Add all displayed songs to current playlist. 81 | 82 | remove all displayed songs from view. 83 | 84 | """ 85 | if action == "rm": 86 | g.model.songs.clear() 87 | msg = c.b + "Cleared all songs" + c.w 88 | g.content = content.generate_songlist_display(zeromsg=msg) 89 | 90 | elif action == "add": 91 | size = len(g.model) 92 | songlist_rm_add("add", "-" + str(size)) 93 | 94 | 95 | @command(r'save', 'save') 96 | def save_last(): 97 | """ Save command with no playlist name. """ 98 | if g.last_opened: 99 | open_save_view("save", g.last_opened) 100 | 101 | else: 102 | saveas = "" 103 | 104 | # save using artist name in postion 1 105 | if g.model: 106 | saveas = g.model[0].title[:18].strip() 107 | saveas = re.sub(r"[^-\w]", "-", saveas, flags=re.UNICODE) 108 | 109 | # loop to find next available name 110 | post = 0 111 | 112 | while g.userpl.get(saveas): 113 | post += 1 114 | saveas = g.model[0].title[:18].strip() + "-" + str(post) 115 | 116 | # Playlists are not allowed to start with a digit 117 | # TODO: Possibly change this, but ban purely numerical names 118 | saveas = saveas.lstrip("0123456789") 119 | 120 | open_save_view("save", saveas) 121 | 122 | 123 | @command(r'(open|save|view)\s*(%s)' % WORD, 'open', 'save', 'view') 124 | def open_save_view(action, name): 125 | """ Open, save or view a playlist by name. Get closest name match. """ 126 | name = name.replace(" ", "-") 127 | if action == "open" or action == "view": 128 | saved = g.userpl.get(name) 129 | 130 | if not saved: 131 | name = util.get_near_name(name, g.userpl) 132 | saved = g.userpl.get(name) 133 | 134 | elif action == "open": 135 | g.active.songs = list(saved.songs) 136 | g.last_opened = name 137 | msg = util.F("pl loaded") % name 138 | paginatesongs(g.active, msg=msg) 139 | 140 | elif action == "view": 141 | g.last_opened = "" 142 | msg = util.F("pl viewed") % name 143 | paginatesongs(list(saved.songs), msg=msg) 144 | 145 | elif not saved and action in "view open".split(): 146 | g.message = util.F("pl not found") % name 147 | g.content = content.playlists_display() 148 | 149 | elif action == "save": 150 | if not g.model: 151 | g.message = "Nothing to save. " + util.F('advise search') 152 | g.content = content.generate_songlist_display() 153 | 154 | else: 155 | g.userpl[name] = Playlist(name, list(g.model.songs)) 156 | g.message = util.F('pl saved') % name 157 | playlists.save() 158 | g.content = content.generate_songlist_display() 159 | 160 | 161 | @command(r'(open|view)\s*(\d{1,4})', 'open', 'view') 162 | def open_view_bynum(action, num): 163 | """ Open or view a saved playlist by number. """ 164 | srt = sorted(g.userpl) 165 | name = srt[int(num) - 1] 166 | open_save_view(action, name) 167 | 168 | 169 | @command(r'ls', 'ls') 170 | def ls(): 171 | """ List user saved playlists. """ 172 | if not g.userpl: 173 | g.message = util.F('no playlists') 174 | g.content = g.content or \ 175 | content.generate_songlist_display(zeromsg=g.message) 176 | 177 | else: 178 | g.content = content.playlists_display() 179 | g.message = util.F('pl help') 180 | 181 | 182 | @command(r'vp', 'vp') 183 | def vp(): 184 | """ View current working playlist. """ 185 | 186 | msg = util.F('current pl') 187 | txt = util.F('advise add') if g.model else util.F('advise search') 188 | failmsg = util.F('pl empty') + " " + txt 189 | 190 | paginatesongs(g.active, msg=msg, failmsg=failmsg) 191 | -------------------------------------------------------------------------------- /mps_youtube/streams.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | from urllib.request import urlopen 4 | 5 | from . import g, c, screen, config, util 6 | 7 | 8 | def prune(): 9 | """ Keep cache size in check. """ 10 | while len(g.pafs) > g.max_cached_streams: 11 | g.pafs.popitem(last=False) 12 | 13 | while len(g.streams) > g.max_cached_streams: 14 | g.streams.popitem(last=False) 15 | 16 | # prune time expired items 17 | 18 | now = time.time() 19 | oldpafs = [k for k in g.pafs if g.pafs[k].expiry < now] 20 | 21 | if len(oldpafs): 22 | util.dbg(c.r + "%s old pafy items pruned%s", len(oldpafs), c.w) 23 | 24 | for oldpaf in oldpafs: 25 | g.pafs.pop(oldpaf, 0) 26 | 27 | oldstreams = [k for k in g.streams if g.streams[k]['expiry'] < now] 28 | 29 | if len(oldstreams): 30 | util.dbg(c.r + "%s old stream items pruned%s", len(oldstreams), c.w) 31 | 32 | for oldstream in oldstreams: 33 | g.streams.pop(oldstream, 0) 34 | 35 | util.dbg(c.b + "paf: %s, streams: %s%s", len(g.pafs), len(g.streams), c.w) 36 | 37 | 38 | def get(vid, force=False, callback=None, threeD=False): 39 | """ Get all streams as a dict. callback function passed to get_pafy. """ 40 | now = time.time() 41 | ytid = vid.ytid 42 | have_stream = g.streams.get(ytid) and g.streams[ytid]['expiry'] > now 43 | prfx = "preload: " if not callback else "" 44 | 45 | if not force and have_stream: 46 | ss = str(int(g.streams[ytid]['expiry'] - now) // 60) 47 | util.dbg("%s%sGot streams from cache (%s mins left)%s", 48 | c.g, prfx, ss, c.w) 49 | return g.streams.get(ytid)['meta'] 50 | 51 | p = util.get_pafy(vid, force=force, callback=callback) 52 | ps = p.allstreams if threeD else [x for x in p.allstreams if not x.threed] 53 | 54 | try: 55 | # test urls are valid 56 | [x.url for x in ps] 57 | 58 | except TypeError: 59 | # refetch if problem 60 | util.dbg("%s****Type Error in get_streams. Retrying%s", c.r, c.w) 61 | p = util.get_pafy(vid, force=True, callback=callback) 62 | ps = p.allstreams if threeD else [x for x in p.allstreams 63 | if not x.threed] 64 | 65 | streams = [{"url": s.url, 66 | "ext": s.extension, 67 | "quality": s.quality, 68 | "rawbitrate": s.rawbitrate, 69 | "mtype": s.mediatype, 70 | "size": -1} for s in ps] 71 | 72 | g.streams[ytid] = dict(expiry=p.expiry, meta=streams) 73 | prune() 74 | return streams 75 | 76 | 77 | def select(slist, q=0, audio=False, m4a_ok=True, maxres=None): 78 | """ Select a stream from stream list. """ 79 | maxres = maxres or config.MAX_RES.get 80 | slist = slist['meta'] if isinstance(slist, dict) else slist 81 | 82 | def okres(x): 83 | """ Return True if resolution is within user specified maxres. """ 84 | return int(x['quality'].split("x")[1]) <= maxres 85 | 86 | def getq(x): 87 | """ Return height aspect of resolution, eg 640x480 => 480. """ 88 | return int(x['quality'].split("x")[1]) 89 | 90 | def getbitrate(x): 91 | """Return the bitrate of a stream.""" 92 | return x['rawbitrate'] 93 | 94 | if audio: 95 | streams = [x for x in slist if x['mtype'] == "audio"] 96 | if not m4a_ok: 97 | streams = [x for x in streams if not x['ext'] == "m4a"] 98 | if not config.AUDIO_FORMAT.get == "auto": 99 | if m4a_ok and config.AUDIO_FORMAT.get == "m4a": 100 | streams = [x for x in streams if x['ext'] == "m4a"] 101 | if config.AUDIO_FORMAT.get == "webm": 102 | streams = [x for x in streams if x['ext'] == "webm"] 103 | if not streams: 104 | streams = [x for x in slist if x['mtype'] == "audio"] 105 | streams = sorted(streams, key=getbitrate, reverse=True) 106 | else: 107 | streams = [x for x in slist if x['mtype'] == "normal" and okres(x)] 108 | if not config.VIDEO_FORMAT.get == "auto": 109 | if config.VIDEO_FORMAT.get == "mp4": 110 | streams = [x for x in streams if x['ext'] == "mp4"] 111 | if config.VIDEO_FORMAT.get == "webm": 112 | streams = [x for x in streams if x['ext'] == "webm"] 113 | if config.VIDEO_FORMAT.get == "3gp": 114 | streams = [x for x in streams if x['ext'] == "3gp"] 115 | if not streams: 116 | streams = [x for x in slist if x['mtype'] == "normal" and okres(x)] 117 | streams = sorted(streams, key=getq, reverse=True) 118 | 119 | util.dbg("select stream, q: %s, audio: %s, len: %s", q, audio, len(streams)) 120 | 121 | try: 122 | ret = streams[q] 123 | 124 | except IndexError: 125 | ret = streams[0] if q and len(streams) else None 126 | 127 | return ret 128 | 129 | 130 | def get_size(ytid, url, preloading=False): 131 | """ Get size of stream, try stream cache first. """ 132 | # try cached value 133 | stream = [x for x in g.streams[ytid]['meta'] if x['url'] == url][0] 134 | size = stream['size'] 135 | prefix = "preload: " if preloading else "" 136 | 137 | if not size == -1: 138 | util.dbg("%s%susing cached size: %s%s", c.g, prefix, size, c.w) 139 | 140 | else: 141 | screen.writestatus("Getting content length", mute=preloading) 142 | stream['size'] = _get_content_length(url, preloading=preloading) 143 | util.dbg("%s%s - content-length: %s%s", c.y, prefix, stream['size'], c.w) 144 | 145 | return stream['size'] 146 | 147 | 148 | def _get_content_length(url, preloading=False): 149 | """ Return content length of a url. """ 150 | prefix = "preload: " if preloading else "" 151 | util.dbg(c.y + prefix + "getting content-length header" + c.w) 152 | response = urlopen(url) 153 | headers = response.headers 154 | cl = headers['content-length'] 155 | return int(cl) 156 | 157 | 158 | def preload(song, delay=2, override=False): 159 | """ Get streams. """ 160 | args = (song, delay, override) 161 | t = threading.Thread(target=_preload, args=args) 162 | t.daemon = True 163 | t.start() 164 | 165 | 166 | def _preload(song, delay, override): 167 | """ Get streams (runs in separate thread). """ 168 | if g.preload_disabled: 169 | return 170 | 171 | ytid = song.ytid 172 | g.preloading.append(ytid) 173 | time.sleep(delay) 174 | video = config.SHOW_VIDEO.get 175 | video = True if override in ("fullscreen", "window", "forcevid") else video 176 | video = False if override == "audio" else video 177 | 178 | try: 179 | m4a = "mplayer" not in config.PLAYER.get 180 | streamlist = get(song) 181 | stream = select(streamlist, audio=not video, m4a_ok=m4a) 182 | 183 | if not stream and not video: 184 | # preload video stream, no audio available 185 | stream = select(streamlist, audio=False) 186 | 187 | get_size(ytid, stream['url'], preloading=True) 188 | 189 | except (ValueError, AttributeError, IOError) as e: 190 | util.dbg(e) # Fail silently on preload 191 | 192 | finally: 193 | g.preloading.remove(song.ytid) 194 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | mps-youtube 2 | =========== 3 | 4 | .. image:: https://img.shields.io/pypi/v/mps-youtube.svg 5 | :target: https://pypi.python.org/pypi/mps-youtube 6 | .. image:: https://img.shields.io/pypi/dm/mps-youtube.svg 7 | :target: https://pypi.python.org/pypi/mps-youtube 8 | .. image:: https://img.shields.io/pypi/wheel/mps-youtube.svg 9 | :target: http://pythonwheels.com/ 10 | :alt: Wheel Status 11 | 12 | 13 | Features 14 | -------- 15 | - Search and play audio/video from YouTube 16 | - Search tracks of albums by album title 17 | - Search and import YouTube playlists 18 | - Create and save local playlists 19 | - Download audio/video 20 | - Convert to mp3 & other formats (requires ffmpeg or avconv) 21 | - View video comments 22 | - Works with Python 3.x 23 | - Works with Windows, Linux and Mac OS X 24 | - Requires mplayer or mpv 25 | 26 | This project is based on `mps `_, a terminal based program to search, stream and download music. This implementation uses YouTube as a source of content and can play and download video as well as audio. The `pafy `_ library handles interfacing with YouTube. 27 | 28 | `FAQ / Troubleshooting common issues `_ 29 | 30 | Screenshots 31 | ----------- 32 | 33 | 34 | Search 35 | ~~~~~~ 36 | .. image:: http://mps-youtube.github.io/mps-youtube/std-search.png 37 | 38 | A standard search is performed by entering ``/`` followed by search terms. 39 | 40 | Local Playlists 41 | ~~~~~~~~~~~~~~~ 42 | .. image:: http://mps-youtube.github.io/mps-youtube/local-playlist.png 43 | 44 | Search result items can easily be stored in local playlists. 45 | 46 | YouTube Playlists 47 | ~~~~~~~~~~~~~~~~~ 48 | .. image:: http://mps-youtube.github.io/mps-youtube/playlist-search.png 49 | 50 | YouTube playlists can be searched and played or saved as local playlists. 51 | 52 | Download 53 | ~~~~~~~~ 54 | .. image:: http://mps-youtube.github.io/mps-youtube/download.png 55 | 56 | Content can be downloaded in various formats and resolutions. 57 | 58 | Comments 59 | ~~~~~~~~ 60 | .. image:: http://mps-youtube.github.io/mps-youtube/comments.png 61 | 62 | A basic comments browser is available to view YouTube user comments. 63 | 64 | Music Album Matching 65 | ~~~~~~~~~~~~~~~~~~~~ 66 | 67 | .. image:: http://mps-youtube.github.io/mps-youtube/album-1.png 68 | 69 | .. image:: http://mps-youtube.github.io/mps-youtube/album-2.png 70 | 71 | An album title can be specified and mps-youtube will attempt to find matches for each track of the album, based on title and duration. Type ``help search`` for more info. 72 | 73 | Customisation 74 | ~~~~~~~~~~~~~ 75 | 76 | .. image:: http://mps-youtube.github.io/mps-youtube/customisation2.png 77 | 78 | Search results can be customised to display additional fields and ordered by various criteria. 79 | 80 | This configuration was set up using the following commands:: 81 | 82 | set order views 83 | set columns user:14 date comments rating likes dislikes category:9 views 84 | 85 | Type ``help config`` for help on configuration options 86 | 87 | 88 | 89 | Installation 90 | ------------ 91 | Linux 92 | ~~~~~ 93 | 94 | **Note**: ``~/.local/bin`` should be in your ``PATH`` for ``--user`` installs. 95 | 96 | Using `pip `_:: 97 | 98 | $ pip3 install --user mps-youtube 99 | 100 | To install the experimental development version and try the latest features:: 101 | 102 | $ pip3 install --user -U git+https://github.com/mps-youtube/mps-youtube.git 103 | 104 | Installing youtube-dl is highly recommended:: 105 | 106 | $ pip3 install --user youtube-dl 107 | and to upgrade: 108 | $ pip3 install --user youtube-dl --upgrade 109 | 110 | (youtube-dl version dowloaded directly from youtybe-dl website can't be used by mps-youtube. While the version in the repositories is usually outdated) 111 | 112 | For mpris2 support, install the python bindings for dbus and gobject:: 113 | 114 | $ pip3 install --user dbus-python pygobject 115 | 116 | Ubuntu 117 | ~~~~~~ 118 | You can install mps-youtube directly from the official repositories:: 119 | 120 | [sudo] apt install mps-youtube 121 | 122 | Arch Linux 123 | ~~~~~~ 124 | You can install mps-youtube directly from the official repositories:: 125 | 126 | [sudo] pacman -S mps-youtube 127 | 128 | macOS X 129 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 130 | Install mpv (recommended player) with `Homebrew `_:: 131 | 132 | brew cask install mpv 133 | 134 | Alternately, you can install mplayer with `MacPorts `_:: 135 | 136 | sudo port install MPlayer 137 | 138 | Or with `Homebrew `_:: 139 | 140 | brew install mplayer 141 | 142 | Install mps-youtube using `Homebrew `_:: 143 | 144 | brew install mps-youtube 145 | 146 | 147 | Additional Windows installation notes 148 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 149 | 150 | As an alternative to installing with pip, there is a standalone binary available. Go to `Releases `_ and download mpsyt-VERSION.exe under downloads for the latest release. 151 | 152 | Install the python `colorama `_ module to get colors (optional):: 153 | 154 | pip3 install colorama 155 | 156 | Mpsyt requires a player to use as a backend, with either mpv or mplayer supported. Mpv is the recommended option. 157 | 158 | Mpv can be downloaded from https://mpv.srsfckn.biz/ 159 | 160 | Extract both ``mpv.exe`` and ``mpv.com`` to the same folder as ``mpsyt.exe`` or to a folder in the system path. 161 | 162 | Alternately, mplayer can be downloaded from http://oss.netfarm.it/mplayer 163 | 164 | Extract the ``mplayer.exe`` file, saving it to the folder that ``mpsyt.exe`` resides in (usually ``C:\PythonXX\Scripts\``) or to a folder in the system path. 165 | 166 | Run via Docker container 167 | ~~~~~~~~~~~~~~~~~~~~~~~~ 168 | 169 | Using `Docker `_, run with:: 170 | 171 | sudo docker run --device /dev/snd -it --rm --name mpsyt rothgar/mpsyt 172 | 173 | Additional Docker notes 174 | ~~~~~~~~~~~~~~~~~~~~~~~ 175 | 176 | If you would like to locally build the container you can run the following steps 177 | 178 | Check out this repo:: 179 | 180 | git clone https://github.com/mps-youtube/mps-youtube.git 181 | 182 | Enter the directory and run docker build:: 183 | 184 | cd mps-youtube 185 | sudo docker build -t mpsyt . 186 | 187 | Now run the container interactively with:: 188 | 189 | sudo docker run -v /dev/snd:/dev/snd -it --rm --privileged --name mpsyt mpsyt 190 | 191 | In order to have access to the local sound device (/dev/snd) the container needs to be privileged. 192 | 193 | Upgrading 194 | --------- 195 | 196 | Upgrade pip installation:: 197 | 198 | [sudo] pip3 install mps-youtube --upgrade 199 | 200 | Usage 201 | ----- 202 | 203 | mps-youtube is run on the command line using the command:: 204 | 205 | mpsyt 206 | 207 | Enter ``h`` from within the program for help. 208 | 209 | IRC 210 | --- 211 | 212 | An IRC channel `#mps-youtube` for the project is available on Freenode (chat.freenode.net:6697). You can join directly by clicking `this link `_. 213 | 214 | How to Contribute 215 | ----------------- 216 | Contributions are welcomed! However, please check out the `contributing page `_ before making a contribution. 217 | -------------------------------------------------------------------------------- /mps_youtube/g.py: -------------------------------------------------------------------------------- 1 | """ Module for holding globals that are needed throught mps-youtube. """ 2 | 3 | import os 4 | import sys 5 | import collections 6 | 7 | from . import c, paths 8 | from .playlist import Playlist 9 | 10 | 11 | volume = None 12 | transcoder_path = "auto" 13 | delete_orig = True 14 | encoders = [] 15 | muxapp = False 16 | meta = {} 17 | artist = "" # Mostly used for scrobbling purposes 18 | album = "" # Mostly used for scrobbling purposes 19 | scrobble = False 20 | scrobble_queue = [] 21 | lastfm_network = None 22 | detectable_size = True 23 | command_line = False 24 | debug_mode = False 25 | preload_disabled = False 26 | ytpls = [] 27 | mpv_version = 0, 0, 0 28 | mpv_options = None 29 | mpv_usesock = False 30 | mplayer_version = 0 31 | mprisctl = None 32 | browse_mode = "normal" 33 | preloading = [] 34 | # expiry = 5 * 60 * 60 # 5 hours 35 | no_clear_screen = False 36 | no_textart = False 37 | max_retries = 3 38 | max_cached_streams = 1500 39 | username_query_cache = collections.OrderedDict() 40 | model = Playlist(name="model") 41 | last_search_query = (None, None) 42 | current_page = 0 43 | result_count = 0 44 | rprompt = None 45 | active = Playlist(name="active") 46 | userpl = {} 47 | userhist = {} 48 | pafs = collections.OrderedDict() 49 | streams = collections.OrderedDict() 50 | pafy_pls = {} # 51 | last_opened = message = content = "" 52 | suffix = "3" # Python 3 53 | OLD_CFFILE = os.path.join(paths.get_config_dir(), "config") 54 | CFFILE = os.path.join(paths.get_config_dir(), "config.json") 55 | TCFILE = os.path.join(paths.get_config_dir(), "transcode") 56 | OLD_PLFILE = os.path.join(paths.get_config_dir(), "playlist" + suffix) 57 | PLFILE = os.path.join(paths.get_config_dir(), "playlist_v2") 58 | PLFOLDER = os.path.join(paths.get_config_dir(), "playlists") 59 | OLDHISTFILE = os.path.join(paths.get_config_dir(), "play_history") 60 | HISTFILE = os.path.join(paths.get_config_dir(), "play_history.m3u") 61 | CACHEFILE = os.path.join(paths.get_config_dir(), "cache_py_" + sys.version[0:5]) 62 | READLINE_FILE = None 63 | PLAYER_OBJ = None 64 | categories = { 65 | "film": 1, 66 | "autos": 2, 67 | "music": 10, 68 | "sports": 17, 69 | "travel": 19, 70 | "gaming": 20, 71 | "blogging": 21, 72 | "news": 25 73 | } 74 | playerargs_defaults = { 75 | "mpv": { 76 | "msglevel": {"<0.4": "--msglevel=all=no:statusline=status", 77 | ">=0.4": "--msg-level=all=no:statusline=status"}, 78 | "title": "--force-media-title", 79 | "fs": "--fs", 80 | "novid": "--no-video", 81 | "ignidx": "--demuxer-lavf-o=fflags=+ignidx", 82 | "geo": "--geometry"}, 83 | "mplayer": { 84 | "title": "-title", 85 | "fs": "-fs", 86 | "novid": "-novideo", 87 | # "ignidx": "-lavfdopts o=fflags=+ignidx".split() 88 | "ignidx": "", 89 | "geo": "-geometry"}, 90 | "vlc": { 91 | "title": "--meta-title"} 92 | } 93 | argument_commands = [] 94 | commands = [] 95 | 96 | text = { 97 | "exitmsg": ("*mps-youtube - *https://github.com/mps-youtube/mps-youtube*" 98 | "\nReleased under the GPLv3 license\n" 99 | "(c) 2014, 2015 np1 and contributors*\n"""), 100 | "exitmsg_": (c.r, c.b, c.r, c.w), 101 | 102 | # Error / Warning messages 103 | 104 | 'no playlists': "*No saved playlists found!*", 105 | 'no playlists_': (c.r, c.w), 106 | 'pl bad name': '*&&* is not valid a valid name. Ensure it starts with' 107 | ' a letter or _', 108 | 'pl bad name_': (c.r, c.w), 109 | 'pl not found': 'Playlist *&&* unknown. Saved playlists are shown ' 110 | 'above', 111 | 'pl not found_': (c.r, c.w), 112 | 'pl not found advise ls': 'Playlist "*&&*" not found. Use *ls* to ' 113 | 'list', 114 | 'pl not found advise ls_': (c.y, c.w, c.g, c.w), 115 | 'pl empty': 'Playlist is empty!', 116 | 'advise add': 'Use *add N* to add a track', 117 | 'advise add_': (c.g, c.w), 118 | 'advise search': 'Search for items and then use *add* to add them', 119 | 'advise search_': (c.g, c.w), 120 | 'no data': 'Error fetching data. Possible network issue.' 121 | '\n*&&*', 122 | 'no data_': (c.r, c.w), 123 | 'use dot': 'Start your query with a *.* to perform a search', 124 | 'use dot_': (c.g, c.w), 125 | 'cant get track': 'Problem playing last item: *&&*', 126 | 'cant get track_': (c.r, c.w), 127 | 'track unresolved': 'Sorry, this track is not available', 128 | 'no player': '*&&* was not found on this system', 129 | 'no player_': (c.y, c.w), 130 | 'no pl match for rename': '*Couldn\'t find matching playlist to ' 131 | 'rename*', 132 | 'no pl match for rename_': (c.r, c.w), 133 | 'invalid range': "*Invalid item / range entered!*", 134 | 'invalid range_': (c.r, c.w), 135 | '-audio': "*Warning* - the filetype you selected (&&) has no audio!", 136 | '-audio_': (c.y, c.w), 137 | 'no mix': 'No mix is available for the selected video', 138 | 'mix only videos': 'Mixes are only available for videos', 139 | 'invalid item': '*Invalid item entered!*', 140 | 'duplicate tracks': '*Warning* - duplicate track(s) && added to ' 141 | 'playlist!', 142 | 'duplicate tracks_': (c.y, c.w), 143 | 144 | # Info messages.. 145 | 146 | 'select mux': ("Select [*&&*] to mux audio or [*Enter*] to download " 147 | "without audio\nThis feature is experimental!"), 148 | 'select mux_': (c.y, c.w, c.y, c.w), 149 | 'pl renamed': 'Playlist *&&* renamed to *&&*', 150 | 'pl renamed_': (c.y, c.w, c.y, c.w), 151 | 'pl saved': 'Playlist saved as *&&*. Use *ls* to list playlists', 152 | 'pl saved_': (c.y, c.w, c.g, c.w), 153 | 'pl loaded': 'Loaded playlist *&&* as current playlist', 154 | 'pl loaded_': (c.y, c.w), 155 | 'pl viewed': 'Showing playlist *&&*', 156 | 'pl viewed_': (c.y, c.w), 157 | 'pl help': 'Enter *open * to load a playlist', 158 | 'pl help_': (c.g, c.w), 159 | 'added to pl': '*&&* tracks added (*&&* total [*&&*]). Use *vp* to ' 160 | 'view', 161 | 'added to pl_': (c.y, c.w, c.y, c.w, c.y, c.w, c.g, c.w), 162 | 'added to saved pl': '*&&* tracks added to *&&* (*&&* total [*&&*])', 163 | 'added to saved pl_': (c.y, c.w, c.y, c.w, c.y, c.w, c.y, c.w), 164 | 'song move': 'Moved *&&* to position *&&*', 165 | 'song move_': (c.y, c.w, c.y, c.w), 166 | 'song sw': ("Switched item *&&* with *&&*"), 167 | 'song sw_': (c.y, c.w, c.y, c.w), 168 | 'current pl': "This is the current playlist. Use *save * to save" 169 | " it", 170 | 'current pl_': (c.g, c.w), 171 | 'help topic': (" Enter *help * for specific help:"), 172 | 'help topic_': (c.y, c.w), 173 | 'songs rm': '*&&* tracks removed &&', 174 | 'songs rm_': (c.y, c.w), 175 | 'mkp empty': "*&&* is either empty or doesn't exist", 176 | 'mkp empty_': (c.b, c.r), 177 | 'mkp parsed': "*&&* entries found in *&&*", 178 | 'mkp parsed_': (c.g, c.w, c.b, c.w), 179 | 'mkp finding': "Finding the best match for *&&* ...", 180 | 'mkp finding_': (c.y, c.w), 181 | 'mkp desc unknown': "Unknown tabletype, *do a new search*", 182 | 'mkp desc unknown_': (c.y, c.w), 183 | 'mkp desc which data': "Which *tracks* to include?", 184 | 'mkp desc which data_': (c.y, c.w), 185 | 'mkp no valid': "*No valid tracks found in that description*", 186 | 'mkp no valid_': (c.y, c.w)} 187 | -------------------------------------------------------------------------------- /mps_youtube/commands/songlist.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | 4 | import pafy 5 | 6 | from .. import g, c, screen, streams, content, util 7 | from ..playlist import Video 8 | from . import command, PL 9 | 10 | 11 | def paginatesongs(func, page=0, splash=True, dumps=False, 12 | length=None, msg=None, failmsg=None, loadmsg=None): 13 | """ 14 | A utility function for handling lists of songs, so that 15 | the pagination and the dump command will work properly. 16 | 17 | :param func: Either a function taking a start and end index, 18 | or a slicable object. Either way, it should produce an iterable 19 | of :class:`mps_youtube.playlist.Video` objects. 20 | :param page: The page number to display 21 | :param splash: Whether or not to display a splash screen while 22 | loading. 23 | :param dumps: Used by :func:`dump` command to load all songs, instead 24 | of only those that fit on a page 25 | :param length: The total number of songs. It it is not provided, 26 | ``len(func)`` will be used instead. 27 | :param msg: Message to display after loading successfully 28 | :param failmsg: Message to display on failure (if no songs are 29 | returned by func 30 | :param loadmsg: Message to display while loading 31 | :type page: int 32 | :type splash: bool 33 | :type dumps: bool 34 | :type length: int 35 | :type msg: str 36 | :type failmsg: str 37 | :type loadmsg: str 38 | """ 39 | if splash: 40 | g.message = loadmsg or '' 41 | g.content = content.logo(col=c.b) 42 | screen.update() 43 | 44 | max_results = util.getxy().max_results 45 | 46 | if dumps: 47 | s = 0 48 | e = None 49 | else: 50 | s = page * max_results 51 | e = (page + 1) * max_results 52 | 53 | if callable(func): 54 | songs = func(s, e) 55 | else: 56 | songs = func[s:e] 57 | 58 | if length is None: 59 | length = len(func) 60 | 61 | args = {'func':func, 'length':length, 'msg':msg, 62 | 'failmsg':failmsg, 'loadmsg': loadmsg} 63 | g.last_search_query = (paginatesongs, args) 64 | g.browse_mode = "normal" 65 | g.current_page = page 66 | g.result_count = length 67 | g.model.songs = songs 68 | g.content = content.generate_songlist_display() 69 | g.last_opened = "" 70 | g.message = msg or '' 71 | if not songs: 72 | g.message = failmsg or g.message 73 | 74 | if songs: 75 | # preload first result url 76 | streams.preload(songs[0], delay=0) 77 | 78 | 79 | @command(r'pl\s+%s' % PL, 'pl') 80 | def plist(parturl): 81 | """ Retrieve YouTube playlist. """ 82 | 83 | if parturl in g.pafy_pls: 84 | ytpl, plitems = g.pafy_pls[parturl] 85 | else: 86 | util.dbg("%sFetching playlist using pafy%s", c.y, c.w) 87 | ytpl = pafy.get_playlist2(parturl) 88 | plitems = util.IterSlicer(ytpl) 89 | g.pafy_pls[parturl] = (ytpl, plitems) 90 | 91 | def pl_seg(s, e): 92 | return [Video(i.videoid, i.title, i.length) for i in plitems[s:e]] 93 | 94 | msg = "Showing YouTube playlist %s" % (c.y + ytpl.title + c.w) 95 | loadmsg = "Retrieving YouTube playlist" 96 | paginatesongs(pl_seg, length=len(ytpl), msg=msg, loadmsg=loadmsg) 97 | 98 | 99 | @command(r'(rm|add)\s*(-?\d[-,\d\s]{,250})', 'rm', 'add') 100 | def songlist_rm_add(action, songrange): 101 | """ Remove or add tracks. works directly on user input. """ 102 | selection = util.parse_multi(songrange) 103 | 104 | if action == "add": 105 | duplicate_songs = [] 106 | for songnum in selection: 107 | if g.model[songnum - 1] in g.active: 108 | duplicate_songs.append(str(songnum)) 109 | g.active.songs.append(g.model[songnum - 1]) 110 | 111 | d = g.active.duration 112 | g.message = util.F('added to pl') % (len(selection), len(g.active), d) 113 | if duplicate_songs: 114 | duplicate_songs = ', '.join(sorted(duplicate_songs)) 115 | g.message += '\n' 116 | g.message += util.F('duplicate tracks') % duplicate_songs 117 | 118 | elif action == "rm": 119 | selection = sorted(set(selection), reverse=True) 120 | removed = str(tuple(reversed(selection))).replace(",", "") 121 | 122 | for x in selection: 123 | g.model.songs.pop(x - 1) 124 | try: 125 | g.active.songs.pop(g.current_page * util.getxy().max_results + x - 1) 126 | except IndexError: 127 | pass 128 | 129 | g.message = util.F('songs rm') % (len(selection), removed) 130 | 131 | g.content = content.generate_songlist_display() 132 | 133 | 134 | @command(r'(mv|sw)\s*(\d{1,4})\s*[\s,]\s*(\d{1,4})', 'mv', 'sw') 135 | def songlist_mv_sw(action, a, b): 136 | """ Move a song or swap two songs. """ 137 | i, j = int(a) - 1, int(b) - 1 138 | 139 | if action == "mv": 140 | g.model.songs.insert(j, g.model.songs.pop(i)) 141 | g.message = util.F('song move') % (g.model[j].title, b) 142 | 143 | elif action == "sw": 144 | g.model[i], g.model[j] = g.model[j], g.model[i] 145 | g.message = util.F('song sw') % (min(a, b), max(a, b)) 146 | 147 | g.content = content.generate_songlist_display() 148 | 149 | 150 | @command(r'(n|p)\s*(\d{1,2})?') 151 | def nextprev(np, page=None): 152 | """ Get next / previous search results. """ 153 | if isinstance(g.content, content.PaginatedContent): 154 | page_count = g.content.numPages() 155 | function = g.content.getPage 156 | args = {} 157 | else: 158 | page_count = math.ceil(g.result_count/util.getxy().max_results) 159 | function, args = g.last_search_query 160 | 161 | good = False 162 | 163 | if function: 164 | if np == "n": 165 | if g.current_page + 1 < page_count: 166 | g.current_page += 1 167 | good = True 168 | 169 | elif np == "p": 170 | if page and int(page) in range(1,20): 171 | g.current_page = int(page)-1 172 | good = True 173 | 174 | elif g.current_page > 0: 175 | g.current_page -= 1 176 | good = True 177 | 178 | if good: 179 | function(page=g.current_page, **args) 180 | 181 | else: 182 | norp = "next" if np == "n" else "previous" 183 | g.message = "No %s items to display" % norp 184 | 185 | if not isinstance(g.content, content.PaginatedContent): 186 | g.content = content.generate_songlist_display() 187 | return good 188 | 189 | 190 | @command(r'(un)?dump', 'dump', 'undump') 191 | def dump(un): 192 | """ Show entire playlist. """ 193 | func, args = g.last_search_query 194 | 195 | if func is paginatesongs: 196 | paginatesongs(dumps=(not un), **args) 197 | 198 | else: 199 | un = "" if not un else un 200 | g.message = "%s%sdump%s may only be used on an open YouTube playlist" 201 | g.message = g.message % (c.y, un, c.w) 202 | g.content = content.generate_songlist_display() 203 | 204 | 205 | @command(r'shuffle', 'shuffle') 206 | def shuffle_fn(): 207 | """ Shuffle displayed items. """ 208 | random.shuffle(g.model.songs) 209 | g.message = c.y + "Items shuffled" + c.w 210 | g.content = content.generate_songlist_display() 211 | 212 | 213 | @command(r'reverse', 'reverse') 214 | def reverse_songs(): 215 | """ Reverse order of displayed items. """ 216 | g.model.songs = g.model.songs[::-1] 217 | g.message = c.y + "Reversed displayed songs" + c.w 218 | g.content = content.generate_songlist_display() 219 | 220 | 221 | @command(r'reverse\s*(\d{1,4})\s*-\s*(\d{1,4})\s*', 'reverse') 222 | def reverse_songs_range(lower, upper): 223 | """ Reverse the songs within a specified range. """ 224 | lower, upper = int(lower), int(upper) 225 | if lower > upper: lower, upper = upper, lower 226 | 227 | g.model.songs[lower-1:upper] = reversed(g.model.songs[lower-1:upper]) 228 | g.message = c.y + "Reversed range: " + str(lower) + "-" + str(upper) + c.w 229 | g.content = content.generate_songlist_display() 230 | 231 | 232 | @command(r'reverse all', 'reverse all') 233 | def reverse_playlist(): 234 | """ Reverse order of entire loaded playlist. """ 235 | # Prevent crash if no last query 236 | if g.last_search_query == (None, None) or \ 237 | 'func' not in g.last_search_query[1]: 238 | g.content = content.logo() 239 | g.message = "No playlist loaded" 240 | return 241 | 242 | songs_list_or_func = g.last_search_query[1]['func'] 243 | if callable(songs_list_or_func): 244 | songs = reversed(songs_list_or_func(0,None)) 245 | else: 246 | songs = reversed(songs_list_or_func) 247 | 248 | paginatesongs(list(songs)) 249 | g.message = c.y + "Reversed entire playlist" + c.w 250 | g.content = content.generate_songlist_display() 251 | -------------------------------------------------------------------------------- /mps_youtube/players/mplayer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import tempfile 4 | import subprocess 5 | import re 6 | 7 | from .. import g, screen, c, paths, config, util 8 | 9 | from ..player import CmdPlayer 10 | 11 | mswin = os.name == "nt" 12 | not_utf8_environment = mswin or "UTF-8" not in sys.stdout.encoding 13 | 14 | 15 | class mplayer(CmdPlayer): 16 | def __init__(self, player): 17 | self.player = player 18 | self.mplayer_version = _get_mplayer_version(player) 19 | 20 | def _generate_real_playerargs(self): 21 | """ Generate args for player command. 22 | 23 | Return args. 24 | 25 | """ 26 | 27 | if "uiressl=yes" in self.stream['url']: 28 | ver = self.mplayer_version 29 | # Mplayer too old to support https 30 | if not (ver > (1, 1) if isinstance(ver, tuple) else ver >= 37294): 31 | raise IOError("%s : Sorry mplayer doesn't support this stream. " 32 | "Use mpv or update mplayer to a newer version" % self.song.title) 33 | 34 | args = config.PLAYERARGS.get.strip().split() 35 | 36 | pd = g.playerargs_defaults['mplayer'] 37 | args.extend((pd["title"], '"{0}"'.format(self.song.title))) 38 | 39 | if pd['geo'] not in args: 40 | geometry = config.WINDOW_SIZE.get or "" 41 | 42 | if config.WINDOW_POS.get: 43 | wp = config.WINDOW_POS.get 44 | xx = "+1" if "left" in wp else "-1" 45 | yy = "+1" if "top" in wp else "-1" 46 | geometry += xx + yy 47 | 48 | if geometry: 49 | args.extend((pd['geo'], geometry)) 50 | 51 | # handle no audio stream available 52 | if self.override == "a-v": 53 | util.list_update(pd["novid"], args) 54 | 55 | elif ((config.FULLSCREEN.get and self.override != "window") 56 | or self.override == "fullscreen"): 57 | util.list_update(pd["fs"], args) 58 | 59 | # prevent ffmpeg issue (https://github.com/mpv-player/mpv/issues/579) 60 | if not self.video and self.stream['ext'] == "m4a": 61 | util.dbg("%susing ignidx flag%s") 62 | util.list_update(pd["ignidx"], args) 63 | 64 | if g.volume: 65 | util.list_update("-volume", args) 66 | util.list_update(str(g.volume), args) 67 | util.list_update("-really-quiet", args, remove=True) 68 | util.list_update("-noquiet", args) 69 | util.list_update("-prefer-ipv4", args) 70 | 71 | return [self.player] + args + [self.stream['url']] 72 | 73 | def clean_up(self): 74 | if self.fifopath: 75 | os.unlink(self.fifopath) 76 | 77 | def launch_player(self, cmd): 78 | self.input_file = _get_input_file() 79 | self.sockpath = None 80 | self.fifopath = None 81 | 82 | cmd.append('-input') 83 | 84 | if mswin: 85 | # Mplayer does not recognize path starting with drive letter, 86 | # or with backslashes as a delimiter. 87 | self.input_file = self.input_file[2:].replace('\\', '/') 88 | 89 | cmd.append('conf=' + self.input_file) 90 | 91 | if g.mprisctl: 92 | self.fifopath = tempfile.mktemp('.fifo', 'mpsyt-mplayer') 93 | os.mkfifo(self.fifopath) 94 | cmd.extend(['-input', 'file=' + self.fifopath]) 95 | g.mprisctl.send(('mplayer-fifo', self.fifopath)) 96 | 97 | self.p = subprocess.Popen(cmd, shell=False, stdout=subprocess.PIPE, 98 | stderr=subprocess.STDOUT, bufsize=1) 99 | self._player_status(self.songdata + "; ", self.song.length) 100 | returncode = self.p.wait() 101 | print(returncode) 102 | 103 | if returncode == 42: 104 | self.previous() 105 | 106 | elif returncode == 43: 107 | self.stop() 108 | 109 | else: 110 | self.next() 111 | 112 | def _player_status(self, prefix, songlength=0): 113 | """ Capture time progress from player output. Write status line. """ 114 | # pylint: disable=R0914, R0912 115 | re_player = re.compile(r"A:\s*(?P\d+)\.\d\s*") 116 | re_volume = re.compile(r"Volume:\s*(?P\d+)\s*%") 117 | last_displayed_line = None 118 | buff = '' 119 | volume_level = None 120 | last_pos = None 121 | 122 | elapsed_s = 0 123 | while self.p.poll() is None: 124 | stdstream = self.p.stdout 125 | char = stdstream.read(1).decode("utf-8", errors="ignore") 126 | 127 | if char in '\r\n': 128 | 129 | mv = re_volume.search(buff) 130 | 131 | if mv: 132 | volume_level = int(mv.group("volume")) 133 | 134 | match_object = re_player.match(buff) 135 | 136 | if match_object: 137 | 138 | try: 139 | h, m, s = map(int, match_object.groups()) 140 | elapsed_s = h * 3600 + m * 60 + s 141 | 142 | except ValueError: 143 | 144 | try: 145 | elapsed_s = int(match_object.group('elapsed_s') or 146 | '0') 147 | 148 | except ValueError: 149 | continue 150 | 151 | if volume_level and volume_level != g.volume: 152 | g.volume = volume_level 153 | self.make_status_line(elapsed_s, prefix, songlength, 154 | volume=volume_level) 155 | 156 | if buff.startswith('ANS_volume='): 157 | volume_level = round(float(buff.split('=')[1])) 158 | 159 | paused = ("PAUSE" in buff) or ("Paused" in buff) 160 | if (elapsed_s != last_pos or paused) and g.mprisctl: 161 | last_pos = elapsed_s 162 | g.mprisctl.send(('pause', paused)) 163 | g.mprisctl.send(('volume', volume_level)) 164 | g.mprisctl.send(('time-pos', elapsed_s)) 165 | 166 | buff = '' 167 | 168 | else: 169 | buff += char 170 | 171 | def _help(self, short=True): 172 | """ Mplayer help. """ 173 | 174 | volume = "[{0}9{1}] volume [{0}0{1}] [{0}CTRL-C{1}] return" 175 | seek = "[{0}\u2190{1}] seek [{0}\u2192{1}]" 176 | pause = "[{0}\u2193{1}] SEEK [{0}\u2191{1}] [{0}space{1}] pause" 177 | 178 | if not_utf8_environment: 179 | seek = "[{0}<-{1}] seek [{0}->{1}]" 180 | pause = "[{0}DN{1}] SEEK [{0}UP{1}] [{0}space{1}] pause" 181 | 182 | single = "[{0}q{1}] next" 183 | next_prev = "[{0}>{1}] next/prev [{0}<{1}]" 184 | # ret = "[{0}q{1}] %s" % ("return" if short else "next track") 185 | ret = single if short and config.AUTOPLAY.get else "" 186 | ret = next_prev if not short else ret 187 | fmt = " %-20s %-20s" 188 | lines = fmt % (seek, volume) + "\n" + fmt % (pause, ret) 189 | return lines.format(c.g, c.w) 190 | 191 | 192 | def _get_input_file(): 193 | """ Check for existence of custom input file. 194 | 195 | Return file name of temp input file with mpsyt mappings included 196 | """ 197 | confpath = conf = '' 198 | 199 | confpath = os.path.join(paths.get_config_dir(), "mplayer-input.conf") 200 | 201 | if os.path.isfile(confpath): 202 | util.dbg("using %s for input key file", confpath) 203 | 204 | with open(confpath) as conffile: 205 | conf = conffile.read() + '\n' 206 | 207 | conf = conf.replace("quit", "quit 43") 208 | conf = conf.replace("playlist_prev", "quit 42") 209 | conf = conf.replace("pt_step -1", "quit 42") 210 | conf = conf.replace("playlist_next", "quit") 211 | conf = conf.replace("pt_step 1", "quit") 212 | standard_cmds = ['q quit 43\n', '> quit\n', '< quit 42\n', 'NEXT quit\n', 213 | 'PREV quit 42\n', 'ENTER quit\n'] 214 | bound_keys = [i.split()[0] for i in conf.splitlines() if i.split()] 215 | 216 | for i in standard_cmds: 217 | key = i.split()[0] 218 | 219 | if key not in bound_keys: 220 | conf += i 221 | 222 | with tempfile.NamedTemporaryFile('w', prefix='mpsyt-input', 223 | delete=False) as tmpfile: 224 | tmpfile.write(conf) 225 | return tmpfile.name 226 | 227 | 228 | def _get_mplayer_version(exename): 229 | o = subprocess.check_output([exename]).decode() 230 | m = re.search('MPlayer SVN[\s-]r([0-9]+)', o, re.MULTILINE | re.IGNORECASE) 231 | 232 | ver = 0 233 | if m: 234 | ver = int(m.groups()[0]) 235 | else: 236 | m = re.search('MPlayer ([0-9])+.([0-9]+)', o, re.MULTILINE) 237 | if m: 238 | ver = tuple(int(i) for i in m.groups()) 239 | 240 | else: 241 | util.dbg("%sFailed to detect mplayer version%s", c.r, c.w) 242 | 243 | return ver 244 | -------------------------------------------------------------------------------- /mps_youtube/content.py: -------------------------------------------------------------------------------- 1 | import math 2 | import copy 3 | import random 4 | 5 | import pafy 6 | 7 | from . import g, c, config 8 | from .util import getxy, fmt_time, uea_pad, yt_datetime, F 9 | 10 | try: 11 | import qrcode 12 | import io 13 | HAS_QRCODE = True 14 | except ImportError: 15 | HAS_QRCODE = False 16 | 17 | 18 | # In the future, this could support more advanced features 19 | class Content: 20 | pass 21 | 22 | 23 | class PaginatedContent(Content): 24 | def getPage(self, page): 25 | raise NotImplementedError 26 | 27 | def numPages(self): 28 | raise NotImplementedError 29 | 30 | 31 | class LineContent(PaginatedContent): 32 | def getPage(self, page): 33 | max_results = getxy().max_results 34 | s = page * max_results 35 | e = (page + 1) * max_results 36 | return self.get_text(s, e) 37 | 38 | def numPages(self): 39 | return math.ceil(self.get_count()/getxy().max_results) 40 | 41 | def get_text(self, s, e): 42 | raise NotImplementedError 43 | 44 | def get_count(self): 45 | raise NotImplementedError 46 | 47 | 48 | class StringContent(LineContent): 49 | def __init__(self, string): 50 | self._lines = string.splitlines() 51 | 52 | def get_text(self, s, e): 53 | return '\n'.join(self._lines[s:e]) 54 | 55 | def get_count(self): 56 | width = getxy().width 57 | count = sum(len(i) // width + 1 for i in self._lines) 58 | return count 59 | 60 | 61 | def page_msg(page=0): 62 | """ Format information about currently displayed page to a string. """ 63 | if isinstance(g.content, PaginatedContent): 64 | page_count = g.content.numPages() 65 | else: 66 | page_count = math.ceil(g.result_count/getxy().max_results) 67 | 68 | if page_count > 1: 69 | pagemsg = "{}{}/{}{}" 70 | #start_index = max_results * g.current_page 71 | return pagemsg.format('<' if page > 0 else '[', 72 | "%s%s%s" % (c.y, page+1, c.w), 73 | page_count, 74 | '>' if page + 1 < page_count else ']') 75 | return None 76 | 77 | 78 | def generate_songlist_display(song=False, zeromsg=None): 79 | """ Generate list of choices from a song list.""" 80 | # pylint: disable=R0914 81 | if g.browse_mode == "ytpl": 82 | return generate_playlist_display() 83 | 84 | max_results = getxy().max_results 85 | 86 | if not g.model: 87 | g.message = zeromsg or "Enter /search-term to search or [h]elp" 88 | return logo(c.g) + "\n\n" 89 | g.rprompt = page_msg(g.current_page) 90 | 91 | have_meta = all(x.ytid in g.meta for x in g.model) 92 | 93 | user_columns = _get_user_columns() if have_meta else [] 94 | maxlength = max(x.length for x in g.model) 95 | lengthsize = 8 if maxlength > 35999 else 7 96 | lengthsize = 6 if maxlength < 6000 else lengthsize 97 | reserved = 9 + lengthsize + len(user_columns) 98 | cw = getxy().width 99 | cw -= 1 100 | title_size = cw - sum(1 + x['size'] for x in user_columns) - reserved 101 | before = [{"name": "idx", "size": 3, "heading": "Num"}, 102 | {"name": "title", "size": title_size, "heading": "Title"}] 103 | after = [{"name": "length", "size": lengthsize, "heading": "Length"}] 104 | columns = before + user_columns + after 105 | 106 | for n, column in enumerate(columns): 107 | column['idx'] = n 108 | column['sign'] = "-" if not column['name'] == "length" else "" 109 | 110 | fmt = ["%{}{}s ".format(x['sign'], x['size']) for x in columns] 111 | fmtrow = fmt[0:1] + ["%s "] + fmt[2:] 112 | fmt, fmtrow = "".join(fmt).strip(), "".join(fmtrow).strip() 113 | titles = tuple([x['heading'][:x['size']] for x in columns]) 114 | hrow = c.ul + fmt % titles + c.w 115 | out = "\n" + hrow + "\n" 116 | 117 | for n, x in enumerate(g.model[:max_results]): 118 | col = (c.r if n % 2 == 0 else c.p) if not song else c.b 119 | details = {'title': x.title, "length": fmt_time(x.length)} 120 | details = copy.copy(g.meta[x.ytid]) if have_meta else details 121 | otitle = details['title'] 122 | details['idx'] = "%2d" % (n + 1) 123 | details['title'] = uea_pad(columns[1]['size'], otitle) 124 | cat = details.get('category') or '-' 125 | details['category'] = pafy.get_categoryname(cat) 126 | details['ytid'] = x.ytid 127 | line = '' 128 | 129 | for z in columns: 130 | fieldsize, field, direction = z['size'], z['name'], "<" if z['sign'] == "-" else ">" 131 | line += uea_pad(fieldsize, details[field], direction) 132 | if not columns[-1] == z: 133 | line += " " 134 | 135 | col = col if not song or song != g.model[n] else c.p 136 | line = col + line + c.w 137 | out += line + "\n" 138 | 139 | return out + "\n" * (5 - len(g.model)) if not song else out 140 | 141 | 142 | def generate_playlist_display(): 143 | """ Generate list of playlists. """ 144 | 145 | if not g.ytpls: 146 | g.message = c.r + "No playlists found!" 147 | return logo(c.g) + "\n\n" 148 | g.rprompt = page_msg(g.current_page) 149 | 150 | cw = getxy().width 151 | fmtrow = "%s%-5s %s %-12s %-8s %-2s%s\n" 152 | fmthd = "%s%-5s %-{}s %-12s %-9s %-5s%s\n".format(cw - 36) 153 | head = (c.ul, "Item", "Playlist", "Author", "Updated", "Count", c.w) 154 | out = "\n" + fmthd % head 155 | 156 | for n, x in enumerate(g.ytpls): 157 | col = (c.g if n % 2 == 0 else c.w) 158 | length = x.get('size') or "?" 159 | length = "%4s" % length 160 | title = x.get('title') or "unknown" 161 | author = x.get('author') or "unknown" 162 | updated = yt_datetime(x.get('updated'))[1] 163 | title = uea_pad(cw - 36, title) 164 | out += (fmtrow % (col, str(n + 1), title, author[:12], updated, str(length), c.w)) 165 | 166 | return out + "\n" * (5 - len(g.ytpls)) 167 | 168 | 169 | def _get_user_columns(): 170 | """ Get columns from user config, return dict. """ 171 | total_size = 0 172 | user_columns = config.COLUMNS.get 173 | user_columns = user_columns.replace(",", " ").split() 174 | 175 | defaults = {"views": dict(name="viewCount", size=4, heading="View"), 176 | "rating": dict(name="rating", size=4, heading="Rtng"), 177 | "comments": dict(name="commentCount", size=4, heading="Comm"), 178 | "date": dict(name="uploaded", size=8, heading="Date"), 179 | "time": dict(name="uploadedTime", size=11, heading="Time"), 180 | "user": dict(name="uploaderName", size=10, heading="User"), 181 | "likes": dict(name="likes", size=4, heading="Like"), 182 | "dislikes": dict(name="dislikes", size=4, heading="Dslk"), 183 | "category": dict(name="category", size=8, heading="Category"), 184 | "ytid": dict(name="ytid", size=12, heading="Video ID")} 185 | 186 | ret = [] 187 | for column in user_columns: 188 | namesize = column.split(":") 189 | name = namesize[0] 190 | 191 | if name in defaults: 192 | z = defaults[name] 193 | nm, sz, hd = z['name'], z['size'], z['heading'] 194 | 195 | if len(namesize) == 2 and namesize[1].isdigit(): 196 | sz = int(namesize[1]) 197 | 198 | total_size += sz 199 | cw = getxy().width 200 | if total_size < cw - 18: 201 | ret.append(dict(name=nm, size=sz, heading=hd)) 202 | 203 | return ret 204 | 205 | 206 | def logo(col=None, version=""): 207 | """ Return text logo. """ 208 | col = col if col else random.choice((c.g, c.r, c.y, c.b, c.p, c.w)) 209 | logo_txt = r""" _ _ 210 | _ __ ___ _ __ ___ _ _ ___ _ _| |_ _ _| |__ ___ 211 | | '_ ` _ \| '_ \/ __|_____| | | |/ _ \| | | | __| | | | '_ \ / _ \ 212 | | | | | | | |_) \__ \_____| |_| | (_) | |_| | |_| |_| | |_) | __/ 213 | |_| |_| |_| .__/|___/ \__, |\___/ \__,_|\__|\__,_|_.__/ \___| 214 | |_| |___/""" 215 | version = " v" + version if version else "" 216 | logo_txt = col + logo_txt + c.w + version 217 | lines = logo_txt.split("\n") 218 | length = max(len(x) for x in lines) 219 | x, y, _ = getxy() 220 | indent = (x - length - 1) // 2 221 | newlines = (y - 12) // 2 222 | indent, newlines = (0 if x < 0 else x for x in (indent, newlines)) 223 | lines = [" " * indent + l for l in lines] 224 | logo_txt = "\n".join(lines) + "\n" * newlines 225 | return "" if g.debug_mode or g.no_textart else logo_txt 226 | 227 | 228 | def playlists_display(): 229 | """ Produce a list of all playlists. """ 230 | if not g.userpl: 231 | g.message = F("no playlists") 232 | return generate_songlist_display() if g.model else (logo(c.y) + "\n\n") 233 | 234 | maxname = max(len(a) for a in g.userpl) 235 | out = " {0}Local Playlists{1}\n".format(c.ul, c.w) 236 | start = " " 237 | fmt = "%s%s%-3s %-" + str(maxname + 3) + "s%s %s%-7s%s %-5s%s" 238 | head = (start, c.b, "ID", "Name", c.b, c.b, "Count", c.b, "Duration", c.w) 239 | out += "\n" + fmt % head + "\n\n" 240 | 241 | for v, z in enumerate(sorted(g.userpl)): 242 | n, p = z, g.userpl[z] 243 | l = fmt % (start, c.g, v + 1, n, c.w, c.y, str(len(p)), c.y, 244 | p.duration, c.w) + "\n" 245 | out += l 246 | 247 | return out 248 | 249 | 250 | def qrcode_display(url): 251 | if not HAS_QRCODE: 252 | return "(Install 'qrcode' to generate them)" 253 | qr = qrcode.QRCode() 254 | buf = io.StringIO() 255 | buf.isatty = lambda: True 256 | qr.add_data(url) 257 | qr.print_ascii(out=buf) 258 | return buf.getvalue() 259 | -------------------------------------------------------------------------------- /mps_youtube/init.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import logging 5 | import tempfile 6 | import argparse 7 | import platform 8 | import multiprocessing 9 | 10 | import pafy 11 | 12 | try: 13 | # pylint: disable=F0401 14 | import colorama 15 | has_colorama = True 16 | 17 | except ImportError: 18 | has_colorama = False 19 | 20 | try: 21 | import readline 22 | readline.set_history_length(2000) 23 | has_readline = True 24 | 25 | except ImportError: 26 | has_readline = False 27 | 28 | from . import cache, g, __version__, __notes__, screen, c, paths, config 29 | from .util import has_exefile, dbg, xprint, load_player_info, assign_player 30 | from .helptext import helptext 31 | 32 | mswin = os.name == "nt" 33 | 34 | 35 | def init(): 36 | """ Initial setup. """ 37 | 38 | _process_cl_args() 39 | 40 | # set player to mpv or mplayer if found, otherwise unset 41 | suffix = ".exe" if mswin else "" 42 | mplayer, mpv = "mplayer" + suffix, "mpv" + suffix 43 | 44 | # check for old pickled binary config and convert to json if so 45 | config.convert_old_cf_to_json() 46 | 47 | if not os.path.exists(g.CFFILE): 48 | 49 | if has_exefile(mpv): 50 | config.PLAYER.set(mpv) 51 | 52 | elif has_exefile(mplayer): 53 | config.PLAYER.set(mplayer) 54 | 55 | config.save() 56 | 57 | else: 58 | config.load() 59 | assign_player(config.PLAYER.get) # Player is not assigned when config is loaded 60 | 61 | _init_readline() 62 | cache.load() 63 | _init_transcode() 64 | 65 | # ensure encoder is not set beyond range of available presets 66 | if config.ENCODER.get >= len(g.encoders): 67 | config.ENCODER.set("0") 68 | 69 | # check mpv/mplayer version 70 | if has_exefile(config.PLAYER.get): 71 | load_player_info(config.PLAYER.get) 72 | 73 | # setup colorama 74 | if has_colorama and mswin: 75 | # Colorama converts ansi escape codes to Windows system calls 76 | colorama.init() 77 | 78 | # find muxer app 79 | if mswin: 80 | g.muxapp = has_exefile("ffmpeg.exe") or has_exefile("avconv.exe") 81 | 82 | else: 83 | g.muxapp = has_exefile("ffmpeg") or has_exefile("avconv") 84 | 85 | # initialize MPRIS2 interface 86 | if config.MPRIS.get: 87 | try: 88 | from . import mpris 89 | conn1, conn2 = multiprocessing.Pipe() 90 | g.mprisctl = mpris.MprisConnection(conn1) 91 | t = multiprocessing.Process(target=mpris.main, args=(conn2,)) 92 | t.daemon = True 93 | t.start() 94 | except ImportError: 95 | print("could not load MPRIS interface. missing libraries.") 96 | 97 | # Make pafy use the same api key 98 | pafy.set_api_key(config.API_KEY.get) 99 | 100 | 101 | def _init_transcode(): 102 | """ Create transcoding presets if not present. 103 | 104 | Read transcoding presets. 105 | """ 106 | if not os.path.exists(g.TCFILE): 107 | config_file_contents = """\ 108 | # transcoding presets for mps-youtube 109 | # VERSION 0 110 | 111 | # change ENCODER_PATH to the path of ffmpeg / avconv or leave it as auto 112 | # to let mps-youtube attempt to find ffmpeg or avconv 113 | ENCODER_PATH: auto 114 | 115 | # Delete original file after encoding it 116 | # Set to False to keep the original downloaded file 117 | DELETE_ORIGINAL: True 118 | 119 | # ENCODING PRESETS 120 | 121 | # Encode ogg or m4a to mp3 256k 122 | name: MP3 256k 123 | extension: mp3 124 | valid for: ogg,m4a 125 | command: ENCODER_PATH -i IN -codec:a libmp3lame -b:a 256k OUT.EXT 126 | 127 | # Encode ogg or m4a to mp3 192k 128 | name: MP3 192k 129 | extension: mp3 130 | valid for: ogg,m4a 131 | command: ENCODER_PATH -i IN -codec:a libmp3lame -b:a 192k OUT.EXT 132 | 133 | # Encode ogg or m4a to mp3 highest quality vbr 134 | name: MP3 VBR best 135 | extension: mp3 136 | valid for: ogg,m4a 137 | command: ENCODER_PATH -i IN -codec:a libmp3lame -q:a 0 OUT.EXT 138 | 139 | # Encode ogg or m4a to mp3 high quality vbr 140 | name: MP3 VBR good 141 | extension: mp3 142 | valid for: ogg,m4a 143 | command: ENCODER_PATH -i IN -codec:a libmp3lame -q:a 2 OUT.EXT 144 | 145 | # Encode m4a to ogg 146 | name: OGG 256k 147 | extension: ogg 148 | valid for: m4a 149 | command: ENCODER_PATH -i IN -codec:a libvorbis -b:a 256k OUT.EXT 150 | 151 | # Encode ogg to m4a 152 | name: M4A 256k 153 | extension: m4a 154 | valid for: ogg 155 | command: ENCODER_PATH -i IN -strict experimental -codec:a aac -b:a 256k OUT.EXT 156 | 157 | # Encode ogg or m4a to wma v2 158 | name: Windows Media Audio v2 159 | extension: wma 160 | valid for: ogg,m4a 161 | command: ENCODER_PATH -i IN -codec:a wmav2 -q:a 0 OUT.EXT""" 162 | 163 | with open(g.TCFILE, "w") as tcf: 164 | tcf.write(config_file_contents) 165 | dbg("generated transcoding config file") 166 | 167 | else: 168 | dbg("transcoding config file exists") 169 | 170 | with open(g.TCFILE, "r") as tcf: 171 | g.encoders = [dict(name="None", ext="COPY", valid="*")] 172 | e = {} 173 | 174 | for line in tcf.readlines(): 175 | 176 | if line.startswith("TRANSCODER_PATH:"): 177 | m = re.match("TRANSCODER_PATH:(.*)", line).group(1) 178 | g.transcoder_path = m.strip() 179 | 180 | elif line.startswith("DELETE_ORIGINAL:"): 181 | m = re.match("DELETE_ORIGINAL:(.*)", line).group(1) 182 | do = m.strip().lower() in ("true", "yes", "enabled", "on") 183 | g.delete_orig = do 184 | 185 | elif line.startswith("name:"): 186 | e['name'] = re.match("name:(.*)", line).group(1).strip() 187 | 188 | elif line.startswith("extension:"): 189 | e['ext'] = re.match("extension:(.*)", line).group(1).strip() 190 | 191 | elif line.startswith("valid for:"): 192 | e['valid'] = re.match("valid for:(.*)", line).group(1).strip() 193 | 194 | elif line.startswith("command:"): 195 | e['command'] = re.match("command:(.*)", line).group(1).strip() 196 | 197 | if "name" in e and "ext" in e and "valid" in e: 198 | g.encoders.append(e) 199 | e = {} 200 | 201 | 202 | def _init_readline(): 203 | """ Enable readline for input history. """ 204 | if g.command_line: 205 | return 206 | 207 | if has_readline: 208 | g.READLINE_FILE = os.path.join(paths.get_config_dir(), "input_history") 209 | 210 | if os.path.exists(g.READLINE_FILE): 211 | readline.read_history_file(g.READLINE_FILE) 212 | dbg(c.g + "Read history file" + c.w) 213 | 214 | 215 | def _process_cl_args(): 216 | """ Process command line arguments. """ 217 | 218 | parser = argparse.ArgumentParser(add_help=False) 219 | parser.add_argument('commands', nargs='*') 220 | parser.add_argument('--help', '-h', action='store_true') 221 | parser.add_argument('--version', '-v', action='store_true') 222 | parser.add_argument('--debug', '-d', action='store_true') 223 | parser.add_argument('--logging', '-l', action='store_true') 224 | parser.add_argument('--no-autosize', action='store_true') 225 | parser.add_argument('--no-preload', action='store_true') 226 | parser.add_argument('--no-textart', action='store_true') 227 | args = parser.parse_args() 228 | 229 | if args.version: 230 | screen.msgexit(_get_version_info()) 231 | 232 | elif args.help: 233 | screen.msgexit('\n'.join(i[2] for i in helptext())) 234 | 235 | if args.debug or os.environ.get("mpsytdebug") == "1": 236 | xprint(_get_version_info()) 237 | g.debug_mode = True 238 | g.no_clear_screen = True 239 | 240 | if args.logging or os.environ.get("mpsytlog") == "1" or g.debug_mode: 241 | logfile = os.path.join(tempfile.gettempdir(), "mpsyt.log") 242 | logging.basicConfig(level=logging.DEBUG, filename=logfile) 243 | logging.getLogger("pafy").setLevel(logging.DEBUG) 244 | 245 | if args.no_autosize: 246 | g.detectable_size = False 247 | 248 | g.command_line = "playurl" in args.commands or "dlurl" in args.commands 249 | if g.command_line: 250 | g.no_clear_screen = True 251 | 252 | if args.no_preload: 253 | g.preload_disabled = True 254 | 255 | if args.no_textart: 256 | g.no_textart = True 257 | 258 | g.argument_commands = args.commands 259 | 260 | 261 | def _get_version_info(): 262 | """ Return version and platform info. """ 263 | pafy_version = pafy.__version__ 264 | youtube_dl_version = None 265 | if tuple(map(int, pafy_version.split('.'))) >= (0, 5, 0): 266 | pafy_version += " (" + pafy.backend + " backend)" 267 | if pafy.backend == "youtube-dl": 268 | import youtube_dl 269 | youtube_dl_version = youtube_dl.version.__version__ 270 | 271 | out = "mpsyt version : " + __version__ 272 | out += "\n notes : " + __notes__ 273 | out += "\npafy version : " + pafy_version 274 | if youtube_dl_version: 275 | out += "\nyoutube-dl version : " + youtube_dl_version 276 | out += "\nPython version : " + sys.version 277 | out += "\nProcessor : " + platform.processor() 278 | out += "\nMachine type : " + platform.machine() 279 | out += "\nArchitecture : %s, %s" % platform.architecture() 280 | out += "\nPlatform : " + platform.platform() 281 | out += "\nsys.stdout.enc : " + sys.stdout.encoding 282 | out += "\ndefault enc : " + sys.getdefaultencoding() 283 | out += "\nConfig dir : " + paths.get_config_dir() 284 | 285 | for env in "TERM SHELL LANG LANGUAGE".split(): 286 | value = os.environ.get(env) 287 | out += "\nenv:%-15s: %s" % (env, value) if value else "" 288 | 289 | return out 290 | -------------------------------------------------------------------------------- /mps_youtube/commands/spotify_playlist.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import difflib 4 | 5 | try: 6 | # pylint: disable=F0401 7 | import spotipy 8 | import spotipy.oauth2 as oauth2 9 | has_spotipy = True 10 | 11 | except ImportError: 12 | has_spotipy = False 13 | 14 | import pafy 15 | 16 | from .. import c, g, screen, __version__, __url__, content, config, util 17 | from . import command 18 | from .songlist import paginatesongs 19 | from .search import generate_search_qs, get_tracks_from_json 20 | 21 | 22 | def generate_credentials(): 23 | """Generate the token. Please respect these credentials :)""" 24 | credentials = oauth2.SpotifyClientCredentials( 25 | client_id='6451e12933bb49ed8543d41e3296a88d', 26 | client_secret='40ef54678fe441bd9acd66f5d5c34e69') 27 | return credentials 28 | 29 | 30 | def grab_playlist(spotify, playlist): 31 | if '/' in playlist: 32 | if playlist.endswith('/'): 33 | playlist = playlist[:-1] 34 | splits = playlist.split('/') 35 | else: 36 | splits = playlist.split(':') 37 | 38 | username = splits[-3] 39 | playlist_id = splits[-1] 40 | results = spotify.user_playlist(username, playlist_id, 41 | fields='tracks,next,name,owner') 42 | 43 | all_tracks = [] 44 | tracks = results['tracks'] 45 | while True: 46 | for item in tracks['items']: 47 | track = item['track'] 48 | try: 49 | all_tracks.append(track) 50 | except KeyError: 51 | pass 52 | # 1 page = 50 results 53 | # check if there are more pages 54 | if tracks['next']: 55 | tracks = spotify.next(tracks) 56 | else: 57 | break 58 | 59 | return (results, all_tracks) 60 | 61 | 62 | def show_message(message, col=c.r, update=False): 63 | """ Show message using col, update screen if required. """ 64 | g.content = content.generate_songlist_display() 65 | g.message = col + message + c.w 66 | 67 | if update: 68 | screen.update() 69 | 70 | 71 | def _best_song_match(songs, title, duration, titleweight, durationweight): 72 | """ Select best matching song based on title, length. 73 | 74 | Score from 0 to 1 where 1 is best. titleweight and durationweight 75 | parameters added to enable function usage when duration can't be guessed 76 | 77 | """ 78 | # pylint: disable=R0914 79 | seqmatch = difflib.SequenceMatcher 80 | 81 | def variance(a, b): 82 | """ Return difference ratio. """ 83 | return float(abs(a - b)) / max(a, b) 84 | 85 | candidates = [] 86 | 87 | ignore = "music video lyrics new lyrics video audio".split() 88 | extra = "official original vevo".split() 89 | 90 | for song in songs: 91 | dur, tit = int(song.length), song.title 92 | util.dbg("Title: %s, Duration: %s", tit, dur) 93 | 94 | for word in extra: 95 | if word in tit.lower() and word not in title.lower(): 96 | pattern = re.compile(word, re.I) 97 | tit = pattern.sub("", tit) 98 | 99 | for word in ignore: 100 | if word in tit.lower() and word not in title.lower(): 101 | pattern = re.compile(word, re.I) 102 | tit = pattern.sub("", tit) 103 | 104 | replacechars = re.compile(r"[\]\[\)\(\-]") 105 | tit = replacechars.sub(" ", tit) 106 | multiple_spaces = re.compile(r"(\s)(\s*)") 107 | tit = multiple_spaces.sub(r"\1", tit) 108 | 109 | title_score = seqmatch(None, title.lower(), tit.lower()).ratio() 110 | duration_score = 1 - variance(duration, dur) 111 | util.dbg("Title score: %s, Duration score: %s", title_score, 112 | duration_score) 113 | 114 | # apply weightings 115 | score = duration_score * durationweight + title_score * titleweight 116 | candidates.append((score, song)) 117 | 118 | best_score, best_song = max(candidates, key=lambda x: x[0]) 119 | percent_score = int(100 * best_score) 120 | return best_song, percent_score 121 | 122 | 123 | def _match_tracks(tracks): 124 | """ Match list of tracks by performing multiple searches. """ 125 | # pylint: disable=R0914 126 | 127 | def dtime(x): 128 | """ Format time to M:S. """ 129 | return time.strftime('%M:%S', time.gmtime(int(x))) 130 | 131 | # do matching 132 | for track in tracks: 133 | ttitle = track['name'] 134 | artist = track['artists'][0]['name'] 135 | length = track['duration_ms']/1000 136 | util.xprint("Search : %s%s - %s%s - %s" % (c.y, artist, ttitle, c.w, 137 | dtime(length))) 138 | q = "%s %s" % (artist, ttitle) 139 | w = q = ttitle if artist == "Various Artists" else q 140 | query = generate_search_qs(w, 0) 141 | util.dbg(query) 142 | 143 | # perform fetch 144 | wdata = pafy.call_gdata('search', query) 145 | results = get_tracks_from_json(wdata) 146 | 147 | if not results: 148 | util.xprint(c.r + "Nothing matched :(\n" + c.w) 149 | continue 150 | 151 | s, score = _best_song_match( 152 | results, artist + " " + ttitle, length, .5, .5) 153 | cc = c.g if score > 85 else c.y 154 | cc = c.r if score < 75 else cc 155 | util.xprint("Matched: %s%s%s - %s \n[%sMatch confidence: " 156 | "%s%s]\n" % (c.y, s.title, c.w, util.fmt_time(s.length), 157 | cc, score, c.w)) 158 | yield s 159 | 160 | 161 | @command(r'suser\s*(.*[-_a-zA-Z0-9].*)?', 'suser') 162 | def search_user(term): 163 | """Search for Spotify user playlists. """ 164 | # pylint: disable=R0914,R0912 165 | if has_spotipy: 166 | 167 | if not term: 168 | show_message("Enter username:", c.g, update=True) 169 | term = input("> ") 170 | 171 | if not term or len(term) < 2: 172 | g.message = c.r + "Not enough input!" + c.w 173 | g.content = content.generate_songlist_display() 174 | return 175 | 176 | credentials = generate_credentials() 177 | token = credentials.get_access_token() 178 | spotify = spotipy.Spotify(auth=token) 179 | 180 | playlists = spotify.user_playlists(term) 181 | links = [] 182 | check = 1 183 | 184 | g.content = "Playlists:\n" 185 | 186 | while True: 187 | for playlist in playlists['items']: 188 | if playlist['name'] is not None: 189 | g.content += (u'{0:>2}. {1:<30} ({2} tracks)'.format( 190 | check, playlist['name'], 191 | playlist['tracks']['total'])) 192 | g.content += "\n" 193 | links.append(playlist) 194 | check += 1 195 | if playlists['next']: 196 | playlists = spotify.next(playlists) 197 | else: 198 | break 199 | 200 | g.message = c.g + "Choose your playlist:" + c.w 201 | screen.update() 202 | 203 | choice = int(input("> ")) 204 | playlist = links[choice-1] 205 | 206 | search_playlist(playlist['external_urls']['spotify'], spotify=spotify) 207 | 208 | else: 209 | g.message = "spotipy module must be installed for Spotify support\n" 210 | g.message += "see https://pypi.python.org/pypi/spotipy/" 211 | 212 | 213 | 214 | @command(r'splaylist\s*(.*[-_a-zA-Z0-9].*)?', 'splaylist') 215 | def search_playlist(term, spotify=None): 216 | """Search for Spotify playlist. """ 217 | # pylint: disable=R0914,R0912 218 | if has_spotipy: 219 | 220 | if not term: 221 | show_message("Enter playlist url:", c.g, update=True) 222 | term = input("> ") 223 | 224 | if not term or len(term) < 2: 225 | g.message = c.r + "Not enough input!" + c.w 226 | g.content = content.generate_songlist_display() 227 | return 228 | 229 | if not spotify: 230 | credentials = generate_credentials() 231 | token = credentials.get_access_token() 232 | spotify = spotipy.Spotify(auth=token) 233 | 234 | try: 235 | playlist, tracks = grab_playlist(spotify, term) 236 | except TypeError: 237 | tracks = None 238 | 239 | if not tracks: 240 | show_message("Playlist '%s' not found!" % term) 241 | return 242 | 243 | if not playlist['tracks']['total']: 244 | show_message("Playlist '%s' by '%s' has 0 tracks!" % (playlist['name'], playlist['owner']['id'])) 245 | return 246 | 247 | msg = "%s%s%s by %s%s%s\n\n" % (c.g, playlist['name'], c.w, c.g, playlist['owner']['id'], c.w) 248 | msg += "Enter to begin matching or [q] to abort" 249 | g.message = msg 250 | g.content = "Tracks:\n" 251 | for n, track in enumerate(tracks, 1): 252 | trackname = '{0:<20} - {1}'.format(track['artists'][0]['name'], track['name']) 253 | g.content += "%03s %s" % (n, trackname) 254 | g.content += "\n" 255 | 256 | screen.update() 257 | entry = input("Continue? [Enter] > ") 258 | 259 | if entry == "": 260 | pass 261 | 262 | else: 263 | show_message("Playlist search abandoned!") 264 | return 265 | 266 | songs = [] 267 | screen.clear() 268 | itt = _match_tracks(tracks) 269 | 270 | stash = config.SEARCH_MUSIC.get, config.ORDER.get 271 | config.SEARCH_MUSIC.value = True 272 | config.ORDER.value = "relevance" 273 | 274 | try: 275 | songs.extend(itt) 276 | 277 | except KeyboardInterrupt: 278 | util.xprint("%sHalted!%s" % (c.r, c.w)) 279 | 280 | finally: 281 | config.SEARCH_MUSIC.value, config.ORDER.value = stash 282 | 283 | if songs: 284 | util.xprint("\n%s / %s songs matched" % (len(songs), len(tracks))) 285 | input("Press Enter to continue") 286 | 287 | msg = "Contents of playlist %s%s - %s%s %s(%d/%d)%s:" % ( 288 | c.y, playlist['owner']['id'], playlist['name'], c.w, c.b, len(songs), len(tracks), c.w) 289 | failmsg = "Found no playlist tracks for %s%s%s" % (c.y, playlist['name'], c.w) 290 | 291 | paginatesongs(songs, msg=msg, failmsg=failmsg) 292 | 293 | else: 294 | g.message = "spotipy module must be installed for Spotify support\n" 295 | g.message += "see https://pypi.python.org/pypi/spotipy/" 296 | -------------------------------------------------------------------------------- /mps_youtube/commands/album_search.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import difflib 4 | from urllib.request import build_opener 5 | from urllib.error import HTTPError, URLError 6 | from urllib.parse import urlencode 7 | from xml.etree import ElementTree as ET 8 | 9 | import pafy 10 | 11 | from .. import c, g, screen, __version__, __url__, content, config, util 12 | from . import command 13 | from .songlist import paginatesongs 14 | from .search import generate_search_qs, get_tracks_from_json 15 | 16 | 17 | def show_message(message, col=c.r, update=False): 18 | """ Show message using col, update screen if required. """ 19 | g.content = content.generate_songlist_display() 20 | g.message = col + message + c.w 21 | 22 | if update: 23 | screen.update() 24 | 25 | 26 | def _do_query(url, query, err='query failed', report=False): 27 | """ Perform http request using mpsyt user agent header. 28 | 29 | if report is True, return whether response is from memo 30 | 31 | """ 32 | # create url opener 33 | ua = "mps-youtube/%s ( %s )" % (__version__, __url__) 34 | mpsyt_opener = build_opener() 35 | mpsyt_opener.addheaders = [('User-agent', ua)] 36 | 37 | # convert query to sorted list of tuples (needed for consistent url_memo) 38 | query = [(k, query[k]) for k in sorted(query.keys())] 39 | url = "%s?%s" % (url, urlencode(query)) 40 | 41 | try: 42 | wdata = mpsyt_opener.open(url).read().decode() 43 | 44 | except (URLError, HTTPError) as e: 45 | g.message = "%s: %s (%s)" % (err, e, url) 46 | g.content = content.logo(c.r) 47 | return None if not report else (None, False) 48 | 49 | return wdata if not report else (wdata, False) 50 | 51 | 52 | def _best_song_match(songs, title, duration, titleweight, durationweight): 53 | """ Select best matching song based on title, length. 54 | 55 | Score from 0 to 1 where 1 is best. titleweight and durationweight 56 | parameters added to enable function usage when duration can't be guessed 57 | 58 | """ 59 | # pylint: disable=R0914 60 | seqmatch = difflib.SequenceMatcher 61 | 62 | def variance(a, b): 63 | """ Return difference ratio. """ 64 | return float(abs(a - b)) / max(a, b) 65 | 66 | candidates = [] 67 | 68 | ignore = "music video lyrics new lyrics video audio".split() 69 | extra = "official original vevo".split() 70 | 71 | for song in songs: 72 | dur, tit = int(song.length), song.title 73 | util.dbg("Title: %s, Duration: %s", tit, dur) 74 | 75 | for word in extra: 76 | if word in tit.lower() and word not in title.lower(): 77 | pattern = re.compile(word, re.I) 78 | tit = pattern.sub("", tit) 79 | 80 | for word in ignore: 81 | if word in tit.lower() and word not in title.lower(): 82 | pattern = re.compile(word, re.I) 83 | tit = pattern.sub("", tit) 84 | 85 | replacechars = re.compile(r"[\]\[\)\(\-]") 86 | tit = replacechars.sub(" ", tit) 87 | multiple_spaces = re.compile(r"(\s)(\s*)") 88 | tit = multiple_spaces.sub(r"\1", tit) 89 | 90 | title_score = seqmatch(None, title.lower(), tit.lower()).ratio() 91 | duration_score = 1 - variance(duration, dur) 92 | util.dbg("Title score: %s, Duration score: %s", title_score, 93 | duration_score) 94 | 95 | # apply weightings 96 | score = duration_score * durationweight + title_score * titleweight 97 | candidates.append((score, song)) 98 | 99 | best_score, best_song = max(candidates, key=lambda x: x[0]) 100 | percent_score = int(100 * best_score) 101 | return best_song, percent_score 102 | 103 | 104 | def _match_tracks(artist, title, mb_tracks): 105 | """ Match list of tracks in mb_tracks by performing multiple searches. """ 106 | # pylint: disable=R0914 107 | util.dbg("artists is %s", artist) 108 | util.dbg("title is %s", title) 109 | title_artist_str = c.g + title + c.w, c.g + artist + c.w 110 | util.xprint("\nSearching for %s by %s\n\n" % title_artist_str) 111 | 112 | def dtime(x): 113 | """ Format time to M:S. """ 114 | return time.strftime('%M:%S', time.gmtime(int(x))) 115 | 116 | # do matching 117 | for track in mb_tracks: 118 | ttitle = track['title'] 119 | length = track['length'] 120 | util.xprint("Search : %s%s - %s%s - %s" % (c.y, artist, ttitle, c.w, 121 | dtime(length))) 122 | q = "%s %s" % (artist, ttitle) 123 | w = q = ttitle if artist == "Various Artists" else q 124 | query = generate_search_qs(w, 0) 125 | util.dbg(query) 126 | 127 | # perform fetch 128 | wdata = pafy.call_gdata('search', query) 129 | results = get_tracks_from_json(wdata) 130 | 131 | if not results: 132 | util.xprint(c.r + "Nothing matched :(\n" + c.w) 133 | continue 134 | 135 | s, score = _best_song_match( 136 | results, artist + " " + ttitle, length, .5, .5) 137 | cc = c.g if score > 85 else c.y 138 | cc = c.r if score < 75 else cc 139 | util.xprint("Matched: %s%s%s - %s \n[%sMatch confidence: " 140 | "%s%s]\n" % (c.y, s.title, c.w, util.fmt_time(s.length), 141 | cc, score, c.w)) 142 | yield s 143 | 144 | 145 | def _get_mb_tracks(albumid): 146 | """ Get track listing from MusicBraiz by album id. """ 147 | ns = {'mb': 'http://musicbrainz.org/ns/mmd-2.0#'} 148 | url = "http://musicbrainz.org/ws/2/release/" + albumid 149 | query = {"inc": "recordings"} 150 | wdata = _do_query(url, query, err='album search error') 151 | 152 | if not wdata: 153 | return None 154 | 155 | root = ET.fromstring(wdata) 156 | tlist = root.find("./mb:release/mb:medium-list/mb:medium/mb:track-list", 157 | namespaces=ns) 158 | mb_songs = tlist.findall("mb:track", namespaces=ns) 159 | tracks = [] 160 | path = "./mb:recording/mb:" 161 | 162 | for track in mb_songs: 163 | 164 | try: 165 | title, length, rawlength = "unknown", 0, 0 166 | title = track.find(path + "title", namespaces=ns).text 167 | rawlength = track.find(path + "length", namespaces=ns).text 168 | length = int(round(float(rawlength) / 1000)) 169 | 170 | except (ValueError, AttributeError): 171 | util.xprint("not found") 172 | 173 | tracks.append(dict(title=title, length=length, rawlength=rawlength)) 174 | 175 | return tracks 176 | 177 | 178 | def _get_mb_album(albumname, **kwa): 179 | """ Return artist, album title and track count from MusicBrainz. """ 180 | url = "http://musicbrainz.org/ws/2/release/" 181 | qargs = dict( 182 | release='"%s"' % albumname, 183 | primarytype=kwa.get("primarytype", "album"), 184 | status=kwa.get("status", "official")) 185 | qargs.update({k: '"%s"' % v for k, v in kwa.items()}) 186 | qargs = ["%s:%s" % item for item in qargs.items()] 187 | qargs = {"query": " AND ".join(qargs)} 188 | g.message = "Album search for '%s%s%s'" % (c.y, albumname, c.w) 189 | wdata = _do_query(url, qargs) 190 | 191 | if not wdata: 192 | return None 193 | 194 | ns = {'mb': 'http://musicbrainz.org/ns/mmd-2.0#'} 195 | root = ET.fromstring(wdata) 196 | rlist = root.find("mb:release-list", namespaces=ns) 197 | 198 | if int(rlist.get('count')) == 0: 199 | return None 200 | 201 | album = rlist.find("mb:release", namespaces=ns) 202 | artist = album.find("./mb:artist-credit/mb:name-credit/mb:artist", 203 | namespaces=ns).find("mb:name", namespaces=ns).text 204 | title = album.find("mb:title", namespaces=ns).text 205 | aid = album.get('id') 206 | return dict(artist=artist, title=title, aid=aid) 207 | 208 | 209 | @command(r'album\s*(.{0,500})', 'album') 210 | def search_album(term): 211 | """Search for albums. """ 212 | # pylint: disable=R0914,R0912 213 | if not term: 214 | show_message("Enter album name:", c.g, update=True) 215 | term = input("> ") 216 | 217 | if not term or len(term) < 2: 218 | g.message = c.r + "Not enough input!" + c.w 219 | g.content = content.generate_songlist_display() 220 | return 221 | 222 | album = _get_mb_album(term) 223 | 224 | if not album: 225 | show_message("Album '%s' not found!" % term) 226 | return 227 | 228 | prompt = "Artist? [%s] > " % album['artist'] 229 | util.xprint(prompt, end="") 230 | artistentry = input().strip() 231 | 232 | if artistentry: 233 | 234 | if artistentry == "q": 235 | show_message("Album search abandoned!") 236 | return 237 | 238 | album = _get_mb_album(term, artist=artistentry) 239 | 240 | if not album: 241 | show_message("Album '%s' by '%s' not found!" % (term, artistentry)) 242 | return 243 | 244 | title, artist = album['title'], album['artist'] 245 | mb_tracks = _get_mb_tracks(album['aid']) 246 | 247 | if not mb_tracks: 248 | show_message("Album '%s' by '%s' has 0 tracks!" % (title, artist)) 249 | return 250 | 251 | msg = "%s%s%s by %s%s%s\n\n" % (c.g, title, c.w, c.g, artist, c.w) 252 | msg += "Enter to begin matching or [q] to abort" 253 | g.message = msg 254 | g.content = "Tracks:\n" 255 | for n, track in enumerate(mb_tracks, 1): 256 | g.content += "%02s %s" % (n, track['title']) 257 | g.content += "\n" 258 | 259 | screen.update() 260 | entry = input("Continue? [Enter] > ") 261 | 262 | if entry == "": 263 | pass 264 | 265 | else: 266 | show_message("Album search abandoned!") 267 | return 268 | 269 | songs = [] 270 | screen.clear() 271 | itt = _match_tracks(artist, title, mb_tracks) 272 | 273 | stash = config.SEARCH_MUSIC.get, config.ORDER.get 274 | config.SEARCH_MUSIC.value = True 275 | config.ORDER.value = "relevance" 276 | 277 | try: 278 | songs.extend(itt) 279 | 280 | except KeyboardInterrupt: 281 | util.xprint("%sHalted!%s" % (c.r, c.w)) 282 | 283 | finally: 284 | config.SEARCH_MUSIC.value, config.ORDER.value = stash 285 | 286 | if songs: 287 | util.xprint("\n%s / %s songs matched" % (len(songs), len(mb_tracks))) 288 | input("Press Enter to continue") 289 | if g.lastfm_network: 290 | g.artist = artist 291 | g.album = title 292 | g.scrobble = True 293 | # Fill up queue with all the track names 294 | g.scrobble_queue = [t['title'] for t in mb_tracks] 295 | 296 | msg = "Contents of album %s%s - %s%s %s(%d/%d)%s:" % ( 297 | c.y, artist, title, c.w, c.b, len(songs), len(mb_tracks), c.w) 298 | failmsg = "Found no album tracks for %s%s%s" % (c.y, title, c.w) 299 | 300 | paginatesongs(songs, msg=msg, failmsg=failmsg) 301 | -------------------------------------------------------------------------------- /mps_youtube/players/mpv.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import tempfile 4 | import subprocess 5 | import json 6 | import re 7 | import socket 8 | import time 9 | 10 | from .. import g, screen, c, paths, config, util 11 | 12 | from ..player import CmdPlayer 13 | 14 | mswin = os.name == "nt" 15 | not_utf8_environment = mswin or "UTF-8" not in sys.stdout.encoding 16 | 17 | 18 | class mpv(CmdPlayer): 19 | def __init__(self, player): 20 | self.player = player 21 | self.mpv_version = _get_mpv_version(player) 22 | self.mpv_options = subprocess.check_output( 23 | [player, "--list-options"]).decode() 24 | 25 | if not mswin: 26 | if "--input-unix-socket" in self.mpv_options: 27 | self.mpv_usesock = "--input-unix-socket" 28 | util.dbg(c.g + "mpv supports --input-unix-socket" + c.w) 29 | elif "--input-ipc-server" in self.mpv_options: 30 | self.mpv_usesock = "--input-ipc-server" 31 | util.dbg(c.g + "mpv supports --input-ipc-server" + c.w) 32 | 33 | def _generate_real_playerargs(self): 34 | """ Generate args for player command. 35 | 36 | Return args. 37 | 38 | """ 39 | 40 | args = config.PLAYERARGS.get.strip().split() 41 | 42 | pd = g.playerargs_defaults['mpv'] 43 | # Use new mpv syntax 44 | # https://github.com/mps-youtube/mps-youtube/issues/1052 45 | completetitle = '='.join((pd["title"], '"{0}"'.format(self.song.title))) 46 | util.list_update(completetitle, args) 47 | 48 | if pd['geo'] not in args: 49 | geometry = config.WINDOW_SIZE.get or "" 50 | 51 | if config.WINDOW_POS.get: 52 | wp = config.WINDOW_POS.get 53 | xx = "+1" if "left" in wp else "-1" 54 | yy = "+1" if "top" in wp else "-1" 55 | geometry += xx + yy 56 | 57 | if geometry: 58 | # Use new mpv syntax 59 | # See: https://github.com/mps-youtube/mps-youtube/issues/1052 60 | newgeometry = '='.join((pd['geo'], geometry)) 61 | util.list_update(newgeometry, args) 62 | 63 | # handle no audio stream available 64 | if self.override == "a-v": 65 | util.list_update(pd["novid"], args) 66 | 67 | elif ((config.FULLSCREEN.get and self.override != "window") 68 | or self.override == "fullscreen"): 69 | util.list_update(pd["fs"], args) 70 | 71 | # prevent ffmpeg issue (https://github.com/mpv-player/mpv/issues/579) 72 | if not self.video and self.stream['ext'] == "m4a": 73 | util.dbg("%susing ignidx flag%s") 74 | util.list_update(pd["ignidx"], args) 75 | 76 | if "--ytdl" in self.mpv_options: 77 | util.list_update("--no-ytdl", args) 78 | 79 | msglevel = pd["msglevel"]["<0.4"] 80 | 81 | # undetected (negative) version number assumed up-to-date 82 | if self.mpv_version[0:2] < (0, 0) or self.mpv_version[0:2] >= (0, 4): 83 | msglevel = pd["msglevel"][">=0.4"] 84 | 85 | if not g.debug_mode: 86 | if self.mpv_usesock: 87 | util.list_update("--really-quiet", args) 88 | else: 89 | util.list_update("--really-quiet", args, remove=True) 90 | util.list_update(msglevel, args) 91 | 92 | if g.volume: 93 | util.list_update("--volume=" + str(g.volume), args) 94 | if self.softrepeat: 95 | util.list_update("--loop-file", args) 96 | 97 | return [self.player] + args + [self.stream['url']] 98 | 99 | def clean_up(self): 100 | if self.input_file: 101 | os.unlink(self.input_file) 102 | 103 | if self.sockpath and os.path.exists(self.sockpath): 104 | os.unlink(self.sockpath) 105 | 106 | if self.fifopath and os.path.exists(self.fifopath): 107 | os.unlink(self.fifopath) 108 | 109 | def launch_player(self, cmd): 110 | self.input_file = _get_input_file() 111 | cmd.append('--input-conf=' + self.input_file) 112 | self.sockpath = None 113 | self.fifopath = None 114 | 115 | if self.mpv_usesock: 116 | self.sockpath = tempfile.mktemp('.sock', 'mpsyt-mpv') 117 | cmd.append(self.mpv_usesock + '=' + self.sockpath) 118 | 119 | with open(os.devnull, "w") as devnull: 120 | self.p = subprocess.Popen(cmd, shell=False, stderr=devnull) 121 | 122 | if g.mprisctl: 123 | g.mprisctl.send(('socket', self.sockpath)) 124 | else: 125 | if g.mprisctl: 126 | self.fifopath = tempfile.mktemp('.fifo', 'mpsyt-mpv') 127 | os.mkfifo(self.fifopath) 128 | cmd.append('--input-file=' + self.fifopath) 129 | g.mprisctl.send(('mpv-fifo', self.fifopath)) 130 | 131 | self.p = subprocess.Popen(cmd, shell=False, stderr=subprocess.PIPE, 132 | bufsize=1) 133 | 134 | self._player_status(self.songdata + "; ", self.song.length) 135 | returncode = self.p.wait() 136 | 137 | if returncode == 42: 138 | self.previous() 139 | 140 | elif returncode == 43: 141 | self.stop() 142 | 143 | else: 144 | self.next() 145 | 146 | def _player_status(self, prefix, songlength=0): 147 | """ Capture time progress from player output. Write status line. """ 148 | # pylint: disable=R0914, R0912 149 | re_player = re.compile(r".{,15}AV?:\s*(\d\d):(\d\d):(\d\d)") 150 | re_volume = re.compile(r"Volume:\s*(?P\d+)\s*%") 151 | last_displayed_line = None 152 | buff = '' 153 | volume_level = None 154 | last_pos = None 155 | 156 | if self.sockpath: 157 | s = socket.socket(socket.AF_UNIX) 158 | 159 | tries = 0 160 | while tries < 10 and self.p.poll() is None: 161 | time.sleep(.5) 162 | try: 163 | s.connect(self.sockpath) 164 | break 165 | except socket.error: 166 | pass 167 | tries += 1 168 | else: 169 | return 170 | 171 | try: 172 | observe_full = False 173 | cmd = {"command": ["observe_property", 1, "time-pos"]} 174 | s.send(json.dumps(cmd).encode() + b'\n') 175 | volume_level = elapsed_s = None 176 | 177 | for line in s.makefile(): 178 | resp = json.loads(line) 179 | 180 | # deals with bug in mpv 0.7 - 0.7.3 181 | if resp.get('event') == 'property-change' and not observe_full: 182 | cmd = {"command": ["observe_property", 2, "volume"]} 183 | s.send(json.dumps(cmd).encode() + b'\n') 184 | observe_full = True 185 | 186 | if resp.get('event') == 'property-change' and resp['id'] == 1: 187 | if resp['data'] is not None: 188 | elapsed_s = int(resp['data']) 189 | 190 | elif resp.get('event') == 'property-change' and resp['id'] == 2: 191 | volume_level = int(resp['data']) 192 | 193 | if(volume_level and volume_level != g.volume): 194 | g.volume = volume_level 195 | if elapsed_s: 196 | self.make_status_line(elapsed_s, prefix, songlength, 197 | volume=volume_level) 198 | 199 | except socket.error: 200 | pass 201 | 202 | else: 203 | elapsed_s = 0 204 | 205 | while self.p.poll() is None: 206 | stdstream = self.p.stderr 207 | char = stdstream.read(1).decode("utf-8", errors="ignore") 208 | 209 | if char in '\r\n': 210 | 211 | mv = re_volume.search(buff) 212 | 213 | if mv: 214 | volume_level = int(mv.group("volume")) 215 | 216 | match_object = re_player.match(buff) 217 | 218 | if match_object: 219 | 220 | try: 221 | h, m, s = map(int, match_object.groups()) 222 | elapsed_s = h * 3600 + m * 60 + s 223 | 224 | except ValueError: 225 | 226 | try: 227 | elapsed_s = int(match_object.group('elapsed_s') 228 | or '0') 229 | 230 | except ValueError: 231 | continue 232 | 233 | if volume_level and volume_level != g.volume: 234 | g.volume = volume_level 235 | self.make_status_line(elapsed_s, prefix, songlength, 236 | volume=volume_level) 237 | 238 | if buff.startswith('ANS_volume='): 239 | volume_level = round(float(buff.split('=')[1])) 240 | 241 | paused = ("PAUSE" in buff) or ("Paused" in buff) 242 | if (elapsed_s != last_pos or paused) and g.mprisctl: 243 | last_pos = elapsed_s 244 | g.mprisctl.send(('pause', paused)) 245 | g.mprisctl.send(('volume', volume_level)) 246 | g.mprisctl.send(('time-pos', elapsed_s)) 247 | 248 | buff = '' 249 | 250 | else: 251 | buff += char 252 | 253 | def _help(self, short=True): 254 | """ Mplayer help. """ 255 | 256 | volume = "[{0}9{1}] volume [{0}0{1}] [{0}CTRL-C{1}] return" 257 | seek = "[{0}\u2190{1}] seek [{0}\u2192{1}]" 258 | pause = "[{0}\u2193{1}] SEEK [{0}\u2191{1}] [{0}space{1}] pause" 259 | 260 | if not_utf8_environment: 261 | seek = "[{0}<-{1}] seek [{0}->{1}]" 262 | pause = "[{0}DN{1}] SEEK [{0}UP{1}] [{0}space{1}] pause" 263 | 264 | single = "[{0}q{1}] next" 265 | next_prev = "[{0}>{1}] next/prev [{0}<{1}]" 266 | # ret = "[{0}q{1}] %s" % ("return" if short else "next track") 267 | ret = single if short and config.AUTOPLAY.get else "" 268 | ret = next_prev if not short else ret 269 | fmt = " %-20s %-20s" 270 | lines = fmt % (seek, volume) + "\n" + fmt % (pause, ret) 271 | return lines.format(c.g, c.w) 272 | 273 | 274 | def _get_input_file(): 275 | """ Check for existence of custom input file. 276 | 277 | Return file name of temp input file with mpsyt mappings included 278 | """ 279 | confpath = conf = '' 280 | 281 | confpath = os.path.join(paths.get_config_dir(), "mpv-input.conf") 282 | 283 | if os.path.isfile(confpath): 284 | util.dbg("using %s for input key file", confpath) 285 | 286 | with open(confpath) as conffile: 287 | conf = conffile.read() + '\n' 288 | 289 | conf = conf.replace("quit", "quit 43") 290 | conf = conf.replace("playlist_prev", "quit 42") 291 | conf = conf.replace("pt_step -1", "quit 42") 292 | conf = conf.replace("playlist_next", "quit") 293 | conf = conf.replace("pt_step 1", "quit") 294 | standard_cmds = ['q quit 43\n', '> quit\n', '< quit 42\n', 'NEXT quit\n', 295 | 'PREV quit 42\n', 'ENTER quit\n'] 296 | bound_keys = [i.split()[0] for i in conf.splitlines() if i.split()] 297 | 298 | for i in standard_cmds: 299 | key = i.split()[0] 300 | 301 | if key not in bound_keys: 302 | conf += i 303 | 304 | with tempfile.NamedTemporaryFile('w', prefix='mpsyt-input', 305 | delete=False) as tmpfile: 306 | tmpfile.write(conf) 307 | return tmpfile.name 308 | 309 | 310 | def _get_mpv_version(exename): 311 | """ Get version of mpv as 3-tuple. """ 312 | o = subprocess.check_output([exename, "--version"]).decode() 313 | re_ver = re.compile(r"mpv (\d+)\.(\d+)\.(\d+)") 314 | 315 | for line in o.split("\n"): 316 | m = re_ver.match(line) 317 | 318 | if m: 319 | v = tuple(map(int, m.groups())) 320 | util.dbg("%s version %s.%s.%s detected", exename, *v) 321 | return v 322 | 323 | util.dbg("%sFailed to detect mpv version%s", c.r, c.w) 324 | return -1, 0, 0 325 | -------------------------------------------------------------------------------- /mps_youtube/commands/misc.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | import socket 4 | import traceback 5 | from urllib.request import urlopen 6 | from urllib.error import HTTPError, URLError 7 | from .. import player 8 | 9 | try: 10 | # pylint: disable=F0401 11 | import pyperclip 12 | has_pyperclip = True 13 | 14 | except ImportError: 15 | has_pyperclip = False 16 | 17 | try: 18 | import readline 19 | has_readline = True 20 | except ImportError: 21 | has_readline = False 22 | 23 | import pafy 24 | from .. import g, c, __version__, content, screen, cache 25 | from .. import streams, history, config, util 26 | from ..helptext import get_help 27 | from ..content import generate_songlist_display, logo, qrcode_display 28 | from . import command 29 | from .songlist import paginatesongs 30 | 31 | 32 | @command(r'clearcache') 33 | def clearcache(): 34 | """ Clear cached items - for debugging use. """ 35 | g.pafs = {} 36 | g.streams = {} 37 | util.dbg("%scache cleared%s", c.p, c.w) 38 | g.message = "cache cleared" 39 | 40 | 41 | @command(r'(?:help|h)(?:\s+([-_a-zA-Z]+))?', 'help') 42 | def show_help(choice): 43 | """ Print help message. """ 44 | 45 | g.content = get_help(choice) 46 | 47 | 48 | @command(r'(?:q|quit|exit)', 'quit', 'exit') 49 | def quits(showlogo=True): 50 | """ Exit the program. """ 51 | if has_readline and config.INPUT_HISTORY.get: 52 | readline.write_history_file(g.READLINE_FILE) 53 | util.dbg("Saved history file") 54 | 55 | cache.save() 56 | 57 | screen.clear() 58 | msg = logo(c.r, version=__version__) if showlogo else "" 59 | msg += util.F("exitmsg", 2) 60 | 61 | if config.CHECKUPDATE.get and showlogo: 62 | 63 | try: 64 | url = "https://raw.githubusercontent.com/mps-youtube/mps-youtube/master/VERSION" 65 | v = urlopen(url, timeout=1).read().decode() 66 | v = re.search(r"^version\s*([\d\.]+)\s*$", v, re.MULTILINE) 67 | 68 | if v: 69 | v = v.group(1) 70 | 71 | if v > __version__: 72 | msg += "\n\nA newer version is available (%s)\n" % v 73 | 74 | except (URLError, HTTPError, socket.timeout): 75 | util.dbg("check update timed out") 76 | 77 | screen.msgexit(msg) 78 | 79 | def _format_comment(snippet, n, qnt, reply=False): 80 | poster = snippet.get('authorDisplayName') 81 | shortdate = util.yt_datetime(snippet.get('publishedAt', ''))[1] 82 | text = snippet.get('textDisplay', '') 83 | cid = ("%s%s/%s %s" % ('└── ' if reply else '', n, qnt, c.c("g", poster))) 84 | return ("%-39s %s\n" % (cid, shortdate)) + \ 85 | c.c("y", text.strip()) + '\n\n' 86 | 87 | def _fetch_commentreplies(parentid): 88 | return pafy.call_gdata('comments', { 89 | 'parentId': parentid, 90 | 'part': 'snippet', 91 | 'textFormat': 'plainText', 92 | 'maxResults': 50}).get('items', []) 93 | 94 | def fetch_comments(item): 95 | """ Fetch comments for item using gdata. """ 96 | # pylint: disable=R0912 97 | # pylint: disable=R0914 98 | ytid, title = item.ytid, item.title 99 | util.dbg("Fetching comments for %s", c.c("y", ytid)) 100 | screen.writestatus("Fetching comments for %s" % c.c("y", title[:55])) 101 | qs = {'textFormat': 'plainText', 102 | 'videoId': ytid, 103 | 'maxResults': 50, 104 | 'part': 'snippet'} 105 | 106 | jsdata = None 107 | try: 108 | jsdata = pafy.call_gdata('commentThreads', qs) 109 | except pafy.util.GdataError as e: 110 | raise pafy.util.GdataError(str(e).replace(" identified by the videoId parameter", "")) 111 | coms = [x.get('snippet', {}) for x in jsdata.get('items', [])] 112 | 113 | # skip blanks 114 | coms = [x for x in coms 115 | if len(x.get('topLevelComment', {}).get('snippet', {}).get('textDisplay', '').strip())] 116 | 117 | if not len(coms): 118 | g.message = "No comments for %s" % item.title[:50] 119 | g.content = generate_songlist_display() 120 | return 121 | 122 | commentstext = '' 123 | 124 | for n, com in enumerate(coms, 1): 125 | snippet = com.get('topLevelComment', {}).get('snippet', {}) 126 | commentstext += _format_comment(snippet, n, len(coms)) 127 | if com.get('totalReplyCount') > 0: 128 | replies = _fetch_commentreplies(com.get('topLevelComment').get('id')) 129 | for n, com in enumerate(reversed(replies), 1): 130 | commentstext += _format_comment(com.get('snippet', {}), 131 | n, len(replies), True) 132 | 133 | g.current_page = 0 134 | g.content = content.StringContent(commentstext) 135 | 136 | 137 | @command(r'c\s?(\d{1,4})', 'c') 138 | def comments(number): 139 | """ Receive use request to view comments. """ 140 | if g.browse_mode == "normal": 141 | item = g.model[int(number) - 1] 142 | fetch_comments(item) 143 | 144 | else: 145 | g.content = generate_songlist_display() 146 | g.message = "Comments only available for video items" 147 | 148 | 149 | @command(r'x\s*(\d+)', 'x') 150 | def clipcopy_video(num): 151 | """ Copy video/playlist url to clipboard. """ 152 | if g.browse_mode == "ytpl": 153 | 154 | p = g.ytpls[int(num) - 1] 155 | link = "https://youtube.com/playlist?list=%s" % p['link'] 156 | 157 | elif g.browse_mode == "normal": 158 | item = (g.model[int(num) - 1]) 159 | link = "https://youtube.com/watch?v=%s" % item.ytid 160 | 161 | else: 162 | g.message = "clipboard copy not valid in this mode" 163 | g.content = generate_songlist_display() 164 | return 165 | 166 | if has_pyperclip: 167 | 168 | try: 169 | pyperclip.copy(link) 170 | g.message = c.y + link + c.w + " copied" 171 | g.content = generate_songlist_display() 172 | 173 | except Exception as e: 174 | g.content = generate_songlist_display() 175 | g.message = link + "\nError - couldn't copy to clipboard.\n" + \ 176 | ''.join(traceback.format_exception_only(type(e), e)) 177 | 178 | else: 179 | g.message = "pyperclip module must be installed for clipboard support\n" 180 | g.message += "see https://pypi.python.org/pypi/pyperclip/" 181 | g.content = generate_songlist_display() 182 | 183 | 184 | @command(r'X\s*(\d+)', 'X') 185 | def clipcopy_stream(num): 186 | """ Copy content stream url to clipboard. """ 187 | if g.browse_mode == "normal": 188 | 189 | item = (g.model[int(num) - 1]) 190 | details = player.stream_details(item)[1] 191 | stream = details['url'] 192 | 193 | else: 194 | g.message = "clipboard copy not valid in this mode" 195 | g.content = generate_songlist_display() 196 | return 197 | 198 | if has_pyperclip: 199 | 200 | try: 201 | pyperclip.copy(stream) 202 | g.message = c.y + stream + c.w + " copied" 203 | g.content = generate_songlist_display() 204 | 205 | except Exception as e: 206 | g.content = generate_songlist_display() 207 | g.message = stream + "\nError - couldn't copy to clipboard.\n" + \ 208 | ''.join(traceback.format_exception_only(type(e), e)) 209 | 210 | else: 211 | g.message = "pyperclip module must be installed for clipboard support\n" 212 | g.message += "see https://pypi.python.org/pypi/pyperclip/" 213 | g.content = generate_songlist_display() 214 | 215 | 216 | @command(r'i\s*(\d{1,4})', 'i') 217 | def video_info(num): 218 | """ Get video information. """ 219 | if g.browse_mode == "ytpl": 220 | p = g.ytpls[int(num) - 1] 221 | 222 | # fetch the playlist item as it has more metadata 223 | if p['link'] in g.pafy_pls: 224 | ytpl = g.pafy_pls[p['link']][0] 225 | else: 226 | g.content = logo(col=c.g) 227 | g.message = "Fetching playlist info.." 228 | screen.update() 229 | util.dbg("%sFetching playlist using pafy%s", c.y, c.w) 230 | ytpl = pafy.get_playlist2(p['link']) 231 | g.pafy_pls[p['link']] = (ytpl, util.IterSlicer(ytpl)) 232 | 233 | ytpl_desc = ytpl.description 234 | g.content = generate_songlist_display() 235 | created = util.yt_datetime_local(p['created']) 236 | updated = util.yt_datetime_local(p['updated']) 237 | out = c.ul + "Playlist Info" + c.w + "\n\n" 238 | out += p['title'] 239 | out += "\n" + ytpl_desc 240 | out += ("\n\nAuthor : " + p['author']) 241 | out += "\nSize : " + str(p['size']) + " videos" 242 | out += "\nCreated : " + created[1] + " " + created[2] 243 | out += "\nUpdated : " + updated[1] + " " + updated[2] 244 | out += "\nID : " + str(p['link']) 245 | out += ("\n\n%s[%sPress enter to go back%s]%s" % (c.y, c.w, c.y, c.w)) 246 | g.content = out 247 | 248 | elif g.browse_mode == "normal": 249 | g.content = logo(c.b) 250 | screen.update() 251 | screen.writestatus("Fetching video metadata..") 252 | item = (g.model[int(num) - 1]) 253 | streams.get(item) 254 | p = util.get_pafy(item) 255 | pub = datetime.strptime(str(p.published), "%Y-%m-%d %H:%M:%S") 256 | pub = util.utc2local(pub) 257 | screen.writestatus("Fetched") 258 | out = c.ul + "Video Info" + c.w + "\n\n" 259 | out += p.title or "" 260 | out += "\n" + (p.description or "") + "\n" 261 | out += "\nAuthor : " + str(p.author) 262 | out += "\nPublished : " + pub.strftime("%c") 263 | out += "\nView count : " + str(p.viewcount) 264 | out += "\nRating : " + str(p.rating)[:4] 265 | out += "\nLikes : " + str(p.likes) 266 | out += "\nDislikes : " + str(p.dislikes) 267 | out += "\nCategory : " + str(p.category) 268 | out += "\nLink : " + "https://youtube.com/watch?v=%s" % p.videoid 269 | if config.SHOW_QRCODE.get: 270 | out += "\n" + qrcode_display( 271 | "https://youtube.com/watch?v=%s" % p.videoid) 272 | 273 | out += "\n\n%s[%sPress enter to go back%s]%s" % (c.y, c.w, c.y, c.w) 274 | g.content = out 275 | 276 | 277 | @command(r's\s*(\d{1,4})', 's') 278 | def stream_info(num): 279 | """ Get stream information. """ 280 | if g.browse_mode == "normal": 281 | g.content = logo(c.b) 282 | screen.update() 283 | screen.writestatus("Fetching stream metadata..") 284 | item = (g.model[int(num) - 1]) 285 | streams.get(item) 286 | p = util.get_pafy(item) 287 | setattr(p, 'ytid', p.videoid) 288 | details = player.stream_details(p)[1] 289 | screen.writestatus("Fetched") 290 | out = "\n\n" + c.ul + "Stream Info" + c.w + "\n" 291 | out += "\nExtension : " + details['ext'] 292 | out += "\nSize : " + str(details['size']) 293 | out += "\nQuality : " + details['quality'] 294 | out += "\nRaw bitrate : " + str(details['rawbitrate']) 295 | out += "\nMedia type : " + details['mtype'] 296 | out += "\nLink : " + details['url'] 297 | out += "\n\n%s[%sPress enter to go back%s]%s" % (c.y, c.w, c.y, c.w) 298 | g.content = out 299 | 300 | 301 | @command(r'history', 'history') 302 | def view_history(duplicates=True): 303 | """ Display the user's play history """ 304 | history = g.userhist.get('history') 305 | #g.last_opened = "" 306 | try: 307 | hist_list = list(reversed(history.songs)) 308 | message = "Viewing play history" 309 | if not duplicates: 310 | # List unique elements and preserve order. 311 | seen = set() 312 | seen_add = seen.add # it makes calls to add() faster 313 | hist_list = [x for x in hist_list if not (x.ytid in seen or seen_add(x.ytid))] 314 | message = "Viewing recent played songs" 315 | paginatesongs(hist_list) 316 | g.message = message 317 | 318 | except AttributeError: 319 | g.content = logo(c.r) 320 | g.message = "History empty" 321 | 322 | 323 | if not config.HISTORY.get: 324 | g.message += "\t{1}History recording is currently off{0}".format(c.w,c.y) 325 | 326 | 327 | 328 | @command(r'history recent', 'history recent') 329 | def recent_history(): 330 | """ Display the recent user's played songs """ 331 | view_history(duplicates=False) 332 | 333 | 334 | @command(r'history clear', 'history clear') 335 | def clear_history(): 336 | """ Clears the user's play history """ 337 | g.userhist['history'].songs = [] 338 | history.save() 339 | g.message = "History cleared" 340 | g.content = logo() 341 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # mps_youtube documentation build configuration file, created by 5 | # sphinx-quickstart on Mon Apr 18 17:35:31 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | import sphinx_rtd_theme 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.todo', 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = 'mps_youtube' 54 | copyright = '2016, mps-youtube developers' 55 | author = 'mps-youtube developers' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = '' 63 | # The full version, including alpha/beta/rc tags. 64 | release = '' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = 'en' 72 | 73 | # There are two options for replacing |today|: either, you set today to some 74 | # non-false value, then it is used: 75 | #today = '' 76 | # Else, today_fmt is used as the format for a strftime call. 77 | #today_fmt = '%B %d, %Y' 78 | 79 | # List of patterns, relative to source directory, that match files and 80 | # directories to ignore when looking for source files. 81 | # This patterns also effect to html_static_path and html_extra_path 82 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 83 | 84 | # The reST default role (used for this markup: `text`) to use for all 85 | # documents. 86 | #default_role = None 87 | 88 | # If true, '()' will be appended to :func: etc. cross-reference text. 89 | #add_function_parentheses = True 90 | 91 | # If true, the current module name will be prepended to all description 92 | # unit titles (such as .. function::). 93 | #add_module_names = True 94 | 95 | # If true, sectionauthor and moduleauthor directives will be shown in the 96 | # output. They are ignored by default. 97 | #show_authors = False 98 | 99 | # The name of the Pygments (syntax highlighting) style to use. 100 | pygments_style = 'sphinx' 101 | 102 | # A list of ignored prefixes for module index sorting. 103 | #modindex_common_prefix = [] 104 | 105 | # If true, keep warnings as "system message" paragraphs in the built documents. 106 | #keep_warnings = False 107 | 108 | # If true, `todo` and `todoList` produce output, else they produce nothing. 109 | todo_include_todos = True 110 | 111 | 112 | # -- Options for HTML output ---------------------------------------------- 113 | 114 | # The theme to use for HTML and HTML Help pages. See the documentation for 115 | # a list of builtin themes. 116 | #html_theme = 'alabaster' 117 | html_theme = "sphinx_rtd_theme" 118 | 119 | # Theme options are theme-specific and customize the look and feel of a theme 120 | # further. For a list of options available for each theme, see the 121 | # documentation. 122 | #html_theme_options = {} 123 | 124 | # Add any paths that contain custom themes here, relative to this directory. 125 | #html_theme_path = [] 126 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 127 | 128 | # The name for this set of Sphinx documents. 129 | # " v documentation" by default. 130 | #html_title = 'mps_youtube v' 131 | 132 | # A shorter title for the navigation bar. Default is the same as html_title. 133 | #html_short_title = None 134 | 135 | # The name of an image file (relative to this directory) to place at the top 136 | # of the sidebar. 137 | #html_logo = None 138 | 139 | # The name of an image file (relative to this directory) to use as a favicon of 140 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 141 | # pixels large. 142 | #html_favicon = None 143 | 144 | # Add any paths that contain custom static files (such as style sheets) here, 145 | # relative to this directory. They are copied after the builtin static files, 146 | # so a file named "default.css" will overwrite the builtin "default.css". 147 | html_static_path = ['_static'] 148 | 149 | # Add any extra paths that contain custom files (such as robots.txt or 150 | # .htaccess) here, relative to this directory. These files are copied 151 | # directly to the root of the documentation. 152 | #html_extra_path = [] 153 | 154 | # If not None, a 'Last updated on:' timestamp is inserted at every page 155 | # bottom, using the given strftime format. 156 | # The empty string is equivalent to '%b %d, %Y'. 157 | #html_last_updated_fmt = None 158 | 159 | # If true, SmartyPants will be used to convert quotes and dashes to 160 | # typographically correct entities. 161 | #html_use_smartypants = True 162 | 163 | # Custom sidebar templates, maps document names to template names. 164 | #html_sidebars = {} 165 | 166 | # Additional templates that should be rendered to pages, maps page names to 167 | # template names. 168 | #html_additional_pages = {} 169 | 170 | # If false, no module index is generated. 171 | #html_domain_indices = True 172 | 173 | # If false, no index is generated. 174 | #html_use_index = True 175 | 176 | # If true, the index is split into individual pages for each letter. 177 | #html_split_index = False 178 | 179 | # If true, links to the reST sources are added to the pages. 180 | #html_show_sourcelink = True 181 | 182 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 183 | #html_show_sphinx = True 184 | 185 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 186 | #html_show_copyright = True 187 | 188 | # If true, an OpenSearch description file will be output, and all pages will 189 | # contain a tag referring to it. The value of this option must be the 190 | # base URL from which the finished HTML is served. 191 | #html_use_opensearch = '' 192 | 193 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 194 | #html_file_suffix = None 195 | 196 | # Language to be used for generating the HTML full-text search index. 197 | # Sphinx supports the following languages: 198 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 199 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 200 | #html_search_language = 'en' 201 | 202 | # A dictionary with options for the search language support, empty by default. 203 | # 'ja' uses this config value. 204 | # 'zh' user can custom change `jieba` dictionary path. 205 | #html_search_options = {'type': 'default'} 206 | 207 | # The name of a javascript file (relative to the configuration directory) that 208 | # implements a search results scorer. If empty, the default will be used. 209 | #html_search_scorer = 'scorer.js' 210 | 211 | # Output file base name for HTML help builder. 212 | htmlhelp_basename = 'mps_youtubedoc' 213 | 214 | # -- Options for LaTeX output --------------------------------------------- 215 | 216 | latex_elements = { 217 | # The paper size ('letterpaper' or 'a4paper'). 218 | #'papersize': 'letterpaper', 219 | 220 | # The font size ('10pt', '11pt' or '12pt'). 221 | #'pointsize': '10pt', 222 | 223 | # Additional stuff for the LaTeX preamble. 224 | #'preamble': '', 225 | 226 | # Latex figure (float) alignment 227 | #'figure_align': 'htbp', 228 | } 229 | 230 | # Grouping the document tree into LaTeX files. List of tuples 231 | # (source start file, target name, title, 232 | # author, documentclass [howto, manual, or own class]). 233 | latex_documents = [ 234 | (master_doc, 'mps_youtube.tex', 'mps\\_youtube Documentation', 235 | 'Author', 'manual'), 236 | ] 237 | 238 | # The name of an image file (relative to this directory) to place at the top of 239 | # the title page. 240 | #latex_logo = None 241 | 242 | # For "manual" documents, if this is true, then toplevel headings are parts, 243 | # not chapters. 244 | #latex_use_parts = False 245 | 246 | # If true, show page references after internal links. 247 | #latex_show_pagerefs = False 248 | 249 | # If true, show URL addresses after external links. 250 | #latex_show_urls = False 251 | 252 | # Documents to append as an appendix to all manuals. 253 | #latex_appendices = [] 254 | 255 | # If false, no module index is generated. 256 | #latex_domain_indices = True 257 | 258 | 259 | # -- Options for manual page output --------------------------------------- 260 | 261 | # One entry per manual page. List of tuples 262 | # (source start file, name, description, authors, manual section). 263 | man_pages = [ 264 | (master_doc, 'mps_youtube', 'mps_youtube Documentation', 265 | [author], 1) 266 | ] 267 | 268 | # If true, show URL addresses after external links. 269 | #man_show_urls = False 270 | 271 | 272 | # -- Options for Texinfo output ------------------------------------------- 273 | 274 | # Grouping the document tree into Texinfo files. List of tuples 275 | # (source start file, target name, title, author, 276 | # dir menu entry, description, category) 277 | texinfo_documents = [ 278 | (master_doc, 'mps_youtube', 'mps_youtube Documentation', 279 | author, 'mps_youtube', 'One line description of project.', 280 | 'Miscellaneous'), 281 | ] 282 | 283 | # Documents to append as an appendix to all manuals. 284 | #texinfo_appendices = [] 285 | 286 | # If false, no module index is generated. 287 | #texinfo_domain_indices = True 288 | 289 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 290 | #texinfo_show_urls = 'footnote' 291 | 292 | # If true, do not generate a @detailmenu in the "Top" node's menu. 293 | #texinfo_no_detailmenu = False 294 | 295 | 296 | # -- Options for Epub output ---------------------------------------------- 297 | 298 | # Bibliographic Dublin Core info. 299 | epub_title = project 300 | epub_author = author 301 | epub_publisher = author 302 | epub_copyright = copyright 303 | 304 | # The basename for the epub file. It defaults to the project name. 305 | #epub_basename = project 306 | 307 | # The HTML theme for the epub output. Since the default themes are not 308 | # optimized for small screen space, using the same theme for HTML and epub 309 | # output is usually not wise. This defaults to 'epub', a theme designed to save 310 | # visual space. 311 | #epub_theme = 'epub' 312 | 313 | # The language of the text. It defaults to the language option 314 | # or 'en' if the language is not set. 315 | #epub_language = '' 316 | 317 | # The scheme of the identifier. Typical schemes are ISBN or URL. 318 | #epub_scheme = '' 319 | 320 | # The unique identifier of the text. This can be a ISBN number 321 | # or the project homepage. 322 | #epub_identifier = '' 323 | 324 | # A unique identification for the text. 325 | #epub_uid = '' 326 | 327 | # A tuple containing the cover image and cover page html template filenames. 328 | #epub_cover = () 329 | 330 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 331 | #epub_guide = () 332 | 333 | # HTML files that should be inserted before the pages created by sphinx. 334 | # The format is a list of tuples containing the path and title. 335 | #epub_pre_files = [] 336 | 337 | # HTML files that should be inserted after the pages created by sphinx. 338 | # The format is a list of tuples containing the path and title. 339 | #epub_post_files = [] 340 | 341 | # A list of files that should not be packed into the epub file. 342 | epub_exclude_files = ['search.html'] 343 | 344 | # The depth of the table of contents in toc.ncx. 345 | #epub_tocdepth = 3 346 | 347 | # Allow duplicate toc entries. 348 | #epub_tocdup = True 349 | 350 | # Choose between 'default' and 'includehidden'. 351 | #epub_tocscope = 'default' 352 | 353 | # Fix unsupported image types using the Pillow. 354 | #epub_fix_images = False 355 | 356 | # Scale large images. 357 | #epub_max_image_width = 0 358 | 359 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 360 | #epub_show_urls = 'inline' 361 | 362 | # If false, no index is generated. 363 | #epub_use_index = True 364 | -------------------------------------------------------------------------------- /mps_youtube/player.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import random 4 | import logging 5 | import math 6 | import time 7 | import shlex 8 | import subprocess 9 | import socket 10 | from urllib.error import HTTPError, URLError 11 | from abc import ABCMeta, abstractmethod 12 | 13 | 14 | from . import g, screen, c, streams, history, content, config, util 15 | from .commands import lastfm 16 | 17 | 18 | mswin = os.name == "nt" 19 | not_utf8_environment = mswin or "UTF-8" not in sys.stdout.encoding 20 | 21 | class BasePlayer: 22 | _playbackStatus = "Paused" 23 | _last_displayed_line = None 24 | 25 | @property 26 | def PlaybackStatus(self): 27 | return self._playbackStatus 28 | 29 | @PlaybackStatus.setter 30 | def PlaybackStatus(self, value): 31 | self._playbackStatus = value 32 | if value == 'Playing': 33 | paused = False 34 | else: 35 | paused = True 36 | g.mprisctl.send(('pause', paused)) 37 | 38 | def play(self, songlist, shuffle=False, repeat=False, override=False): 39 | """ Play a range of songs, exit cleanly on keyboard interrupt. """ 40 | 41 | if config.ALWAYS_REPEAT.get: 42 | repeat = True 43 | 44 | self.songlist = songlist 45 | self.shuffle = shuffle 46 | self.repeat = repeat 47 | self.override = override 48 | if shuffle: 49 | random.shuffle(self.songlist) 50 | 51 | self.song_no = 0 52 | while 0 <= self.song_no <= len(self.songlist)-1: 53 | self.song = self.songlist[self.song_no] 54 | g.content = self._playback_progress(self.song_no, self.songlist, 55 | repeat=repeat) 56 | 57 | if not g.command_line: 58 | screen.update(fill_blank=False) 59 | 60 | hasnext = len(self.songlist) > self.song_no + 1 61 | 62 | if hasnext: 63 | streams.preload(self.songlist[self.song_no + 1], 64 | override=self.override) 65 | 66 | if config.SET_TITLE.get: 67 | util.set_window_title(self.song.title + " - mpsyt") 68 | 69 | self.softrepeat = repeat and len(self.songlist) == 1 70 | 71 | if g.scrobble: 72 | lastfm.set_now_playing(g.artist, g.scrobble_queue[self.song_no]) 73 | 74 | try: 75 | self.video, self.stream, self.override = stream_details( 76 | self.song, 77 | override=self.override, 78 | softrepeat=self.softrepeat) 79 | self._playsong() 80 | 81 | except KeyboardInterrupt: 82 | logging.info("Keyboard Interrupt") 83 | util.xprint(c.w + "Stopping... ") 84 | screen.reset_terminal() 85 | g.message = c.y + "Playback halted" + c.w 86 | raise KeyboardInterrupt 87 | break 88 | 89 | # skip forbidden, video removed/no longer available, etc. tracks 90 | except TypeError: 91 | self.song_no += 1 92 | pass 93 | 94 | if config.SET_TITLE.get: 95 | util.set_window_title("mpsyt") 96 | 97 | if self.song_no == -1: 98 | self.song_no = len(songlist) - 1 if repeat else 0 99 | elif self.song_no == len(self.songlist) and repeat: 100 | self.song_no = 0 101 | 102 | # To be defined by subclass based on being cmd player or library 103 | # When overriding next and previous don't forget to add the following 104 | # if g.scrobble: 105 | # lastfm.scrobble_track(g.artist, g.album, g.scrobble_queue[self.song_no]) 106 | def next(self): 107 | pass 108 | 109 | def previous(self): 110 | pass 111 | 112 | def stop(self): 113 | pass 114 | ############### 115 | 116 | def seek(self): 117 | pass 118 | 119 | def _playsong(self, failcount=0, softrepeat=False): 120 | """ Play song using config.PLAYER called with args config.PLAYERARGS. 121 | 122 | """ 123 | # pylint: disable=R0911,R0912 124 | if not config.PLAYER.get or not util.has_exefile(config.PLAYER.get): 125 | g.message = "Player not configured! Enter %sset player "\ 126 | "%s to set a player" % (c.g, c.w) 127 | return 128 | 129 | if config.NOTIFIER.get: 130 | subprocess.Popen(shlex.split(config.NOTIFIER.get) + [self.song.title]) 131 | 132 | size = streams.get_size(self.song.ytid, self.stream['url']) 133 | songdata = (self.song.ytid, self.stream['ext'] + " " + self.stream['quality'], 134 | int(size / (1024 ** 2))) 135 | self.songdata = "%s; %s; %s Mb" % songdata 136 | screen.writestatus(self.songdata) 137 | 138 | self._launch_player() 139 | 140 | if config.HISTORY.get: 141 | history.add(self.song) 142 | 143 | def _launch_player(self): 144 | """ Launch player application. """ 145 | pass 146 | 147 | def send_metadata_mpris(self): 148 | metadata = util._get_metadata(self.song.title) if config.LOOKUP_METADATA.get else None 149 | 150 | if metadata is None: 151 | arturl = "https://i.ytimg.com/vi/%s/default.jpg" % self.song.ytid 152 | metadata = (self.song.ytid, self.song.title, self.song.length, 153 | arturl, [''], '') 154 | else: 155 | arturl = metadata['album_art_url'] 156 | metadata = (self.song.ytid, metadata['track_title'], 157 | self.song.length, arturl, 158 | [metadata['artist']], metadata['album']) 159 | 160 | if g.mprisctl: 161 | g.mprisctl.send(('metadata', metadata)) 162 | 163 | def _playback_progress(self, idx, allsongs, repeat=False): 164 | """ Generate string to show selected tracks, indicate current track. """ 165 | # pylint: disable=R0914 166 | # too many local variables 167 | cw = util.getxy().width 168 | out = " %s%-XXs%s%s\n".replace("XX", str(cw - 9)) 169 | out = out % (c.ul, "Title", "Time", c.w) 170 | multi = len(allsongs) > 1 171 | 172 | for n, song in enumerate(allsongs): 173 | length_orig = util.fmt_time(song.length) 174 | length = " " * (8 - len(length_orig)) + length_orig 175 | i = util.uea_pad(cw - 14, song.title), length, length_orig 176 | fmt = (c.w, " ", c.b, i[0], c.w, c.y, i[1], c.w) 177 | 178 | if n == idx: 179 | fmt = (c.y, "> ", c.p, i[0], c.w, c.p, i[1], c.w) 180 | cur = i 181 | 182 | out += "%s%s%s%s%s %s%s%s\n" % fmt 183 | 184 | out += "\n" * (3 - len(allsongs)) 185 | pos = 8 * " ", c.y, idx + 1, c.w, c.y, len(allsongs), c.w 186 | playing = "{}{}{}{} of {}{}{}\n\n".format(*pos) if multi else "\n\n" 187 | keys = self._help(short=(not multi and not repeat)) 188 | out = out if multi else content.generate_songlist_display(song=allsongs[0]) 189 | 190 | if config.SHOW_PLAYER_KEYS.get and keys is not None: 191 | out += "\n" + keys 192 | 193 | else: 194 | playing = "{}{}{}{} of {}{}{}\n".format(*pos) if multi else "\n" 195 | out += "\n" + " " * (cw - 19) if multi else "" 196 | 197 | fmt = playing, c.r, cur[0].strip()[:cw - 19], c.w, c.w, cur[2], c.w 198 | out += "%s %s%s%s %s[%s]%s" % fmt 199 | out += " REPEAT MODE" if repeat else "" 200 | return out 201 | 202 | def make_status_line(self, elapsed_s, prefix, songlength=0, volume=None): 203 | self._line = self._make_status_line(elapsed_s, prefix, songlength, 204 | volume=volume) 205 | 206 | if self._line != self._last_displayed_line: 207 | screen.writestatus(self._line) 208 | self._last_displayed_line = self._line 209 | 210 | def _make_status_line(self, elapsed_s, prefix, songlength=0, volume=None): 211 | """ Format progress line output. """ 212 | # pylint: disable=R0914 213 | 214 | display_s = elapsed_s 215 | display_h = display_m = 0 216 | 217 | if elapsed_s >= 60: 218 | display_m = display_s // 60 219 | display_s %= 60 220 | 221 | if display_m >= 60: 222 | display_h = display_m // 60 223 | display_m %= 60 224 | 225 | pct = (float(elapsed_s) / songlength * 100) if songlength else 0 226 | 227 | status_line = "%02i:%02i:%02i %s" % ( 228 | display_h, display_m, display_s, 229 | ("[%.0f%%]" % pct).ljust(6) 230 | ) 231 | 232 | if volume: 233 | vol_suffix = " vol: %d%%" % volume 234 | 235 | else: 236 | vol_suffix = "" 237 | 238 | cw = util.getxy().width 239 | prog_bar_size = cw - len(prefix) - len(status_line) - len(vol_suffix) - 7 240 | progress = int(math.ceil(pct / 100 * prog_bar_size)) 241 | status_line += " [%s]" % ("=" * (progress - 1) + 242 | ">").ljust(prog_bar_size, ' ') 243 | return prefix + status_line + vol_suffix 244 | 245 | 246 | class CmdPlayer(BasePlayer): 247 | 248 | def next(self): 249 | if g.scrobble: 250 | lastfm.scrobble_track(g.artist, g.album, 251 | g.scrobble_queue[self.song_no]) 252 | self.terminate_process() 253 | self.song_no += 1 254 | 255 | def previous(self): 256 | if g.scrobble: 257 | lastfm.scrobble_track(g.artist, g.album, 258 | g.scrobble_queue[self.song_no]) 259 | self.terminate_process() 260 | self.song_no -= 1 261 | 262 | def stop(self): 263 | self.terminate_process() 264 | self.song_no = len(self.songlist) 265 | 266 | def terminate_process(self): 267 | self.p.terminate() 268 | # If using shell=True or the player 269 | # requires some obscure way of killing the process 270 | # the child class can define this function 271 | 272 | def _generate_real_playerargs(self): 273 | pass 274 | 275 | def clean_up(self): 276 | pass 277 | 278 | def launch_player(self, cmd): 279 | pass 280 | 281 | def _help(self, short=True): 282 | pass 283 | 284 | def _launch_player(self): 285 | """ Launch player application. """ 286 | 287 | cmd = self._generate_real_playerargs() 288 | 289 | util.dbg("playing %s", self.song.title) 290 | util.dbg("calling %s", " ".join(cmd)) 291 | 292 | # Fix UnicodeEncodeError when title has characters 293 | # not supported by encoding 294 | cmd = [util.xenc(i) for i in cmd] 295 | 296 | self.send_metadata_mpris() 297 | try: 298 | self.launch_player(cmd) 299 | 300 | except OSError: 301 | g.message = util.F('no player') % config.PLAYER.get 302 | return None 303 | 304 | finally: 305 | if g.mprisctl: 306 | g.mprisctl.send(('stop', True)) 307 | 308 | if self.p and self.p.poll() is None: 309 | self.p.terminate() # make sure to kill mplayer if mpsyt crashes 310 | 311 | self.clean_up() 312 | 313 | 314 | def stream_details(song, failcount=0, override=False, softrepeat=False): 315 | """Fetch stream details for a song.""" 316 | # don't interrupt preloading: 317 | while song.ytid in g.preloading: 318 | screen.writestatus("fetching item..") 319 | time.sleep(0.1) 320 | 321 | try: 322 | streams.get(song, force=failcount, callback=screen.writestatus) 323 | 324 | except (IOError, URLError, HTTPError, socket.timeout) as e: 325 | util.dbg("--ioerror in stream_details call to streams.get %s", str(e)) 326 | 327 | if "Youtube says" in str(e): 328 | g.message = util.F('cant get track') % (song.title + " " + str(e)) 329 | return 330 | 331 | elif failcount < g.max_retries: 332 | util.dbg("--ioerror - trying next stream") 333 | failcount += 1 334 | return stream_details(song, failcount=failcount, override=override, softrepeat=softrepeat) 335 | 336 | elif "pafy" in str(e): 337 | g.message = str(e) + " - " + song.ytid 338 | return 339 | 340 | except ValueError: 341 | g.message = util.F('track unresolved') 342 | util.dbg("----valueerror in stream_details call to streams.get") 343 | return 344 | 345 | if failcount == g.max_retries: 346 | raise TypeError() 347 | 348 | try: 349 | video = ((config.SHOW_VIDEO.get and override != "audio") or 350 | (override in ("fullscreen", "window", "forcevid"))) 351 | m4a = "mplayer" not in config.PLAYER.get 352 | cached = g.streams[song.ytid] 353 | stream = streams.select(cached, q=failcount, audio=(not video), m4a_ok=m4a) 354 | 355 | # handle no audio stream available, or m4a with mplayer 356 | # by switching to video stream and suppressing video output. 357 | if (not stream or failcount) and not video: 358 | util.dbg(c.r + "no audio or mplayer m4a, using video stream" + c.w) 359 | override = "a-v" 360 | video = True 361 | stream = streams.select(cached, q=failcount, audio=False, maxres=1600) 362 | 363 | if not stream: 364 | raise IOError("No streams available") 365 | 366 | return (video, stream, override) 367 | 368 | except (HTTPError) as e: 369 | 370 | # Fix for invalid streams (gh-65) 371 | util.dbg("----htterror in stream_details call to gen_real_args %s", str(e)) 372 | if failcount < g.max_retries: 373 | failcount += 1 374 | return stream_details(song, failcount=failcount, 375 | override=override, softrepeat=softrepeat) 376 | else: 377 | g.message = str(e) 378 | return 379 | 380 | except IOError as e: 381 | # this may be cause by attempting to play a https stream with 382 | # mplayer 383 | # ==== 384 | errmsg = e.message if hasattr(e, "message") else str(e) 385 | g.message = c.r + str(errmsg) + c.w 386 | return 387 | --------------------------------------------------------------------------------