Commit 3dcada4a authored by arnaudv6's avatar arnaudv6
Browse files

lizzy went through quite an overhaul, in a single huge MR (ahem.). See...

lizzy went through quite an overhaul, in a single huge MR (ahem.). See discussion attached for most relevant changes
parent 831d82d6
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (C) 2017-2020 Arnaud VALLETTE d'OSIA, aka Arnaudv6
#
......@@ -15,71 +16,47 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# pylint: disable=too-many-lines
import argparse
import configparser
import curses
import itertools
import json
import locale
import os
import pickle
import signal
import sys
import tempfile
import time
from collections import ChainMap
from operator import itemgetter
from shutil import which
from subprocess import run
from tempfile import gettempdir
from time import strftime
from unicodedata import normalize, combining
from urllib.request import urlopen
from urllib.error import URLError
SCRIPT_REVISION = '20200209'
HELP = """Search your music (videos...) by directories/files names.
Your playlist gets fed to mpv.
usage: \tlizzy [--profile=NAME] [PLAYLIST]
On first run, you might want to create the index: press ^U.
--profile=NAME
Use profile NAME for this session. For 'why?' and 'how?', see:
https://gitlab.com/Arnaudv6/lizzy#usage
SCRIPT_REVISION = '20200512'
PLAYLIST
Load PLAYLIST. (discard last session's for this profile).
PLAYLIST is a UTF8 plain text file with one entry per line.
Entries can be URLs and paths (A la M3U8), or even raw names.
Relative paths are relative to musics library dir (define a
profile if you need relative paths from elsewhere).
lizzy will propose searching raw names words on youtube-dl.
Resident files stored in XDG_DATA_HOME (i.e. '~/.local/share/lizzy').
The rest is all in tmpfs (i.e. '/tmp/lizzy_player').
"""
class DownloadedNothing(Exception):
""" raised by fetch_from_youtube() if it downloaded nothing """
class TerminalResize(Exception):
# Exception raised when curses says the terminal got resized
pass # escape loops & functions, continue where exception is catched
class YoutubeDlDumbLogger(object):
""" avoid default behavior so no printed message fuzzes out tui """
""" raised by read_keyboard() on terminal-resized signal from curse """
def debug(self, msg):
pass
def warning(self, msg):
pass
def error(self, msg):
pass
# Escape loops & functions, continue where exception is caught
settings = {
'check_updates': False,
'stdscr': None,
'shuffle_mode': False,
'play_all_mode': False,
'library_is_slash': True,
'next_action': None, # select, save_and_quit or play
'music_db_list': [], # Is actually a list of tuples
'ytdownloaded_files_list': [], # Is actually a list of tuples
'filtered_list': [], # Is actually a list of tuples
......@@ -93,28 +70,27 @@ settings = {
'pane2_selected_item': 0,
'pane1_top_item': 0,
'pane2_top_item': 0,
'print_delay': 3,
'search_terms': '',
'xdg_data_home': '',
'profile_config_file': '',
'xdg_config_dir': os.path.join(
os.getenv('XDG_CONFIG_HOME', default=os.path.expanduser('~/.config')),
'lizzy',
'',
),
'music_db_file': '',
'session_file': '', # Can differ from 'user_specified_playlist'
'temp_dir': os.path.join(gettempdir(), 'lizzy_player', ''),
'temp_dir': os.path.join(tempfile.gettempdir(), 'lizzy_player', ''),
'dl_temp_dir': '',
'user_musics_path': '',
'mpv_command': '',
'profile': 'DEFAULT',
'banned_exts': ('.jpg', '.jpeg', '.png', '.txt', '.nfo', '.ini', '.db'),
} # banned_exts is a list of _smallcase_ strings
settings['xdg_data_home'] = os.path.join(
os.getenv('XDG_DATA_HOME', default=os.path.expanduser('~/.local/share')),
'lizzy',
'',
)
settings['profile_config_file'] = os.path.join(settings['xdg_data_home'], 'config.ini')
'mpv_command': [],
'profile': '',
}
settings['dl_temp_dir'] = os.path.join(settings['temp_dir'], 'downloads')
def get_music_xdg_folder():
""" try and return users music path based on XDG_USER_DIRS.
""" return user its music path based on XDG_USER_DIRS.
pyxdg module is no perfect match for the task. """
xdg_dirs_config = os.path.expanduser('~/.config/user-dirs.dirs')
try:
......@@ -123,10 +99,8 @@ def get_music_xdg_folder():
if line.startswith('XDG_MUSIC_DIR'):
line = line.partition('=')[2].strip()
return line.strip('"').replace('$HOME', '~')
except KeyboardInterrupt:
raise
except OSError: # replacing IOError from Python 3.3
pass # file not readable or non-existent...
except OSError:
pass # Non-existent file or not readable...
return '~/Music/'
......@@ -134,35 +108,38 @@ def read_profile_config():
""" Read settings from config file, fall back to default values """
default_config = {
'backEndCommand': 'mpv --no-video --display-tags=""',
'libraryIsSlash': 'yes',
'libraryIsSlash': 'no',
'userMusicsPath': get_music_xdg_folder(),
'checkUpdates': 'no',
'printWaitDelay': 3,
'skipExtensions': [
'.jpg',
'.jpeg',
'.png',
'.txt',
'.nfo',
'.ini',
'.db',
'.srt',
],
} # due to lizzy history, camelCase INI parameters are assigned to snake_case vars.
#profiles should use default collection
def sanitize_path(dir_path):
""" dir_path comes from user defined profiles or user-dirs.dirs. """
return os.path.join(os.path.normpath(os.path.expanduser(dir_path)), '')
cfg = configparser.ConfigParser(defaults=default_config)
cfg.read(settings['profile_config_file'])
if settings['profile'] not in cfg: # or we could catch configparser.NoSectionError
config_file = os.path.join(settings['xdg_config_dir'], 'config.ini')
cfg.read(config_file) # Python 3.8: walrus operator?
if settings['profile'] not in cfg: # No way to properly try and except that
cfg[settings['profile']] = {}
print_error(f'error: "{settings["profile"]}" profile not found')
print(f' consider editing "{settings["profile_config_file"]}"')
# no need to mention '--help': user was just there.
try:
settings['check_updates'] = cfg[settings['profile']].getboolean('checkUpdates')
except ValueError:
print_error("Non boolean libraryIsSlash value, defaulting to 'no'.")
print_wait(f'consider editing "{config_file}"')
# No need to mention '--help': user was just there.
# Reading and checking mpv_command
settings['mpv_command'] = cfg[settings['profile']].get('backEndCommand').split()
settings['mpv_command'][0] = which(settings['mpv_command'][0])
# python 3.8 will allow for walrus operator here.
if not settings['mpv_command'][0]:
if not settings['mpv_command'][0]: # Python 3.8: walrus operator?
sys.exit('Check that mpv is installed or check backEndCommand config value.')
# Reading and checking library_is_slash
......@@ -173,17 +150,31 @@ def read_profile_config():
except ValueError:
print_error("Non boolean libraryIsSlash value, defaulting to 'yes'.")
# print_delay
try:
settings['print_delay'] = cfg[settings['profile']].getint('printWaitDelay')
except ValueError:
print_error("printWaitDelay must be an int.")
# Smallcase banned_exts tuple
settings['banned_exts'] = tuple(
(
f'.{ext.lower()}'
for ext in cfg[settings['profile']].get('skipExtensions').split()
), # without this coma, when only one extension, it gets exploded to letters.
)
# Reading and checking session files
settings['user_musics_path'] = sanitize_path(
cfg[settings['profile']].get('userMusicsPath')
)
# If music collection is not default-one, use separate index file.
# Per-profile collection: banned_exts may differ though library path be the same.
settings['music_db_file'] = os.path.join(
settings['xdg_data_home'], f'{settings["profile"]}collection.idx'
settings['xdg_config_dir'], f'{settings["profile"]}collection.idx'
)
settings['session_file'] = os.path.join(
settings['xdg_data_home'], f'{settings["profile"]}session.m3u'
settings['xdg_config_dir'], f'{settings["profile"]}session.m3u'
)
......@@ -192,11 +183,11 @@ def read_music_db_file():
try:
with open(settings['music_db_file'], mode='rb') as file:
settings['music_db_list'] = pickle.load(file)
# corrupted pickle files load just fine. Not checking the hash (yet).
except KeyboardInterrupt:
raise
# Corrupted pickle files load just fine. We don't check any hash (yet).
except OSError:
print_error('Could not load music index file. Press ^u in lizzy to create one.')
else:
make_nice_dir_names_list()
def read_startup_playlist():
......@@ -208,10 +199,9 @@ def read_startup_playlist():
settings['playlist'] = [
item for item in stripped if item and not item.startswith('#')
]
except KeyboardInterrupt:
raise
except OSError:
print(f'Could not load startup playlist file: {startup_playlist}')
print_wait(f'Could not load startup playlist file: {startup_playlist}')
settings['undo_playList'] = settings['playlist'] # Initialize '^Z'
def make_nice_dir_names_list():
......@@ -229,20 +219,19 @@ def nice_dir_name(dir_path):
try:
position = dir_path.rindex('/', 0, -1)
return position * '-' + dir_path[position:]
# multiplicating (12x max) a dash is faster than slicing a string of dashes,
# Multiplicating (12x max) a dash is faster than slicing a string of dashes,
# or twice as fast as looking though an table of incrementing dash strings
except ValueError: # rfind() would return -1
except ValueError: # rfind() would return -1, not 0
return dir_path
def convert_to_ascii_lower(string):
""" translate given string to lowercase, basic ASCII (drop accents and all),
Avoid depending on unidecode. Credit: MiniQuark:
https://stackoverflow.com/questions/517923 """
""" translate given string to lowercase, basic ASCII (drop accents and all) """
try:
string.encode('ASCII') # encode() does not work in-place.
return string.lower()
except UnicodeEncodeError:
# Thanks MiniQuark on https://stackoverflow.com/questions/517923
nkfd_form = normalize('NFKD', string)
return ''.join([c for c in nkfd_form if not combining(c)]).lower()
......@@ -250,33 +239,29 @@ def convert_to_ascii_lower(string):
def make_ytdownloaded_files_list():
""" refresh list of temporary downloaded musics. """
def file_to_tupple(file):
return (file, convert_to_ascii_lower(file))
def file_to_tupple(file): # This returns a tupple
return os.path.join(settings['dl_temp_dir'], file), convert_to_ascii_lower(file)
try:
settings['ytdownloaded_files_list'] = [
file_to_tupple(os.path.join(settings['temp_dir'], file))
for file in os.listdir(settings['temp_dir'])
file_to_tupple(file) for file in os.listdir(settings['dl_temp_dir'])
]
except FileNotFoundError:
settings['ytdownloaded_files_list'] = []
def update_music_db_file():
""" recursively list user_musics_path files into music_db_file. """
settings['next_action'] = select
""" recursively list user_musics_path files into music_db_file on ^U. """
curse_wrap_off()
print('\nUpdating database...')
from operator import itemgetter
settings['music_db_list'] = [] # Emptying before appending.
# python 3.5 bundles scandir.walk() its fast implementation, from pypi
errors = False
# Python 3.5+ bundles scandir.walk() its fast implementation, from pypi
for root, dirs, files in os.walk(settings['user_musics_path'], followlinks=True):
for name in dirs:
if any(char in name for char in ('\n', '\t')):
print(f'Erroneous path string, ignoring: {repr(name)}')
errors = True
continue
continue # Normal print(): don't urge user with red warning
name = os.path.join(root, name)
name = os.path.relpath(name, start=settings['user_musics_path'])
name = os.path.join(name, '')
......@@ -284,7 +269,6 @@ def update_music_db_file():
for name in files:
if any(char in name for char in ('\n', '\t')):
print(f'Erroneous path string, ignoring: {repr(name)}')
errors = True
continue
if not name.lower().endswith(settings['banned_exts']):
name = os.path.join(root, name)
......@@ -292,59 +276,63 @@ def update_music_db_file():
settings['music_db_list'].append((name, convert_to_ascii_lower(name)))
settings['music_db_list'].sort(key=itemgetter(1))
if settings['music_db_list']: # musics index list not empty: saving it to disk.
os.makedirs(settings['xdg_data_home'], exist_ok=True)
if settings['music_db_list']: # List is indeed populated: save it to disk.
os.makedirs(settings['xdg_config_dir'], exist_ok=True)
with open(settings['music_db_file'], mode='wb') as file:
pickle.dump(settings['music_db_list'], file)
make_nice_dir_names_list()
print('Updated database.\n')
if errors:
input('press [enter] to continue')
print_wait('Updated database.\n') # In case we printed errors above.
make_filtered_list()
else:
print_error(f"No file to index in {settings['user_musics_path']}\n")
print('Consider editing ~/.local/share/lizzy/config.ini as shown here:')
print('https://gitlab.com/Arnaudv6/lizzy#usage')
input('press [enter] to continue\n')
print_wait('https://gitlab.com/Arnaudv6/lizzy#usage')
curse_wrap_on()
def make_filtered_list(search_terms):
""" update settings['filtered_list'] to match the search_terms. """
def ins_str_at_cur(key):
update_filter(before_cursor() + key + after_cursor())
settings['pane0_cursor'] += len(key)
if settings['search_terms'] == search_terms and settings['filtered_list']:
return
settings['pane1_selected_item'] = 0
if not search_terms.strip(): # search_terms is but spaces, clear them
search_terms = ''
def update_filter(search_terms):
settings['search_terms'] = search_terms
make_filtered_list()
if is_url(search_terms): # support for drag'n'drop ; internet radio streams...
settings['filtered_list'] = [(search_terms, '')]
return
# No need to strip() before split()
search_terms = convert_to_ascii_lower(search_terms).split()
all_musics_list = itertools.chain(
settings['music_db_list'], settings['ytdownloaded_files_list']
)
if not search_terms:
if settings['library_is_slash']:
settings['filtered_list'] = [('/', '/')]
def make_filtered_list():
""" update settings['filtered_list'] matches against settings['search_terms']. """
settings['pane1_selected_item'] = 0
if not settings['search_terms'].strip(): # strip() does not work in place.
settings['search_terms'] = '' # Nothing but spaces, clear them
if is_url(settings['search_terms']): # Local files, internet radios...
settings['filtered_list'] = [(settings['search_terms'], '')]
else:
# No need to strip() before split()
search_terms = convert_to_ascii_lower(settings['search_terms']).split()
all_musics_list = itertools.chain(
settings['music_db_list'], settings['ytdownloaded_files_list']
)
# Not limiting results here: see homepage notes on slow hardware performance.
if not search_terms:
if settings['library_is_slash']:
settings['filtered_list'] = [('/', '/')]
else:
settings['filtered_list'] = list(all_musics_list)
else:
settings['filtered_list'] = list(all_musics_list)
# This (possibly very) long list is OK here. Is not a problem anywhere, yet.
return
settings['filtered_list'] = [
tuple_item
for tuple_item in all_musics_list
if all(word in tuple_item[1] for word in search_terms)
] # Do not limit results here if all_musics_list is no problem above.
settings['filtered_list'].append(SEARCH_YT)
settings['filtered_list'] = [
tuple_item
for tuple_item in all_musics_list
if all(word in tuple_item[1] for word in search_terms)
]
settings['filtered_list'].append(SEARCH_YT)
def get_substring_indexes(substring, string):
""" return positions indices for each occurence of substring in
string. String and substring must be lowercase. """
string. String and substring both are lowercase. """
position = -1
while True:
try:
......@@ -361,54 +349,69 @@ def full_file_path(file_path):
return os.path.join(settings['user_musics_path'], file_path)
def append_selected():
""" append selection to playlist.
Drop folders, keep their contents. """
settings['undo_playList'] = list(settings['playlist'])
if settings['filtered_list'] == [('/', '/')]:
settings['play_all_mode'] = True # Not to do in append_all()
return True
def get_selected():
""" return selected contents, drop folders """
selected = settings['filtered_list'][settings['pane1_selected_item']][0]
if selected == SEARCH_YT[0]:
notification_box(
f"searching {settings['search_terms']} on youtube. Press '^C' to cancel."
)
file = fetch_from_youtube(settings['search_terms'])
if not file: # python 3.8 will allow for walrus operator here.
return False
settings['playlist'].append(file)
make_filtered_list(os.path.basename(os.path.basename(file)))
elif selected.endswith('/'):
# append folder's contents. (youtube entries don't show here. But URLs might!?)
settings['playlist'].extend(
(
os.path.join(settings['user_musics_path'], item)
for item, _ in settings['music_db_list']
if item.startswith(selected) and not item.endswith('/')
)
)
else:
settings['playlist'].append(full_file_path(selected))
return True
if selected == SEARCH_YT[0]:
curse_wrap_off()
settings['search_terms'] = settings['search_terms'].replace('/', ' ')
settings['pane0_cursor'] = len(settings['search_terms'])
print(f"Searching {settings['search_terms']} on youtube. Press '^C' to cancel.")
def append_all():
""" append all search results to playlist. (1000 results max, yet).
Drop folders, keep their contents. """
settings['undo_playList'] = list(settings['playlist'])
settings['playlist'].extend(
(
full_file_path(item)
for item, _ in settings['filtered_list']
if not item.endswith('/') and not item == SEARCH_YT[0]
)
try:
fetch_from_youtube(settings['search_terms'])
except DownloadedNothing:
print_wait('download failed.')
curse_wrap_on()
return []
else:
make_ytdownloaded_files_list()
make_filtered_list()
curse_wrap_on()
return [os.path.join(settings['dl_temp_dir'], settings['search_terms'])]
elif selected.endswith('/') and not is_url(selected): # Some URL end with a slash.
return (
os.path.join(settings['user_musics_path'], item)
for item, _ in settings['music_db_list']
if item.startswith(selected) and not item.endswith('/')
) # We append folder's contents.
else: # Here be URLs, downloaded files as well as local ones.
return [full_file_path(selected)]
def get_all():
""" on ^A, return all search results but folders. """
return (
full_file_path(item)
for item, _ in settings['filtered_list']
if not item.endswith('/') and not item == SEARCH_YT[0]
)
# TODO: check we use notification_box after curse_wrap_on; elsewhere print_wait().
# linked to notification_box() behavior overhaul...
# pane2 (scrollbar) update is feedback to the user... if there were results indeed.
# if not settings['library_is_slash']:
# notification_box('Added all to playlist')
# Playlist updates and notification_box() can't both show without user interaction
# pane2 updates and notification_box() can't both show without user interaction
# None of notification_box() and msg_validation_box() works what to do?
def append_to_playlist(append_intent_source=get_selected, play_if_successful=False):
buffer_list = append_intent_source()
if settings['filtered_list'] == [('/', '/')]:
settings['play_all_mode'] = play_if_successful # Not if we only pressed space
play()
elif buffer_list:
settings['undo_playList'] = list(settings['playlist'])
goto_pane2_footer()
settings['playlist'].extend(buffer_list)
if play_if_successful:
settings['pane2_selected_item'] = settings['playlist_length'] # That is n+1
settings['selected_pane'] = 2 # This makes for a logical behavior.
play()
def is_url(string):
""" return true for local or distant URLs, any protocol.
return false for relative paths, or plain strings. """
......@@ -416,10 +419,9 @@ def is_url(string):
def not_file(item):
if item.startswith(settings['temp_dir']):
if item.startswith(settings['dl_temp_dir']):
return not os.path.isfile(item)
else:
return not is_url(item)
return not is_url(item)
def ytdl_if_not_isfile():
......@@ -428,8 +430,8 @@ def ytdl_if_not_isfile():
if not_file(settings['playlist'][settings['pane2_selected_item']]):
notification_box('File not found. [ENTER] to youtube-dl it')
# Rest of the logic is in pane2_enter()
except IndexError: # i.e. playlist is empty
pass
except IndexError:
pass # Playlist is empty
def notification_box(text):
......@@ -450,16 +452,15 @@ def msg_validation_box(text):
if [Enter] gets pressed. Try to pass the key otherwise. """
notification_box(text)
key = read_keyboard()
# python 3.8 will allow for walrus operator here.
# Python 3.8: walrus operator?
if key == '^M': # Enter
return True
if len(key) == 1:
for byte in reversed(key): # accents: 1 key, multiple bytes.
for byte in reversed(key): # Accents: 1 key, multiple bytes.
curses.unget_wch(byte)
elif '^' not in key and (
key in PANES_KEYS or key in PANE_SPECIFIC_KEYS[settings['selected_pane']]
):
# 'KEY_LEFT', but not 'KEY_F2', nor pasted-text for instance
): # 'KEY_LEFT', but not 'KEY_F2', nor pasted-text for instance
curses.ungetch(vars(curses)[key])
return False
......@@ -472,10 +473,6 @@ def after_cursor():
return settings['search_terms'][settings['pane0_cursor'] :]
def ins_str_at_cur(key):
make_filtered_list(before_cursor() + key + after_cursor())
# Context independent keys
def panes_tab():
settings['selected_pane'] = (settings['selected_pane'] + 1) % 3
......@@ -485,13 +482,17 @@ def panes_btab():
settings['selected_pane'] = (settings['selected_pane'] - 1) % 3
def panes_ctrl_f():
settings['selected_pane'] = 0
def panes_bspace():
settings['selected_pane'] = 0
if not settings['search_terms']:
draw_library_browser()
settings['pane0_cursor'] = len(settings['search_terms'])
elif settings['pane0_cursor'] > 0:
make_filtered_list(before_cursor()[:-1] + after_cursor())
update_filter(before_cursor()[:-1] + after_cursor())
settings['pane0_cursor'] -= 1
......@@ -507,13 +508,14 @@ def panes_ctrl_r():
def panes_ctrl_s():
file = os.path.join(
settings['temp_dir'], f"{settings['profile']}-{strftime('%Y%m%d-%H%M%S')}.m3u"
settings['temp_dir'],
f"{settings['profile']}-{time.strftime('%Y%m%d-%H%M%S')}.m3u",
)
msg_validation_box(save_playlist_file(file))
def panes_ctrl_u():
settings['next_action'] = update_music_db_file
update_music_db_file()
def panes_ctrl_z():
......@@ -524,21 +526,20 @@ def panes_ctrl_z():
def panes_ctrl_q():
if msg_validation_box('Press [Enter] to quit.'):
settings['next_action'] = save_and_quit
save_and_quit()