Skip to content

Instantly share code, notes, and snippets.

@Moonbase59
Last active August 20, 2023 14:20
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Moonbase59/a62363d61a4dcb596bd27d464368a9a4 to your computer and use it in GitHub Desktop.
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.
#!/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."
@Moonbase59
Copy link
Author

Moonbase59 commented Aug 14, 2023

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

  • "Dynamic" or "intelligent" playlists. Since searches are based on the (hopefully) updated library, generated playlists will always be up-to-date.
  • Create lots of carefully crafted playlists for broadcasting in one go. They only need to be uploaded into your station, like AzuraCast, mAirList, LibreTime, CentovaCast or others.
  • Create personal playlists you like to listen to.
  • And many more.

Features

  • Create playlists from all saved searches in one go (by fetching the list from Quod Libet).
  • Exclude saved searches from generating a playlist by using a name starting with "-". (These can still be exported using something like ql-playlists -- '- My Special Search'.)
  • Playlists are named after your saved search.
  • No need to use saved search names without "dangerous" characters—ql-playlists will automatically sanitize generated filenames by replacing those with an underscore "_".
  • Absolute or relative file paths.
  • Selectable output folder (default: ~/Music/Playlists)
  • Prefix playlist names with the current ISO date, or anything else you specify.
  • Sort playlist, using the system locale’s sort rules.
  • Unique: Ensure that no duplicate files are in the playlist.
  • Use plain (not #EXTM3U) format, so generated playlists can be used much more easily with tools like sort, uniq or wc -l *.m3u.

Usage

  • Supported OS: Linux. Might work on MacOS and Windows WSL, no guarantees. (Tested on current versions of Arch, Manjaro, Ubuntu, Linux Mint).
  • Download from my Gist: https://gist.github.com/Moonbase59/a62363d61a4dcb596bd27d464368a9a4
  • Save somewhere handy so it’s in the path, like in ~/bin, ~/.local/bin, or /usr/local/bin.
  • chmod +x ql-playlists to make it executable.
  • Open in a text editor and study the comments. Optionally edit preferences.
  • ql-playlists -h or ql-playlists --help for help.
  • Note: The 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:

matthias@e6510: ~-Musik-Playlists-Radio_001

"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 named Test: "a/b"? 'ÄÖÜẞäöüß 测试 テスト Тест! *%<> \hello and is purely for testing.

Errors/problems/suggestions?

  • Let me know. It’s just a quick bash script and might break under odd circumstances, but I’d be interested to know and probably improve.
  • I’d be especially interested if my Python hack to find the QL 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment