Last active
August 20, 2023 14:20
-
-
Save Moonbase59/a62363d61a4dcb596bd27d464368a9a4 to your computer and use it in GitHub Desktop.
ql-playlists – Export Quod Libet saved searches to M3U playlists. Bash script with many options.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# ql-playlists | |
# | |
# Generate radio playlists from Quod Libet saved searches. | |
# Quod Libet must be running before starting this script, | |
# and the library should be up-to-date. | |
# The "Include Saved Search" plugin MUST be activated for this to work! | |
# | |
# This may not be the fastest way to do it (~250 songs/s on my system), | |
# but it's flexible and reliable. | |
# | |
# chmod +x this script and save somewhere handy so it's in the path, | |
# like in ~/bin, ~/.local/bin, or /usr/local/bin. | |
# | |
# 2023-08-08 Matthias C. Hormann a.k.a. Moonbase59 | |
# - v0.3 -- first public release | |
# 2023-08-09 -- v0.4 | |
# - added -u --unique switch (automatically activates sorting) | |
# - checks if "realpath" installed | |
# - moved Quod Libet running check after getopts so help can be read | |
# even if QL doesn't yet run | |
# 2023-08-10 -- v0.5 | |
# - added -p --prefix switch to allow date or custom filename prefixes | |
# - added custom overall prefix (in PREFIX), prepended before other prefixes | |
# - added -v --verbose switch (shows full output file paths) | |
# - better -o --output handling, tilde expansion if -o~ or --output=~ used | |
# - output filepath shown if not in output folder and realpath installed | |
# - better realpath handling: -a --abspath can be used without having realpath | |
# - lots of edge case testing | |
# 2023-08-11 -- v0.6 | |
# - added -f, --fetch to fetch list of saved searches directly from Quod Libet. | |
# This will ignore any playlists predefined in PLAYLISTS below, | |
# and ignore any playlists specified on the commandline, | |
# because it generates playlists for ALL saved searches | |
# except those beginning with a minus ("-"). | |
# This feature can be used to auto-generate all playlists and still have | |
# some special searches that aren’t included, simply by giving those | |
# a name starting with "-". | |
# - Check & error if QL's "queries.saved" file can't be found or read. | |
# - added filename sanitation, so obscure names like | |
# Test: "a/b"? 'ÄÖÜẞäöüß 测试 テスト Тест! *%<> \hello | |
# can be used in saved searches and won't break this script. | |
# Replaced chars: / \ ? % * : | " < > \x7F \x00-\x1F | |
# Unwanted characters are replaced with an underscore "_" in filenames. | |
# - Unified error messages: All start with script name now. | |
# - lots of testing -- again ;-) | |
# 2023-08-12 -- v0.7 | |
# - added "--" (stop option parsing) before truncate, mv and rm commands, | |
# so filenames starting with a "-" (like the excluded saved searches!) | |
# can be safely used with this script. You HAVE to use "--" on ql-playlists | |
# itself in this case. Example: ql-playlists -- '- Modified last week' | |
# - Help text: Use echo instead of cat, better layout and more concise wording. | |
# Added note about simple vs. #EXTM3U formats. | |
# - Renamed "songs" to "tracks" in total. | |
# - When no results are found, show query name instead of (would-be) filename. | |
# - PREFIX now included in filename sanitation, so constructs like | |
# -p"$(date +'%F %T') " | |
# now return "YYYY-MM-DD HH_MM_SS" instead of "YYYY-MM-DD HH:MM:SS". | |
# 2023-08-13 -- v0.8 | |
# - Added an extra error check for quodlibet execution, since I saw a | |
# "Timeout error" once in a while, but couldn't reproduce since. | |
# 2023-08-18 -- v0.8.1 | |
# - Just a newline after the help text. | |
# 2023-08-20 -- v0.8.2 | |
# - Fix shebang typo ('#!/bin/bash' instead of '#!/usr/bin/bash'). | |
# define me | |
me=$(basename "$0") | |
version='0.8.2' | |
# --- USER EDITABLE PARAMETERS START HERE --- | |
# The default folder where the playlists should be stored. | |
PLAYLISTFOLDER="$(xdg-user-dir MUSIC)/Playlists" | |
#PLAYLISTFOLDER="$(xdg-user-dir MUSIC)" | |
# (Default) saved searches to generate playlists for. | |
# Must be named accordingly in QL; use no characters illegal in filenames! | |
# Don’t forget the backslash \ at the end of the lines! | |
# Example: | |
# PLAYLISTS=( \ | |
# "1970's Rock" \ | |
# '12" Vinyls only' \ | |
# 'Kuschelrock' \ | |
# 'Night Soul' \ | |
# 'テスト' \ | |
# ) | |
PLAYLISTS=( \ | |
) | |
# Auto-fetch ALL saved searches from Quod Libet (true/false)? | |
# This will override above PLAYLISTS array and might take some time, | |
# depending on the number of saved searches. | |
FETCH=false | |
# Prefix playlist file names with this. | |
# It will always be prepended to anything the user sets with -p or --prefix. | |
# Use -p or --prefix to add the current date in ISO format (YYYY-MM-DD), | |
# use -pvalue or --prefix=value to set a custom string. | |
# | |
# This allows for real elaborate prefixing like -p"$USER $(date +'%F %H:%M') " which, | |
# together with PREFIX="xxx-", would generate a playlist filename like | |
# "xxx-matthias 2023-08-12 13:01 Nuit électronique.m3u". | |
# As an example, such constructs can make sense in broadcasting studios | |
# with several users, that operate on the same central music database. | |
PREFIX='' | |
# Should the playlist be sorted by path & filename (true/false)? | |
SORT=false | |
# Should we sort and also remove duplicates (true/false)? (Auto-activates sort) | |
UNIQUE=false | |
# Use absolute file paths (true/false; default=false)? | |
ABSPATH=false | |
# Be more verbose (true/false; default=false)? (Shows full output file names) | |
VERBOSE=false | |
# --- DO NOT MODIFY ANYTHING BELOW THIS POINT! --- | |
# Can't use this -- QL starts too slow and will block further execution | |
# of this script if not backgrounded. | |
#quodlibet --run --refresh & | |
# Set IFS to process newline-separated QL output and the printf array print. | |
IFS=$'\n' | |
# option handling using GNU getopt | |
VALID_ARGS=$(getopt -o afho:p::suvV --long abspath,fetch,help,output:,prefix::,sort,unique,verbose,version -- "$@") | |
if [[ $? -ne 0 ]]; then | |
exit 1; | |
fi | |
eval set -- "$VALID_ARGS" | |
while [ : ]; do | |
case "$1" in | |
-a | --abspath) | |
ABSPATH=true | |
shift | |
;; | |
-f | --fetch) | |
FETCH=true | |
shift | |
;; | |
-h | --help) | |
# Make a human-readable comma-separated list. | |
PLAYLISTS_TEXT="(none)" | |
if [ "${#PLAYLISTS[@]}" -gt 0 ] ; then | |
printf -v PLAYLISTS_TEXT '%s, ' "${PLAYLISTS[@]}" | |
PLAYLISTS_TEXT="${PLAYLISTS_TEXT%, }." | |
fi | |
echo """ | |
Usage: $me [OPTIONS] [PLAYLIST] [PLAYLIST] ... | |
Generate M3U playlists from Quod Libet saved searches. | |
Quod Libet must be running before starting this script, and the library | |
should be up-to-date. The 'Include Saved Search' plugin MUST be activated. | |
Options: | |
-h, --help Show this help. | |
-V, --version Show version number. | |
-a, --abspath Use absolute instead of the default relative file paths. | |
-f, --fetch Fetch all currently saved searches from Quod Libet and create | |
a playlist for each. This can take some time. | |
To EXCLUDE a saved search from automatic playlist generation, | |
simply give it a name starting with '-' in Quod Libet. | |
-o, --output Set output folder for playlists. Must exist and be writable. | |
(Default: $PLAYLISTFOLDER) | |
-p, --prefix Prefix playlist file names (default: ISO date YYYY-MM-DD). | |
Use -pstring or --prefix=string to set a custom string. | |
A custom overall prefix can be specified in this script. | |
It is currently set to '${PREFIX}'. | |
-s, --sort Sort playlist by path/filename. | |
-u, --unique Sort playlist by path/filename and remove duplicate files. | |
-v, --verbose Show full output file paths instead of just filenames. | |
Note: The locale specified by the environment ($LANG) affects sort order. | |
Playlists: | |
[PLAYLIST] is the exact name of a saved search in Quod Libet (case-sensitive). | |
Use quoting as appropriate, like for 'Names with spaces'. | |
The generated playlist file will have the same name, plus '.m3u' extension. | |
Special characters not allowed in filenames will be changed to '_'. | |
To create a playlist whose name starts with a '-', for example an otherwise | |
excluded search like '- Excluded Search', use this command: | |
$me -- '- Excluded Search' | |
Playlist files will only be (over-)written if a search succeeds. | |
If [PLAYLIST] is omitted, these predefined saved searches/playlists are used: | |
${PLAYLISTS_TEXT} | |
Note: $me generates simple M3U playlists (not #EXTM3U format). | |
These contain 1 line per track and can be used much more easily with tools | |
like 'sort' or 'uniq'. You can, for example, count the number of tracks | |
you have in all playlists by doing a simple | |
wc -l *.m3u | |
Change the defaults by editing '$0', | |
or use the -f or --fetch option to get the list from Quod Libet. | |
""" | |
exit 1 | |
;; | |
-o | --output) | |
PLAYLISTFOLDER="$2" | |
shift 2 | |
;; | |
-p | --prefix) | |
# Prefix is OPTIONAL (-p, -pvalue, --prefix, --prefix=value). | |
# If no value is given, it defaults to the current ISO date as YYYY-MM-DD. | |
# An existing prefix from default config above will be prepended. | |
PREFIX=${PREFIX}${2:-$(date +%F)' '} | |
# getopt ALWAYS generates the value for optional parameters, | |
# so we can simply always shift 2. | |
shift 2 | |
;; | |
-s | --sort) | |
SORT=true | |
shift | |
;; | |
-u | --unique) | |
SORT=true | |
UNIQUE=true | |
shift | |
;; | |
-v | --verbose) | |
VERBOSE=true | |
shift | |
;; | |
-V | --version) | |
echo "$me $version" | |
exit 1 | |
;; | |
--) shift; | |
break | |
;; | |
esac | |
done | |
# Commandline args overrule the built-in default array of playlists. | |
# Use "ql-playlists 'Nuit électronique' 'Schlager' ...". | |
if [ "$1" != "" ] ; then | |
PLAYLISTS=() | |
while [ "$1" != "" ] ; do | |
PLAYLISTS+=("$1") | |
shift | |
done | |
fi | |
# Check if Quod Libet is already running -- exit if not. | |
if ! pgrep -x "quodlibet" > /dev/null ; then | |
echo "$me: Please start Quod Libet and refresh your library before running!" >&2 | |
exit 1 | |
fi | |
# Check if realpath installed (-a/--abspath mode can be used without). | |
command -v realpath >/dev/null 2>&1 && HAVE_REALPATH=true || HAVE_REALPATH=false | |
if [[ "$ABSPATH" != true && "$HAVE_REALPATH" != true ]] ; then | |
echo >&2 "$me: requires 'realpath' but it's not installed. Aborting." | |
exit 2 | |
fi | |
# Fetch list of saved searches from Quod Libet, if -f/--fetch specified. | |
# Playlist names specified in configuration above or on the commandline | |
# will be ignored in this case, since we're doing ALL anyway. | |
if [ "$FETCH" = true ] ; then | |
# Get QL saved searches file location. | |
# We query QL for that since there are so many possibilities. | |
queriesfile=$(python3 -c "import os.path; from quodlibet import get_user_dir; print(os.path.join(get_user_dir(), 'lists', 'queries.saved'))") | |
if [ $? -ne 0 ] || [ ! -r "${queriesfile}" ] ; then | |
echo "$me: Couldn't read Quod Libet's saved searches file. Aborting." >&2 | |
exit 1 | |
fi | |
# Read every 2nd line of "queries.saved" (the names) and format into an array. | |
# We use "mapfile" which isn't too portable but should work on all modern bashes. | |
# Must use process substitution here (returns a file descriptor), piping won't work. | |
# This maps all saved searches EXCEPT those whose name begins with a "-". | |
# Can be used to easily exclude some searches from auto-generating playlists. | |
mapfile -t PLAYLISTS < <(awk '/^-/ {next}; NR %2 == 0' "${queriesfile}") | |
fi | |
# Diag only: Show list and exit. | |
#printf '%s\n' "${PLAYLISTS[@]}" | |
#echo "${#PLAYLISTS[@]} entries" | |
#exit 1 | |
# Check if output folder exists and is writable. | |
# All playlists will be generated here. | |
# Note: As opposed to -o folder and --output folder, | |
# getopt -ofolder and --output=folder will NOT do shell expansion ('~' etc.)! | |
# So we do a little magic: let a subshell do the expansion by echoing the echo... | |
# This will prevent "not found" errors on the if [ ! -w "$PLAYLISTFOLDER" ] below. | |
PLAYLISTFOLDER=$(echo "echo $PLAYLISTFOLDER" | bash) | |
if [ ! -w "$PLAYLISTFOLDER" ] ; then | |
echo "$me: Output folder '$PLAYLISTFOLDER' doesn't exist or isn't writable!" >&2 | |
exit 1 | |
fi | |
# Hop into the output folder, to make things easier. | |
oldpath="$PWD" | |
cd "$PLAYLISTFOLDER" | |
# Show output file path relative to where we are | |
OUTPATH='' | |
if [[ "$oldpath" != "$PWD" && "$HAVE_REALPATH" = true ]] ; then | |
OUTPATH=$(realpath --relative-to="$oldpath" "$PWD")'/' | |
fi | |
# Show full output pathnames if -v/--verbose is selected | |
if [ "$VERBOSE" = true ] ; then | |
OUTPATH="${PWD}/" | |
fi | |
# Regenerate all specified playlists (from array above or commandline). | |
total_playlists=0 | |
total_songs=0 | |
for playlist in ${PLAYLISTS[@]} ; do | |
# Just in case, we try to make "safe" filenames for most operating systems | |
# by replacing potentially "unsafe" characters with an underscore "_". | |
# We include the PREFIX here, since it also might contain unsafe characters. | |
# Note: awk would print only lines that matched the gsub pattern, | |
# so we use the "1" to force it printing unchanged lines, too. ;-) | |
# Otherwise, we'd get empty "playlistfile" names. | |
# Replaced chars: / \ ? % * : | " < > \x7F \x00-\x1F | |
playlistfile=$(echo "${PREFIX}$playlist" | \ | |
awk 'gsub(/[/\\?%*:|"<>\x7F\x00-\x1F]/, "_") 1') | |
# truncate truncates an existing file to size 0, or creates a new one. | |
truncate -s 0 -- "${playlistfile}.m3u.tmp" | |
count=0 | |
# From a saved search we get a newline-terminated list of absolute file paths. | |
# Added an extra error check, since I saw "Timeout error" once in a while, | |
# but couldn't reproduce since. | |
files=$(quodlibet --print-query "@(saved: $playlist)") | |
if [ $? -ne 0 ] ; then | |
echo "$me: Quod Libet error. Aborting." >&2 | |
exit 1 | |
fi | |
for file in ${files[@]} ; do | |
if [ "$ABSPATH" = true ] ; then | |
# Quod Libet generates absolute paths, no need to convert. | |
echo "$file" >> "${playlistfile}.m3u.tmp" | |
else | |
# Convert Quod Libet's absolute paths to relative paths. | |
# Can use --relative-to="." here, since we cd’ed into the folder already. | |
echo $(realpath --relative-to="." "$file") >> "${playlistfile}.m3u.tmp" | |
fi | |
count=$(( $count + 1 )) | |
done | |
# Only overwrite (potentially existing) playlist if at least one entry found. | |
if [ $count -gt 0 ] ; then | |
# We have entries. | |
if [ "$SORT" = true ] ; then | |
sortopt="" | |
if [ "$UNIQUE" = true ] ; then | |
sortopt="-u" | |
fi | |
# Sort temp file, write .m3u, remove temp file. | |
sort $sortopt -- "${playlistfile}.m3u.tmp" > "${playlistfile}.m3u" | |
rm -- "${playlistfile}.m3u.tmp" | |
if [ "$UNIQUE" = true ] ; then | |
echo "${OUTPATH}${playlistfile}.m3u ($count, sorted, unique)" | |
else | |
echo "${OUTPATH}${playlistfile}.m3u ($count, sorted)" | |
fi | |
else | |
# No sorting, just move (will overwrite existing files). | |
mv -- "${playlistfile}.m3u.tmp" "${playlistfile}.m3u" | |
echo "${OUTPATH}${playlistfile}.m3u ($count)" | |
fi | |
total_playlists=$(( $total_playlists + 1 )) | |
total_songs=$(( $total_songs + $count )) | |
else | |
# No entries -- either an error or nothing found with this saved search. | |
# Just remove temp file, keeping possible old file with same name intact. | |
rm -- "${playlistfile}.m3u.tmp" | |
echo "${playlist} -- no results" | |
fi | |
done | |
echo | |
echo "$total_playlists playlists, $total_songs tracks total." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Although this is not the fastest way to do it (~250 tracks/s on my 10-year-old laptop), I hacked together a little bash script to be able to export saved searches as M3U playlists.
Usage scenarios
Features
ql-playlists -- '- My Special Search'
.)sort
,uniq
orwc -l *.m3u
.Usage
~/bin
,~/.local/bin
, or/usr/local/bin
.chmod +x ql-playlists
to make it executable.ql-playlists -h
orql-playlists --help
for help.Include saved searches
plugin in Quod Libet must be activated for this to work! (A typical indicator that the plugin isn’t active is that you will get "-- no results" on all playlists and "0 playlists, 0 tracks total".)Screenshot
On a 10-year-old Dell Latitude laptop, Linux Mint 21.2, "Music" NFS-mounted to server in local LAN, collection size ~157,000 tracks, rather long and complicated searches:
"Only" 186 tracks/s in this case, but much better than having to do it manually. Just grab a cup of your favorite beverage in the meantime…
The
Test_ _a_b__ 'ÄÖÜẞäöüß 测试 テスト Тест! ____ _hello.m3u
playlist comes from a search namedTest: "a/b"? 'ÄÖÜẞäöüß 测试 テスト Тест! *%<> \hello
and is purely for testing.Errors/problems/suggestions?
queries.saved
location works in all cases. Or maybe you have a better way to do that.Enjoy and let me know if and for what use case you use it.