diff options
Diffstat (limited to 'bin/albumbler.py')
-rwxr-xr-x | bin/albumbler.py | 303 |
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() + |