Sunday, December 6, 2020

Improving shuffle/random playback - Plex

Whenever people discuss random playback of multimedia files online, I always see this same anecdote pop up: Apple wanted to add shuffle to iTunes, so an engineer made a perfectly random algorithm to serve up perfectly random tunes. Some time passes and users start to complain because the algorithm seems to have an affinity for certain songs that are played over and over while others languish, played only infrequently or even never. As the story goes, the algorithm is, of course, perfect, and the silly humans merely perceive patterns where none exist, like superstitious cavemen, and the ratio of plays would have tended toward 1.0 if they had just waited long enough and with a large enough sample size. So, our fabled engineer goes back to his perfectly random algorithm and makes it less random, but in a way that appeases the poor dumb-dumbs, and that's how we get iTunes' shuffle.

If you couldn't already tell, I think this anecdote is horseshit on several levels. Anyone that works with computers knows that producing true randomness is very hard to do (an entire branch of cryptology is devoted to this), and patterns in pseudo-random distributions tend to become more apparent with more samples, not less. Personally, I used iTunes' shuffle exclusively on a set of several thousand songs over the course of a few years, and it most definitely picked winners and losers, so Mr. Smug Engineer can get stuffed.

Nevertheless, the fact remains that if you randomly sample from a set (whether it's really random or pseudo-random), you're going to get repeats, and that's a drag when you feel like you're hearing the same songs (or watching the same rerun of your favorite show(s)) over and over. The solution to this problem is to use any ol' pseudo-random shuffle but ensure that each item is removed from the list of available items once it has been played until every item has been played. Rinse and repeat.

Plex has a way to do this, but it's not obvious, and it builds from their "smart playlist" feature. With this in mind, you would think 'step 1' would be to head over to the 'Playlists' page, but this is unintuitively not the case. No, you have to go to main page for the type of file you're wanting to randomize (in my case, TV shows):

 

and, if there are any tabs for "recommended", "library" and/or "playlists," again do the unintuitive thing and *do not* go to the "playlists" tab. Instead, go all the way to the left, where it says "All", click on it, and at the bottom of the menu that pops up, look for "custom filter...":

Here, you can use search terms and keywords to filter down to the criteria you need. For example, "show title" "contains" "Futurama":

 

Now, here's the important bit: click on the plus sign (+) in the circle on the far right, past your search field to add a new set of criteria. From here, instead of "show title", look for "unplayed episodes" and set the next field to "is true". Click on "save as" to give your playlist a title, and it will now appear in your playlists tab, with a little gear in the corner to denote that it's a dynamic "smart" playlist:

Now, when you go to play from the playlist, use the shuffle option and it will shuffle among the remaining unplayed episodes *only*. Eventually, you will exhaust the playlist and need to go back to the show and mark it as "unplayed" to refresh the pool of episodes.

Sunday, November 29, 2020

Handy FFmpeg + bash scripts

I created these scripts to perform some common tasks with FFmpeg and figured I'd post them here in case anyone else needs to do the same jobs.

Convert to mp3

I keep my music collection in lossless FLAC format, but not everything supports this format, including the stereo in my car. It'll play music files from a USB thumb drive and is compatible with a variety of lossy formats, just not FLAC, so if I want to put a new album on my drive to get it into my regular rotation, I have to convert from my lossless archival copy to a compatible lossy format. I made this basic script to simplify the process:

#!/bin/bash

if [ "$1" == "" ] || [ "$1" == "--help" ]; then
echo "Convert audio files to mp3 using LAME with high-quality VB0 settings."
echo "Single files or wildcards are valid arguments."
exit
fi

for i in "$@"
do

[ ! -d "mp3s" ] && mkdir "mp3s"

name=`echo "$i" | sed 's/\.[^.]*$//' | sed 's/.*\///'`

ffmpeg -i "$i" -acodec libmp3lame -q:a 0 mp3s/"$name".mp3

done
To explain a bit what's going on: the first 'if' statement echos out a helper statement if the script is run either without an argument or with the standard '--help' switch. You'll notice I use the special built-in "$1", which means "first argument". More info on the built-in argument variables here. It also exits here instead of going on to the rest of the script, which wouldn't really make sense without the proper arguments anyway.

Next, we start the 'for' loop. Here, I've used another built-in alias, "$@", which is important because it will allow the loop to expand to accept wildcards, such as asterisk/*. When I was first working on this script, I used the same "$1" as before and it would only hit the first match from the wildcard and then stop. More info on that problem in this stackexchange post.

So, for each match to the pattern (be it a single file or a series), it will first check whether the directory named 'mp3s' exists and, if not, it will make it (I find it's easier to deal with the files if they're all in one place instead of mixed in with the FLACs that have exactly the same name other than file extension...).

Next, we declare a variable, "name", which uses some sed magic to remove the file extension. Unfortunately, I looked this up a long time ago and no longer have a link to where I found the answer, and sed syntax is pure black magic to me, so I have zero clue what it's actually doing, but it works. The reason we need to do this is because the resulting mp3s would all be named foo.flac.mp3 otherwise, which is ugly.

Alright, the last bit is where we use FFmpeg to actually do the work of reencoding. The only real gotcha here is knowing that the LAME encoder is called libmp3lame, and knowing about the qscale:a setting, which determines the variable quality/bitrate. Since my car supports it, I use VB0, which is variable bitrate that goes up to 320 kbps when necessary but doesn't waste bits encoding silence, for example. If your device doesn't support variable-bitrate mp3s, you can do -b:a 128k (or whatever bitrate you want) instead.

Trim From The End

Turns out it's really hard to get FFmpeg to trim an arbitrary number of seconds from the end of a file without reencoding, which is exactly what I needed to do to remove a very loud and very useless sequence at the end of every episode of my Duckman DVD rips (my wife likes to fall asleep to reruns, and this sequence would startle her awake every time).

#!/bin/bash

if [ "$1" == "--help" ] || [ "$1" == "" ]; then
echo "Enter a video's filename followed by the number of seconds to trim from it."
echo "For example (to trim 25 seconds from a video named 'foo.mp4'):"
echo "foo.mp4 25"
echo "The trimmed file will be placed in the newly created 'trunc' directory."
exit
fi

DURATION=`ffprobe -v 0 -show_entries format=duration -of compact=p=0:nk=1 "$1"`

echo "$DURATION"

if [ "$2" == "" ]; then
echo "Your video is '$DURATION' seconds long. You need to specify how many seconds to trim."
exit
fi

echo "Total duration is: '$DURATION'"
DUR_INT=`awk 'BEGIN { print int('$DURATION'+0.5) }'`
TRIM=`expr "$DUR_INT" - "$2"`
echo "Duration after trimming is: $TRIM"

if [ ! -d trunc ]; then
  mkdir trunc;
fi

ffmpeg -i "$1" -c copy -t "$TRIM" trunc/"$1"
echo "Success"
exit

Again, I started out with some helpful explanation, just in case I forget how to use the script.

Since we're using two arguments, we use the built-in "$1" and "$2". This command will not run with wildcards.

Next up, we'll use ffprobe (a utility that comes with FFmpeg) to determine the duration of the file in seconds. This gives us a floating point value, which means it usually includes a bunch of fractional stuff after the decimal point. This becomes important later. We'll store this value in the DURATION variable.

So, we now know how long our video is, and we know how many seconds we want to trim, so we should be able to subtract the trim amount from the total value using bash's built-in expr arithmetic, but life is never that easy. No, expr won't subtract an integer from a float, so we need to convert the DURATION float value to an integer using some awk magic (I know as little about awk as sed, sadly). This will take our float value, add 0.5 to it, "floor" it (that is, round it down) to the nearest integer and then cast the value to an "int" (i.e., store it as an actual integer rather than a float; like "2" instead of "2.0"). We'll store that int value in DUR_INT.

Now we can subtract our seconds from the total, and we'll store the resulting total value as TRIM.

After that, it's just like before, where we check for (and if necessary, create) our storage directory (even more important this time because we don't want to accidentally overwrite our original files with the trimmed version), then loop through our pattern matches and use FFmpeg's "-t" switch to stop the new file at our newly calculated "trimmed" duration, which will cut off the end (and we're using the -c copy switch to avoid reencoding, which takes a long time and reduces the quality of the video each time).

Analytics Tracking Footer