summaryrefslogtreecommitdiffstats
path: root/bin/albumbler.py
diff options
context:
space:
mode:
Diffstat (limited to 'bin/albumbler.py')
-rwxr-xr-xbin/albumbler.py303
1 files changed, 303 insertions, 0 deletions
diff --git a/bin/albumbler.py b/bin/albumbler.py
new file mode 100755
index 0000000..db24007
--- /dev/null
+++ b/bin/albumbler.py
@@ -0,0 +1,303 @@
+#! /usr/bin/env python2
+
+# Copyright Kyle Keen 2010
+# gpl v2
+
+import subprocess, os, random, time, sys, ConfigParser, socket
+import cPickle as 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?
+# 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')
+ return config, cache
+
+config_path, cache_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(number, message):
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ #sock.setblocking(0)
+ sock.connect(('localhost', number))
+ sock.send(message)
+ reply = sock.recv(16384) # 16k ought to be more than enough for anyone
+ sock.close()
+ return reply
+
+def dir_tree(dir_root):
+ 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 dirs + playlists
+
+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']))
+ cp.write(open(config_path, 'wb'))
+ 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 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),
+ }
+ 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(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 "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':
+ port(6600, 'clear\n')
+ port(6600, 'add "%s"\n' % strip_dirs(play, conf['music_paths']))
+ port(6600, '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()
+