Search, download and play music from YouTube.
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

#!/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))