You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
604 lines
25 KiB
604 lines
25 KiB
#!/usr/bin/env python3 |
|
# -*- coding: utf-8 -*- |
|
|
|
''' |
|
Name: youplay.py |
|
Description: search, play and save audio files from Youtube |
|
Usage: start it with: './youplay.py --help' |
|
Author: Ralf Hersel - ralf.hersel@gmx.net |
|
Licence: GPL3 |
|
GUI-Library: GTK3 |
|
Repository: https://codeberg.org/ralfhersel/youplay |
|
More infos: see constants and readme.md |
|
''' |
|
|
|
# === Todo ===================================================================== |
|
|
|
''' |
|
- create Flatpak |
|
- delete downloaded songs |
|
- graphical buttons for media controls |
|
- update to gtk4 |
|
- option to stream instead of download (see ZIM 'Musik abspielen' for details) |
|
''' |
|
|
|
# === Dependencies ============================================================= |
|
|
|
''' |
|
python3 - default on most GNU/Linux-Distros # because it is Python :) |
|
yt-dlp - python3 -m pip install -U yt-dlp # https://github.com/yt-dlp/yt-dlp |
|
mpv - sudo apt install mpv # stand-alone player |
|
python-mpv - pip3 install --upgrade python-mpv # python bindings for the integrated MPV player |
|
libmpv1 - mpv library |
|
ffmpeg - sudo apt install ffmpeg # required to calculate mp3 duration |
|
''' |
|
|
|
# === Libraries ================================================================ |
|
|
|
import subprocess # for calling external commands |
|
import os |
|
import gi # for GTK Gui |
|
gi.require_version('Gtk', '3.0') |
|
from gi.repository import Gtk, Gdk, GLib |
|
import time # for H:M:S convertion |
|
import sys |
|
import mpv # for the music play in GUI mode |
|
import glob # for directory patterned file list (mp3) |
|
import string # for song name sanitizing |
|
|
|
|
|
# === Constants ================================================================ |
|
|
|
PROG_NAME = 'YouPlay' |
|
VERSION = '0.37' |
|
LAST_UPDATE = '2022-05-31' |
|
LICENCE = 'GPL3' |
|
AUTHOR = 'Ralf Hersel' |
|
WIN_WIDTH = 700 |
|
WIN_HEIGHT = 373 |
|
LINE = '-' * 80 |
|
NUMBER_OF_SONGS = 10 |
|
MAX_TITLE_LENGHT = 65 |
|
MUSIC_FOLDER = '/youplay/' # subdirectory for downloaded songs |
|
MUSIC_PATH = '' # will be autom. set with full path for downloaded songs |
|
|
|
HELP = '''YouPlay Help: |
|
================================================================================ |
|
-h --help show command line parameter |
|
-v --version show version information |
|
-a --about show general infos about the application |
|
-g --gui start YouPlay in GUI-mode (otherwise CLI-mode) |
|
-s --songs list already downloaded songs |
|
[a song title] song title, that you want to search for''' |
|
|
|
WELCOME = '''Welcome to {} |
|
================================================================================ |
|
'''.format(PROG_NAME) |
|
|
|
CONTROLS = '''Player controls: |
|
q quit |
|
space pause/continue |
|
right fast forward |
|
left fast backward''' |
|
|
|
THANKS = '''==================================================================== |
|
Thank you for using {}!'''.format(PROG_NAME) |
|
|
|
ABOUT = '''Reclaim Freedom |
|
Suck it from Youtube |
|
Fight against digital handcuffs |
|
|
|
Version : {} |
|
Update : {} |
|
Licence : {} |
|
Author : {} |
|
Contact : @ralfhersel:fsfe.org |
|
|
|
Stay free, play music, donate artists'''.format(VERSION, LAST_UPDATE, LICENCE, AUTHOR) |
|
|
|
|
|
# === Features ================================================================= |
|
|
|
def get_songlist(song): # get songlist from Youtube |
|
song_dict = {} |
|
if len(song) > 3: |
|
print('Retrieving song list from Youtube:', song, '\nPlease wait ', end='') |
|
command = 'yt-dlp --get-title --get-duration "ytsearch{}:{}"'.format(str(NUMBER_OF_SONGS), song) |
|
p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) |
|
wait_until_process_finished(p) |
|
stdout = p.stdout |
|
count = 1 # file line counter |
|
number = 1 # list line counter |
|
for line in stdout: |
|
line = line.decode() |
|
striped_line = line.strip() |
|
if count % 2 == 0: # even = duration |
|
duration = striped_line |
|
if duration != '0': # no stream |
|
song_dict[str(number)] = [title, duration] |
|
number += 1 |
|
else: # not even = titel |
|
title = striped_line[:MAX_TITLE_LENGHT].ljust(MAX_TITLE_LENGHT, ' ') # fit title lenght |
|
count += 1 |
|
else: |
|
print('Warning: Song title is too short. Min 5 chars.') |
|
return song_dict |
|
|
|
|
|
def show_list(song_dict): # show the list of songs |
|
print(LINE) |
|
for key, item in song_dict.items(): |
|
number = str(key) |
|
titel = item[0] |
|
duration = item[1] |
|
print(number.ljust(3), titel, '-', duration.rjust(8)) |
|
print(LINE) |
|
keep_asking = True |
|
while keep_asking: |
|
answer = input('\nSong number or RETURN for first song or (q)uit: ') |
|
if answer == 'q': # quit |
|
keep_asking = False |
|
elif len(answer) == 0: # return |
|
answer = '1' # play first song |
|
keep_asking = False |
|
elif not answer.isdigit(): # wrong letter |
|
print('Warning: wrong entry, try again.') |
|
elif int(answer) in range(1, NUMBER_OF_SONGS + 1): # check number |
|
keep_asking = False |
|
else: |
|
print('Warning: wrong number, try again.') |
|
return answer |
|
|
|
|
|
def get_song(song): # Get song from Downloads or Youtube and download it |
|
if not os.path.exists(MUSIC_PATH): # create sub-dir MUSIC_FOLDER if not exists |
|
os.mkdir(MUSIC_PATH) |
|
song = sanitize_song(song) # remove unwanted chars from song title |
|
audio_path = MUSIC_PATH + song + '.mp3' |
|
if os.path.exists(audio_path): # check if mp3-file exists (already downloaded) |
|
return audio_path |
|
else: # file does not exist |
|
print('\nDownloading song from Youtube:', song, |
|
'\nPlease wait until the song is downloaded ', end='') |
|
p = subprocess.Popen(['yt-dlp', '-q', '-f bestaudio', '--max-downloads', |
|
'1', '--yes-playlist', '--default-search', 'ytsearch', song, |
|
'--audio-format', 'mp3', '-o', audio_path, '--extract-audio', |
|
'--audio-quality', '0']) |
|
wait_until_process_finished(p) |
|
return audio_path |
|
|
|
|
|
def play_song(audio_path): # play song from CLI |
|
print(CONTROLS) |
|
p = subprocess.Popen(['mpv', audio_path]) # play the song with MPV |
|
p.wait() |
|
p.terminate() |
|
|
|
|
|
def show_downloaded_songs(): # show list of downloaded songs |
|
files=glob.glob(MUSIC_PATH + '*.mp3') |
|
try: filename = files[0] |
|
except IndexError: |
|
print('Warning: No songs in music folder:', MUSIC_PATH) |
|
answer = 'q' |
|
return answer |
|
number = 0 |
|
song_dict = {} |
|
for filepath in files: |
|
title = os.path.basename(filepath) # get filename from path |
|
title = title[:-4] # cut '.mp3' from filename |
|
title = title[:MAX_TITLE_LENGHT].ljust(MAX_TITLE_LENGHT, ' ') # cut and fit title length |
|
number += 1 |
|
duration = get_mp3_duration(filepath) |
|
song_dict[str(number)] = [title, duration] |
|
keep_asking = True |
|
while keep_asking: |
|
print('Downloaded songs') |
|
print(LINE) |
|
for key, item in song_dict.items(): |
|
number = str(key) |
|
title = item[0] |
|
duration = item[1] |
|
print(number.ljust(3), title, '-', duration) |
|
print(LINE) |
|
answer = input('\nSong number or RETURN for first song or (q)uit: ') |
|
if answer == 'q': # quit |
|
return answer |
|
elif len(answer) == 0: # return |
|
answer = '1' # play first song |
|
song = song_dict[answer][0] # get song name |
|
audio_path = get_song(song) # download song |
|
play_song(audio_path) # play song |
|
elif not answer.isdigit(): # wrong letter |
|
print('Warning: wrong entry, try again.') |
|
elif int(answer) in range(1, len(song_dict)+1): # check number |
|
song = song_dict[answer][0] # get song name |
|
audio_path = get_song(song) # download song |
|
play_song(audio_path) # play song |
|
else: |
|
print('Warning: wrong number, try again.') |
|
return answer |
|
|
|
|
|
# === Misc ===================================================================== |
|
|
|
def sanitize_song(song): # remove special chars from songname |
|
song = song.strip() # remove leading/trailing whitespace |
|
song = song.replace('/', '-') # avoid that song name creates sub-directories |
|
song = song.replace('\\', '-') # avoid other bullshit |
|
return song |
|
|
|
|
|
def wait_until_process_finished(p): # do things until process finished |
|
event_id = GLib.timeout_add_seconds(1, cli_progress, p) |
|
while p.poll() is None: |
|
Gtk.main_iteration_do(True) |
|
GLib.source_remove(event_id) |
|
print() # add break to finish breakless print |
|
|
|
|
|
def cli_progress(p): # show progress |
|
print('.', end='') # CLI progress bar |
|
sys.stdout.flush() # update display |
|
return True |
|
|
|
|
|
def get_mp3_duration(filepath): # calculate duration of mp3 file |
|
result = subprocess.run(["ffprobe", "-v", "error", "-show_entries", |
|
"format=duration", "-of", |
|
"default=noprint_wrappers=1:nokey=1", filepath], |
|
stdout=subprocess.PIPE, |
|
stderr=subprocess.STDOUT) |
|
duration = float(result.stdout) |
|
sec_time_object = time.gmtime(duration) # convert to time object |
|
hms = time.strftime("%H:%M:%S",sec_time_object) # convert seconds to hour:minute:second |
|
return hms |
|
|
|
|
|
def get_music_dir(): # get standard music directory |
|
result = subprocess.run(['xdg-user-dir', 'MUSIC'], stdout=subprocess.PIPE, text=True) |
|
music_path = result.stdout.strip() |
|
youplay_music_path = music_path + MUSIC_FOLDER |
|
return youplay_music_path |
|
|
|
|
|
#~ === GUI ===================================================================== |
|
|
|
class Gui: |
|
def __init__(self): |
|
# === Variables ======================================================== |
|
|
|
self.player = mpv.MPV(ytdl=True, input_default_bindings=True, input_vo_keyboard=True) # initialize mpv internal player |
|
|
|
@self.player.property_observer('time-pos') |
|
def time_observer(_name, value): # get playback timer |
|
if value is not None: # avoid Type Error when started |
|
sec = int(value) # get seconds from flaot |
|
sec_time_object = time.gmtime(sec) # convert to time object |
|
hms = time.strftime("%H:%M:%S",sec_time_object) # convert seconds to hour:minute:second |
|
title_time_text = self.status_text.ljust(int(WIN_WIDTH/6)) + hms # calculate magic factor to align playtime |
|
self.show_status_text(title_time_text) # update status bar |
|
|
|
# === Widgets ========================================================== |
|
|
|
# Window |
|
# Box (vertical) |
|
# Entry |
|
# ScrolledWindow |
|
# TreeView |
|
# TreeViewColumn |
|
# Box (horizontal) |
|
# Buttons |
|
# StatusBar |
|
# ProgressBar |
|
|
|
self.window = Gtk.Window() # Window |
|
self.window.set_title(PROG_NAME + ' it from Youtube') |
|
self.window.set_default_icon_from_file('youplay.svg') |
|
self.window.set_default_size(WIN_WIDTH, WIN_HEIGHT) # width, height |
|
self.window.set_position(Gtk.WindowPosition.CENTER) # center window |
|
|
|
self.window.connect('delete_event', self.on_window_delete) # Window red-cross clicked |
|
|
|
self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Vertical Box |
|
self.window.add(self.main_box) |
|
|
|
self.entry = Gtk.Entry() # Entry |
|
self.entry.connect('activate', self.on_entry_return) # Return pressed |
|
self.entry.set_text('Enter song name') |
|
self.main_box.pack_start(self.entry, False, False, 0) |
|
|
|
self.liststore = Gtk.ListStore(str, str, str) # Treeview and Liststore |
|
self.treeview = Gtk.TreeView(model=self.liststore) |
|
self.treeview.connect('button-press-event', self.on_treeview_clicked) # Treeview item clicked |
|
for i, column_title in enumerate(['Nr', 'Title', 'Duration']): |
|
renderer = Gtk.CellRendererText() |
|
column = Gtk.TreeViewColumn(column_title, renderer, text=i) |
|
if i == 0: # Nr |
|
column.set_alignment(0.5) # center Nr heading |
|
renderer.set_property('xalign', 0.5) # center Nr content |
|
if i == 2: # Duration |
|
column.set_alignment(1) # right align column heading |
|
renderer.set_property('xalign', 1) # right align column content |
|
self.treeview.append_column(column) |
|
|
|
self.scrollable_treelist = Gtk.ScrolledWindow() |
|
self.scrollable_treelist.add(self.treeview) |
|
self.main_box.pack_start(self.scrollable_treelist, True, True, 0) |
|
|
|
self.button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) # Horizontal Button Box |
|
|
|
self.button_play = Gtk.Button(label='Play') # PLAY button |
|
self.button_play.connect('clicked', self.on_button_play_clicked) |
|
self.button_box.pack_start(self.button_play, True, True, 0) |
|
|
|
self.button_pause = Gtk.Button(label='Pause') # PAUSE button |
|
self.button_pause.connect('clicked', self.on_button_pause_clicked) |
|
self.button_box.pack_start(self.button_pause, True, True, 0) |
|
|
|
self.button_back = Gtk.Button(label='Back') # BACK button |
|
self.button_back.connect('clicked', self.on_button_back_clicked) |
|
self.button_box.pack_start(self.button_back, True, True, 0) |
|
|
|
self.button_forward = Gtk.Button(label='Forward') # FORWARD button |
|
self.button_forward.connect('clicked', self.on_button_forward_clicked) |
|
self.button_box.pack_start(self.button_forward, True, True, 0) |
|
|
|
self.button_songs = Gtk.Button(label='Songs') # SONGS button |
|
self.button_songs.connect('clicked', self.on_button_songs_clicked) |
|
self.button_box.pack_start(self.button_songs, True, True, 0) |
|
|
|
self.button_about = Gtk.Button(label='About') # ABOUT button |
|
self.button_about.connect('clicked', self.on_button_about_clicked) |
|
self.button_box.pack_start(self.button_about, True, True, 0) |
|
|
|
self.button_quit = Gtk.Button(label='Quit') # QUIT button |
|
self.button_quit.connect('clicked', self.on_button_quit_clicked) |
|
self.button_box.pack_start(self.button_quit, True, True, 0) |
|
|
|
self.main_box.add(self.button_box) |
|
|
|
self.status_bar = Gtk.Statusbar() |
|
self.main_box.add(self.status_bar) |
|
|
|
self.progressbar = Gtk.ProgressBar() |
|
self.progressbar.set_pulse_step(0.2) |
|
self.timeout_id = GLib.timeout_add(200, self.on_timeout, None) # progress bar speed |
|
self.main_box.add(self.progressbar) |
|
|
|
self.window.show_all() # show GUI |
|
self.progressbar.hide() |
|
|
|
|
|
# === Event Handler ============================================================ |
|
|
|
def on_window_delete(self, widget, event, data=None): # close the window by red cross |
|
self.player.stop() |
|
del self.player |
|
Gtk.main_quit() |
|
|
|
|
|
def on_entry_return(self, widget): # Return pressed in entry field |
|
label = self.button_play.get_label() # Enable new search when song is playing |
|
if label == 'Stop': |
|
self.player.stop() |
|
self.show_status_text('Stopped playing') |
|
|
|
self.progressbar.hide() |
|
self.button_play.set_label('Play') |
|
self.progressbar.show() |
|
self.show_status_text('Retrieving song list from Youtube ... please wait') |
|
song = widget.get_text() |
|
song_dict = get_songlist(song) |
|
song_list = song_dict.items() # convert dict to list |
|
if len(song_list) == 0: # nothing found |
|
self.show_status_text('Nothing found on Youtube, try another search.') |
|
else: |
|
self.liststore.clear() |
|
for key, item in song_dict.items(): |
|
number = key |
|
title = item[0] |
|
duration = item[1] |
|
self.liststore.append([number, title, duration]) |
|
self.show_status_text('Select a song and click Play') |
|
self.progressbar.hide() |
|
|
|
|
|
def on_treeview_clicked(self, widget, event): # click on treeview item |
|
if event.type == Gdk.EventType._2BUTTON_PRESS: # but only doubleclick |
|
self.player.stop() # stop already playing song |
|
selected = self.get_tree_selection(self.treeview) |
|
self.do_play(selected) |
|
|
|
|
|
def on_button_play_clicked(self, widget): # PLAY/STOP clicked |
|
label = widget.get_label() |
|
if label == 'Play': |
|
selected = self.get_tree_selection(self.treeview) |
|
self.do_play(selected) |
|
if label == 'Stop': |
|
widget.set_label('Play') |
|
self.player.stop() |
|
self.show_status_text('Stopped playing') |
|
|
|
|
|
def on_button_pause_clicked(self, widget): # PAUSE/CONTINUE clicked |
|
label = widget.get_label() |
|
if label == 'Pause': |
|
widget.set_label('Continue') |
|
self.player.command('cycle', 'pause') |
|
if label == 'Continue': |
|
widget.set_label('Pause') |
|
self.player.command('cycle', 'pause') |
|
|
|
|
|
def on_button_back_clicked(self, widget): # BACK clicked |
|
self.player.command('seek', '-5') |
|
|
|
|
|
def on_button_forward_clicked(self, widget): # FORWARD clicked |
|
self.player.command('seek', '5') |
|
|
|
|
|
def on_button_songs_clicked(self, widget): # show list of downloaded songs |
|
files=glob.glob(MUSIC_PATH + '*.mp3') |
|
try: filename = files[0] |
|
except IndexError: |
|
self.show_status_text('No songs in music folder: ' + MUSIC_PATH) |
|
return |
|
|
|
self.liststore.clear() |
|
number = 0 |
|
for filepath in files: |
|
title = os.path.basename(filepath) # get filename from path |
|
title = title[:-4] # cut '.mp3' from filename |
|
title = title[:MAX_TITLE_LENGHT].ljust(MAX_TITLE_LENGHT, ' ') # cut and fit title length |
|
number += 1 |
|
duration = get_mp3_duration(filepath) |
|
self.liststore.append([str(number), title, duration]) |
|
self.show_status_text('Select a song and click Play') |
|
|
|
|
|
def on_button_about_clicked(self, widget): # ABOUT clicked |
|
self.show_info_dialog(PROG_NAME, ABOUT) |
|
|
|
|
|
def on_button_quit_clicked(self, widget): # QUIT clicked |
|
self.player.stop() |
|
del self.player |
|
Gtk.main_quit() |
|
|
|
|
|
def on_timeout(self, user_data): # update value on progress bar |
|
self.progressbar.pulse() |
|
return True |
|
|
|
|
|
# === Methods ================================================================== |
|
|
|
def do_play(self, selected): # play song from GUI |
|
if selected == None: |
|
self.show_status_text('Nothing selected') |
|
else: |
|
self.progressbar.show() |
|
self.show_status_text('Downloading song from Youtube ... please wait') |
|
song = selected[1] |
|
audio_path = get_song(song) |
|
self.progressbar.hide() |
|
self.status_text = selected[1] # status bar will be updated by mpv time_observer |
|
self.button_play.set_label('Stop') |
|
self.player.play(audio_path) |
|
|
|
|
|
def get_tree_selection(self, tree): # get selected tree item |
|
selection = tree.get_selection() |
|
model, treeiter = selection.get_selected() |
|
if treeiter != None: |
|
result = model[treeiter] |
|
else: |
|
result = None |
|
return result |
|
|
|
|
|
def show_status_text(self, text): # show status text |
|
self.status_bar.push(0, text) |
|
while Gtk.events_pending(): |
|
Gtk.main_iteration_do(True) # wait until statusbar is refreshed |
|
|
|
|
|
def show_info_dialog(self, title, text): # Info dialog |
|
dialog = Gtk.MessageDialog(message_type=Gtk.MessageType.INFO, |
|
buttons=Gtk.ButtonsType.OK, text=text) |
|
dialog.set_title(title) |
|
dialog.run() |
|
dialog.destroy() |
|
|
|
|
|
# === Main ===================================================================== |
|
|
|
def main(args): |
|
print(WELCOME) |
|
answer = ' '.join(args[1:]) # get cli input and concat args |
|
answer = answer.lower() # convert to lowercase |
|
song_dict = {} |
|
global MUSIC_PATH # enable changing the global variable |
|
MUSIC_PATH = get_music_dir() # get standard XDG music path |
|
question = 'Enter (q)uit, (l)ist, (s)ongs or song title: ' |
|
finish = False |
|
while finish == False: # command loop |
|
if answer == '-h' or answer == '--help': # show help |
|
print(HELP) |
|
finish = True |
|
elif answer == '-v' or answer == '--version': # show version |
|
print(PROG_NAME, '- Version:', VERSION, '-',LAST_UPDATE, '-',LICENCE, '-',AUTHOR, '\n') |
|
finish = True |
|
elif answer == '-a' or answer == '--about': # show about infos |
|
print(ABOUT) |
|
finish = True |
|
elif answer == '-g' or answer == '--gui': # show GUI |
|
gui = Gui() |
|
Gtk.main() |
|
finish = True # leave the loop |
|
elif answer == '': |
|
answer = input(question) # no song from command line |
|
if answer == '': |
|
print('Error: wrong entry') |
|
finish = True |
|
elif answer == 'q': |
|
finish = True |
|
elif answer == 's': |
|
show_downloaded_songs() |
|
answer = input(question) |
|
elif answer == 'l': |
|
print('Warning: there are no search results') |
|
answer = input(question) |
|
elif answer[0] != '-' and answer[:2] != '--': # song title entered but no param |
|
song = answer |
|
song_dict = get_songlist(song) |
|
if len(song_dict) > 0: # list not empty |
|
answer = show_list(song_dict) |
|
else: |
|
print('Warning: nothing found') |
|
answer = input(question) |
|
else: |
|
print('Warning: invalid input') |
|
finish = True |
|
elif answer == 'q': |
|
finish = True |
|
elif answer == 's' or answer == '-s' or answer == '--songs': # show list of downloaded songs |
|
show_downloaded_songs() |
|
answer = input(question) |
|
elif answer == 'l': |
|
if len(song_dict) > 0: # list not empty |
|
answer = show_list(song_dict) |
|
else: |
|
print('Warning: There is no search result') |
|
answer = input(question) |
|
elif answer.isnumeric(): # song number selected |
|
if len(song_dict) == 0: # nothing found |
|
print('Warning: Youtube found nothing') |
|
answer = input(question) |
|
else: |
|
song = song_dict[answer][0] # get song name |
|
audio_path = get_song(song) # download song |
|
play_song(audio_path) # play song |
|
answer = input(question) |
|
elif answer[0] != '-' and answer[:2] != '--': # expect song title from cli |
|
song = answer |
|
song_dict = get_songlist(song) |
|
if len(song_dict) > 0: # list not empty |
|
answer = show_list(song_dict) |
|
else: |
|
print('Warning: nothing found') |
|
answer = input(question) |
|
else: |
|
print('Error: invalid input') |
|
finish = True |
|
|
|
print(THANKS) |
|
return 0 |
|
|
|
|
|
if __name__ == '__main__': |
|
import sys |
|
sys.exit(main(sys.argv))
|
|
|