summaryrefslogtreecommitdiffstats
path: root/bin/albumbler.py
blob: db240074aab39945d92a3f44fa27d61e1244cb0b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
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()