#! /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()