summaryrefslogtreecommitdiff
path: root/albumbler.py
diff options
context:
space:
mode:
authortslil clingman <tslil@posteo.de>2023-02-11 11:29:17 +0100
committertslil clingman <tslil@posteo.de>2023-02-11 11:29:17 +0100
commite75adefbc8a773a63bd12fd572c7dc6d1ca1ce28 (patch)
tree8bf39c2e1c6e40c61adb6471861314ac85ec57ca /albumbler.py
parentd4a030c388e79ee83ed0b84cb5cae40d57051f07 (diff)
albumbler
Diffstat (limited to 'albumbler.py')
-rwxr-xr-xalbumbler.py345
1 files changed, 345 insertions, 0 deletions
diff --git a/albumbler.py b/albumbler.py
new file mode 100755
index 0000000..df03349
--- /dev/null
+++ b/albumbler.py
@@ -0,0 +1,345 @@
+#! /usr/bin/env python
+
+# Copyright Kyle Keen 2010
+# gpl v2
+
+# ported to python 3 by tslil clingman, 2022
+
+import subprocess, os, random, time, sys, configparser, socket
+from functools import reduce
+import pickle
+
+# defaults
+conf = {
+ 'music_paths': [os.getenv('HOME')],
+ 'time_window': 60.0,
+ 'max_playlist': 1000,
+ 'min_playlist': 3,
+ 'notify': 'console',
+ 'player': 'mocp',
+ 'playlist_exts': ['m3u']}
+
+"""
+playlist is a cached list of all directories/playlists.
+
+skiplist is a list of what/when was played
+[ [skipped, skipped, skipped, listened, time], ... ]
+Anything less than time_window is considered as skipped.
+"""
+
+# support for more players
+# support more notifications
+# man page
+# berkely db? sqlite?
+# rewrite in not python?
+# context sensitive?
+# add -forget
+# tapered dislike between 1 minute and 1 hour
+# make some sort of album fingerprinting
+
+def xdg_paths():
+ j = os.path.join
+ e = os.getenv
+ config = e('XDG_CONFIG_HOME', j(e('HOME'), '.config'))
+ config = j(config, 'albumbler', 'albumbler.config')
+ cache = e('XDG_DATA_HOME', j(e('HOME'), '.local/share'))
+ cache = j(cache, 'albumbler', 'albumbler.pickle')
+ exclude = e('XDG_CONFIG_HOME', j(e('HOME'), '.config'))
+ exclude = j(exclude, 'albumbler', 'excludes')
+ return config, cache, exclude
+
+config_path, cache_path, exclude_path = xdg_paths() # probably dumb to do this globally
+
+def call(string):
+ pipe = subprocess.PIPE
+ return subprocess.call(string, stdout=pipe, shell=True)
+
+def port(host, number, message):
+ if type(number) == str:
+ number = int(number)
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ #sock.setblocking(0)
+ sock.connect((host, number))
+ sock.send(message)
+ reply = sock.recv(16384) # 16k ought to be more than enough for anyone
+ sock.close()
+ return reply
+
+def load_excludes():
+ excludes = set()
+ if not os.path.isfile(exclude_path):
+ return excludes
+ with open(exclude_path) as f:
+ for line in f:
+ excludes.add(f.strip('\n'))
+ return excludes
+
+def dir_tree(dir_root):
+ excludes = load_excludes()
+ tree = list(os.walk(dir_root))
+ dirs = [t[0] for t in tree]
+ playlists = [os.path.join(t[0],f) for t in tree for f in t[2] if os.path.splitext(f)[1][1:] in conf['playlist_exts']]
+ return [p for p in dirs + playlists if p not in excludes]
+
+def reasonable_dir(path):
+ "Estimates size of recursive add."
+ if not os.path.isdir(path): # assume any custom playlists are reasonable
+ return True
+ length = 0
+ for w in os.walk(path):
+ length += len(w[2])
+ if length > conf['max_playlist']:
+ return False
+ return length > conf['min_playlist']
+
+def list_files(path):
+ "For spoon feeding some players."
+ j = os.path.join
+ if os.path.splitext(path)[1][1:] in conf['playlist_exts']:
+ dn = os.path.dirname(path)
+ return [j(dn, track) for track in open(path).read().split('\n')]
+ if not os.path.isdir(path):
+ return ''
+ return [os.path.join(p,f) for p,d,files in os.walk(path) for f in files]
+
+def strip_dirs(track, dirs):
+ # fix yucky nesting
+ for d in dirs:
+ if track.startswith(d):
+ track = track[len(d):]
+ if track.startswith('/'):
+ track = track[1:]
+ return track
+ return track
+
+def resync():
+ playlist, skiplist = load_cache()
+ old_len = len(playlist)
+ playlist = []
+ print('Walking', conf['music_paths'])
+ [playlist.extend(dir_tree(mp)) for mp in conf['music_paths']]
+ pickle.dump((playlist, skiplist), open(cache_path, 'wb'), -1)
+ new_len = len(playlist)
+ print('Index: added %i, total %i' % (new_len-old_len, new_len))
+
+def ranking():
+ playlist, skiplist = load_cache()
+ fav_list = [skips[-2] for skips in skiplist]
+ bad_list = []
+ [bad_list.extend(skips[:-2]) for skips in skiplist]
+ fav_count = dict((f,0.0) for f in set(fav_list) | set(bad_list))
+ for f in fav_list:
+ fav_count[f] += 1.0
+ for b in bad_list:
+ fav_count[b] -= 0.1
+ fav_tally = [(c,f) for f,c in fav_count.iteritems()]
+ return fav_tally
+
+def show_favorites():
+ fav_tally = ranking()
+ fav_tally.sort()
+ fav_tally.reverse()
+ for c,f in fav_tally[:100]:
+ print(f)
+
+def show_worst():
+ fav_tally = ranking()
+ fav_tally.sort()
+ for c,f in fav_tally[:100]:
+ print(f)
+
+def update_skiplist(playlist, skiplist, play):
+ now_time = time.time()
+ if len(skiplist) == 0:
+ skiplist.append([play, now_time])
+ pickle.dump((playlist, skiplist), open(cache_path, 'wb'), -1)
+ return
+ last_time = skiplist[-1][-1]
+ if now_time - last_time < conf['time_window']:
+ skiplist[-1].pop() # last_time
+ skiplist[-1].append(play)
+ skiplist[-1].append(now_time)
+ else:
+ skiplist.append([play, now_time])
+ pickle.dump((playlist, skiplist), open(cache_path, 'wb'), -1)
+
+def load_cache():
+ if not os.path.isdir(os.path.dirname(cache_path)):
+ os.makedirs(os.path.dirname(cache_path))
+ if not os.path.isfile(cache_path):
+ pickle.dump(([],[]), open(cache_path, 'wb'), -1)
+ return pickle.load(open(cache_path, 'rb'))
+
+def load_config():
+ global conf
+ cp = configparser.RawConfigParser()
+ if not os.path.isdir(os.path.dirname(config_path)):
+ os.makedirs(os.path.dirname(config_path))
+ if not os.path.isfile(config_path):
+ # create default file
+ cp.add_section('Settings')
+ cp.set('Settings', 'MusicPaths', ','.join(conf['music_paths']))
+ cp.set('Settings', 'TimeWindow', str(conf['time_window']))
+ cp.set('Settings', 'MaxPlaylist', str(conf['max_playlist']))
+ cp.set('Settings', 'MinPlaylist', str(conf['min_playlist']))
+ cp.set('Settings', 'Notify', str(conf['notify']))
+ cp.set('Settings', 'Player', str(conf['player']))
+ cp.set('Settings', 'PlaylistExts', ','.join(conf['playlist_exts']))
+ with open(config_path, "w") as config_file:
+ cp.write(config_file)
+ cp = configparser.RawConfigParser()
+ cp.read(config_path)
+ conf['music_paths'] = cp.get('Settings', 'MusicPaths').split(',')
+ conf['time_window'] = cp.getfloat('Settings', 'TimeWindow')
+ conf['max_playlist'] = cp.getint('Settings', 'MaxPlaylist')
+ conf['notify'] = cp.get('Settings', 'Notify')
+ conf['player'] = cp.get('Settings', 'Player')
+ # make a graceful upgrade path
+ try:
+ conf['playlist_exts'] = cp.get('Settings', 'PlaylistExts').split(',')
+ except configparser.NoOptionError:
+ pass
+ try:
+ conf['min_playlist'] = cp.getint('Settings', 'MinPlaylist')
+ except configparser.NoOptionError:
+ pass
+
+def scale_many(d, scale, paths):
+ for p in paths:
+ if p in d:
+ d[p] *= scale
+ return d
+
+def weighted2(playlist, skiplist):
+ playdict = dict((path, 1.0) for path in playlist)
+ if len(skiplist) == 0:
+ # first time user
+ return playdict
+ now_time = time.time()
+ last_time = skiplist[-1][-1]
+ if now_time - last_time > conf['time_window']:
+ # called first time, avoid common
+ all_heard = (p for skips in skiplist for p in skips[:-1])
+ playdict = scale_many(playdict, 0.25, all_heard)
+ return playdict
+ # called multiple times, do the fancy stuff
+ recent = set(skiplist[-1][:-1])
+ dislikes = [recent]
+ all_dislikes = set(recent)
+ all_skipped = [set(skips[:-2]) for skips in skiplist]
+ # walk the map, discard repeats
+ while dislikes[-1]:
+ associated = set([])
+ [associated.update(skips) for skips in all_skipped if skips & dislikes[-1]]
+ associated.difference_update(all_dislikes)
+ #print len(dislikes[-1]), len(associated), len(all_dislikes)
+ dislikes.append(associated)
+ all_dislikes.update(associated)
+ # dislikes now holds map distances
+ # todo: distance never above 4?
+ dis_fn = lambda x: (2**x-1)/(2.0**x)
+ for i,d in enumerate(dislikes):
+ playdict = scale_many(playdict, dis_fn(i), d)
+ # avoid things similar to recent
+ #all_skipped = [set(skips[:-2]) for skips in skiplist]
+ #similar = [p for skips in all_skipped if skips & recent for p in skips]
+ # todo: be fancy and compute each distance to recent
+ #playdict = scale_many(playdict, 0.50, similar)
+ #print sorted((b,a) for a,b in playdict.items() if b != 1.0)
+ return playdict
+
+def emms_quote(path):
+ "backslash every character to make any shell parameter safe"
+ return reduce(lambda a,c: a+'\\'+c, path, "")
+
+def emms_cmd(path):
+ return '''
+ (progn
+ (require 'emms)
+ (save-excursion
+ (emms-stop)
+ (condition-case nil
+ (with-current-buffer emms-playlist-buffer
+ (emms-playlist-clear))
+ (error nil))
+ (emms-add-directory-tree "%s")
+ (with-current-buffer emms-playlist-buffer
+ (beginning-of-buffer)
+ (emms-playlist-mode-play-current-track))))
+ ''' % path
+
+def play_tunes(play):
+ # player : (command, args, args, ...)
+ f = {'mocp': ('mocp', '--clear', '--append "%s"' % play, '--play'),
+ 'cmus': ('cmus-remote', '-c', '"%s"' % play,
+ '-C "view 3" "win-activate"'),
+ 'xmms2': ('nyxmms2', 'remove "*"', 'add "%s"' % play, 'play'),
+ 'clementine': ('clementine', '-s', '-l "%s"' % play, '-p'),
+ 'gmusicbrowser':('gmusicbrowser', '-play -playlist "%s"' % play),
+ 'mpc': ('mpc', 'clear',
+ 'add "%s"\n' % strip_dirs(play, conf['music_paths']), 'play'),
+ 'emms': ('emacsclient', '-e %s' % emms_quote(emms_cmd(play))),
+ }
+ player = conf['player']
+ if player not in f:
+ return
+ command = f[player][0]
+ [call('%s %s' % (command, arg)) for arg in f[player][1:]]
+
+def main():
+ load_config()
+ # use real argv parsing
+ if len(sys.argv) == 2:
+ if sys.argv[1] == 'sync':
+ resync()
+ elif sys.argv[1] == 'best':
+ show_favorites()
+ elif sys.argv[1] == 'worst':
+ show_worst()
+ else:
+ print("Supported options are 'sync', 'best' or 'worst'.")
+ print("For normal use, call without options.")
+ return
+ playlist, skiplist = load_cache()
+ if not playlist:
+ print('Set musicpath in %s' % config_path)
+ print('And run "albumbler sync"')
+ return
+ playdict = weighted2(playlist, skiplist)
+ while True:
+ play = random.choice(list(playdict.keys()))
+ odds = playdict[play]
+ if not reasonable_dir(play):
+ playdict[play] = 0.0
+ continue
+ if random.random() < odds:
+ break
+ update_skiplist(playlist, skiplist, play)
+
+ # add parallel notifications
+ if conf['notify'] == 'console':
+ print(play)
+ if conf['notify'] == 'notify-send':
+ call('notify-send -t 3000 "Now Playing: %s"' % os.path.basename(play))
+ if conf['notify'] == 'ratpoison':
+ call('ratpoison -c "echo Now Playing: %s"' % os.path.basename(play))
+ play_tunes(play)
+ # but some don't template well
+ player = conf['player']
+ if player == 'mpd':
+ host = os.getenv('MPD_HOST', 'localhost')
+ pnum = os.getenv('MPD_PORT', 6600)
+ port(host, pnum, 'clear\n')
+ port(host, pnum, 'add "%s"\n' % strip_dirs(play, conf['music_paths']))
+ port(host, pnum, 'play\n')
+ if player == 'rhythmbox':
+ call('rhythmbox-client --clear-queue')
+ [call('rhythmbox-client --play-uri="%s"' % track) for track in list_files(play)]
+ call('rhythmbox-client --play')
+ if player == 'audacious':
+ call('audtool --playlist-clear')
+ [call('audtool --playlist-addurl "%s"' % track) for track in list_files(play)]
+ call('audtool --playback-play')
+
+if __name__ == '__main__':
+ main()