Created
May 26, 2018 18:44
-
-
Save dusekdan/5e9179a5fa5a63e14211d13a5d26e120 to your computer and use it in GitHub Desktop.
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
import sys | |
import os | |
import datetime | |
from shutil import copy | |
# By far the easiest way to get song duration is to use mutagen library. You | |
# can install it using PIP as follows: | |
# pip install mutagen | |
from mutagen.mp3 import MP3 | |
from mutagen import MutagenError | |
from random import randint | |
# ____ ___ _ _ _____ ___ ____ | |
# / ___/ _ \| \ | | ___|_ _/ ___| | |
# | | | | | | \| | |_ | | | _ | |
# | |__| |_| | |\ | _| | | |_| | | |
# \____\___/|_| \_|_| |___\____| | |
# | |
# Written by: Daniel Dusek (@DusekDan) | |
# When there is less than SKIP_REMAINING_DURATION seconds left till the end of | |
# constructed playlist part, no additional songs will be added. 60 seconds | |
# proved itself to be a reasonable value. | |
SKIP_REMAINING_DURATION = 60 | |
# Directory (relative to this script) to which MP3 songs should be copied when | |
# playlist is prepared. | |
OUTPUT_PLAYLIST_DIRECTORY = "GeneratedPlaylist2" | |
# Workout phases' duration in minutes. | |
PREWORKOUT_DURATION = 20 # Way to gym. Motivation recommended. | |
CARDIO_DURATION = 10 # Warm up: 10 minutes, that should do. | |
WORKOUT_DURATION = 30 # Kill it for 30 minutes straight. | |
KEEP_GOING_DURATION = 20 # For when lifting becomes too hard. | |
# Source folders with MP3 music files for 4 significant workout parts. | |
# Your directory structure should look something like this: | |
# ./ | |
# |-> PlaylistPreparer.py | |
# |-> 00_preworkout/ (contains mp3 files with your preworkout songs) | |
# |-> 01_cardio/ (the same as above) | |
# |-> ... (folders of other workout parts) | |
# |-> GeneratedPlaylist/ (playlist files will be generated here) | |
PRE_WORKOUT_FOLDER = "00_preworkout" | |
CARDIO_FOLDER = "01_cardio" | |
WORKOUT_FOLDER = "02_workout" | |
KEEP_GOING_FOLDER = "03_keepGoing" | |
# Piece of advice: Preworkout & Keep-going should overlap, as if you keep-going | |
# long enough, the preworkout may start playling (loop). | |
standard_duration = (PREWORKOUT_DURATION * 60, CARDIO_DURATION * 60, WORKOUT_DURATION * 60, KEEP_GOING_DURATION * 60) | |
def main(): | |
# Song files & song files info extraction. | |
preworkoutSongs = extractNameDurationTuples(PRE_WORKOUT_FOLDER) | |
cardioSongs = extractNameDurationTuples(CARDIO_FOLDER) | |
workoutSongs = extractNameDurationTuples(WORKOUT_FOLDER) | |
keepgoingSongs = extractNameDurationTuples(KEEP_GOING_FOLDER) | |
# Partial playlists preparation. | |
preworkoutPlaylist = preparePlaylist(preworkoutSongs, standard_duration[0]) | |
cardioPlaylist = preparePlaylist(cardioSongs, standard_duration[1]) | |
workoutPlaylist = preparePlaylist(workoutSongs, standard_duration[2]) | |
keepgoingPlaylist = preparePlaylist(keepgoingSongs, standard_duration[3]) | |
# Merging partial playlists into single final one. | |
finalPlaylist = preworkoutPlaylist + cardioPlaylist + workoutPlaylist + keepgoingPlaylist | |
# Flip the condition if you (don't) want to see songs used in playlist. | |
if (False): | |
peakPlaylist(finalPlaylist) | |
# Anonymize & move actual songs to playlist directory. | |
anonymizePlaylistSongs(finalPlaylist, OUTPUT_PLAYLIST_DIRECTORY) | |
print("Playlist created. Total time: " + secondsToMinutesString(getPlaylistDuration(finalPlaylist))) | |
def extractNameDurationTuples(targetFolder): | |
"""Iterates over target folder and retrieves tuples where key is a path to | |
song file and the value is its duration in seconds.""" | |
songTuples = [] | |
for file in os.listdir(targetFolder): | |
try: | |
audio = MP3(targetFolder + '/' + file) | |
duration = audio.info.length | |
songTuples.append((targetFolder + '/' + file, duration)) | |
except MutagenError: | |
print("Unable to load information for file " + file + ". This file will be excluded from playlist generation.") | |
return songTuples | |
def preparePlaylist(songsource, duration): | |
"""From song-list of tuples (path_to_song_file, duration_in_seconds) | |
composes playlist of requested length (in seconds).""" | |
playlist = [] | |
while getPlaylistDuration(playlist) <= duration: | |
remainingDurationCap = duration - getPlaylistDuration(playlist) | |
# Avoid filling up playlist with 'shortest possible' songs if there is | |
# less than SKIP_REMAINIG_DURATION seconds remaining to the requested | |
# duration. | |
if remainingDurationCap < SKIP_REMAINING_DURATION: | |
break | |
playlist.append(getRandomSongFromSongListWithDuration(songsource, remainingDurationCap)) | |
return playlist | |
def getRandomSongFromSongListWithDuration(songlist, duration): | |
# Retrieve only songs with required duration. | |
requiredDuration = [s for s in songlist if s[1] <= duration] | |
# If there is no song satisfying duration requirements, the shortest one | |
# is provided instead. | |
if len(requiredDuration) == 0: | |
return findShortestSong(songlist) | |
# In case of multiple songs with requested duration, pick randomly. | |
index = randint(0, len(requiredDuration)-1) | |
return requiredDuration[index] | |
def anonymizePlaylistSongs(playlist, copyTo, external=""): | |
"""Rename & move playlist songs to the final destination. Renamed songs | |
respect their original order, but user can not tell which song was actually | |
added to the playlist. If external parameter is set, files will be copied | |
to copyTo location prefixed by external_root parameter.""" | |
# Ensure external/copyTo directory exists | |
if not os.path.exists(external + copyTo): | |
os.makedirs(external + copyTo) | |
pi = 0 ; ci = 0 ; wi = 0 ; ki = 0 | |
for song in playlist: | |
# Anonymize based on source (and optionally prefix with external path) | |
if song[0].startswith(PRE_WORKOUT_FOLDER): | |
newPath = external + copyTo + "/AAA_" + str(pi) + ".mp3" | |
pi += 1 | |
elif song[0].startswith(CARDIO_FOLDER): | |
newPath = external + copyTo + "/BBB_" + str(ci) + ".mp3" | |
ci += 1 | |
elif song[0].startswith(WORKOUT_FOLDER): | |
newPath = external + copyTo + "/CCC_" + str(wi) + ".mp3" | |
wi += 1 | |
else: | |
newPath = external + copyTo + "/DDD_" + str(ki) + ".mp3" | |
ki += 1 | |
copy(song[0], newPath) | |
# Strip ID3 tags to make all MP3s equal (and sorted only by name). | |
eraseID3Tags(external, copyTo) | |
def eraseID3Tags(root, target): | |
"""Loops over files copied to target directory and erases ID3 Tags.""" | |
for file in os.listdir(root + target): | |
if file.lower().endswith('.mp3'): | |
try: | |
mp3 = MP3(root + target + "/" + file) | |
mp3.delete() | |
mp3.save() | |
except MutagenError: | |
print("Failed to erase ID3 tags for file %s" % file) | |
def getPlaylistDuration(playlist): | |
"""Calculates playlist duration in seconds.""" | |
duration = 0 | |
for record in playlist: | |
duration += record[1] | |
return duration | |
def findShortestSong(songlist): | |
"""Very ugly implementation of finding minimum value in a list of tuples.""" | |
shortestDuration = sys.maxsize | |
shortestSongName = "NO-SHORTEST-SONG (Empty song list)" | |
for song in songlist: | |
# Discover local minimum. | |
if song[1] < shortestDuration: | |
shortestSongName = song[0] | |
shortestDuration = song[1] | |
return [t for t in songlist if t[0] == shortestSongName][0] | |
def secondsToMinutesString(seconds): | |
"""Handy function to convert duration in seconds to printable string.""" | |
return str(datetime.timedelta(seconds=seconds)) | |
def peakPlaylist(playlist): | |
"""In case you do not like surprise playlists, just switch the if (False) | |
condition to if (True) in main function.""" | |
print("Playlist before anonymization:") | |
for song in playlist: | |
print("\t%s (%s)" % (song[0], secondsToMinutesString(song[1]))) | |
# Non-import entry point. | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment