-
Notifications
You must be signed in to change notification settings - Fork 2
/
sync_daemon.py
executable file
·379 lines (312 loc) · 14.2 KB
/
sync_daemon.py
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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
#!/usr/bin/env python3
import json
import logging
import sys
import threading
import time
import urllib.parse
import guessit
import os
import requests
import mpv
import trakt_key_holder
import trakt_v2_oauth
log = logging.getLogger('mpvTraktSync')
TRAKT_ID_CACHE_JSON = 'trakt_ids.json'
config = None
last_is_paused = None
last_playback_position = None
last_working_dir = None
last_path = None
last_duration = None
last_file_start_timestamp = None
is_local_state_dirty = True
next_sync_timer = None
next_regular_timer = None
def on_command_response(monitor, command, response):
log.debug('on_command_response(%s, %s)' % (command, response))
global last_is_paused, last_playback_position, last_working_dir, last_path, last_duration, last_file_start_timestamp
global next_sync_timer
last_command_elements = command['command']
if last_command_elements[0] == 'get_property':
if response['error'] != 'success':
log.warning('Command %s failed: %s', command, response)
else:
if last_command_elements[1] == 'pause':
last_is_paused = response['data']
if not last_is_paused and last_file_start_timestamp is None:
last_file_start_timestamp = time.time()
elif last_command_elements[1] == 'percent-pos':
last_playback_position = response['data']
elif last_command_elements[1] == 'working-directory':
last_working_dir = response['data']
elif last_command_elements[1] == 'path':
last_path = response['data']
elif last_command_elements[1] == 'duration':
last_duration = response['data']
log.debug('is_local_state_dirty: %s\nlast_is_paused: %s\nlast_playback_position: %s\nlast_working_dir: %s\nlast_path: %s\nlast_duration: %s',
is_local_state_dirty, last_is_paused, last_playback_position, last_working_dir, last_path, last_duration)
if is_local_state_dirty \
and last_is_paused is not None \
and last_playback_position is not None \
and last_working_dir is not None \
and last_path is not None \
and last_duration is not None:
if next_sync_timer is not None:
next_sync_timer.cancel()
next_sync_timer = threading.Timer(config['seconds_between_mpv_event_and_trakt_sync'], sync_to_trakt,
(last_is_paused, last_playback_position, last_working_dir, last_path,
last_duration, last_file_start_timestamp, False))
next_sync_timer.start()
def on_event(monitor, event):
log.debug('on_event(%s)' % (event))
event_name = event['event']
# when a new file starts, act as if a new mpv instance got connected
if event_name == 'start-file':
on_disconnected()
on_connected(monitor)
elif event_name == 'pause' or event_name == 'unpause' or event_name == 'seek':
global is_local_state_dirty
is_local_state_dirty = True
issue_scrobble_commands(monitor)
def on_connected(monitor):
log.debug('on_connected()')
global is_local_state_dirty
is_local_state_dirty = True
issue_scrobble_commands(monitor)
def on_disconnected():
log.debug('on_disconnected()')
global last_is_paused, last_playback_position, last_working_dir, last_path, last_duration, last_file_start_timestamp
global next_sync_timer, next_regular_timer
global is_local_state_dirty
if next_sync_timer is not None:
next_sync_timer.cancel()
if next_regular_timer is not None:
next_regular_timer.cancel()
if last_is_paused is not None \
and last_playback_position is not None \
and last_working_dir is not None \
and last_path is not None \
and last_duration is not None:
threading.Thread(target=sync_to_trakt, args=(
last_is_paused, last_playback_position, last_working_dir, last_path, last_duration,
last_file_start_timestamp, True)).start()
last_is_paused = None
last_playback_position = None
last_working_dir = None
last_path = None
last_duration = None
last_file_start_timestamp = None
is_local_state_dirty = True
def issue_scrobble_commands(monitor):
monitor.send_get_property_command('working-directory')
monitor.send_get_property_command('path')
monitor.send_get_property_command('percent-pos')
monitor.send_get_property_command('pause')
monitor.send_get_property_command('duration')
schedule_regular_timer(monitor)
def schedule_regular_timer(monitor):
global next_regular_timer
if next_regular_timer is not None:
next_regular_timer.cancel()
next_regular_timer = threading.Timer(config['seconds_between_regular_get_property_commands'],
issue_scrobble_commands, [monitor])
next_regular_timer.start()
def is_finished(playback_position, duration, start_time):
if start_time is not None:
watch_time = time.time() - start_time
# only consider a session finished if
# at least a minimal playback position is reached
# and
# the session is running long enough
if playback_position >= config['percent_minimal_playback_position_before_scrobble'] \
and watch_time >= duration * config['factor_must_watch_before_scrobble']:
return True
return False
def is_url(url):
try:
return urllib.parse.urlparse(url).scheme != ''
except SyntaxError:
return False
def sync_to_trakt(is_paused, playback_position, working_dir, path, duration, start_time, mpv_closed):
log.debug('sync_to_trakt(%s, %d, %s, %s, %d, %d, %s)' % (is_paused, playback_position, working_dir, path, duration, start_time, mpv_closed))
do_sync = False
if not is_url(path) and not os.path.isabs(path):
# If mpv is not started via double click from a file manager, but rather from a terminal,
# the path to the video file is relative and not absolute. For the monitored_directories thing
# to work, we need an absolute path. that's why we need the working dir
path = os.path.join(working_dir, path)
for monitored_directory in config['monitored_directories']:
if path.startswith(monitored_directory):
do_sync = True
break
# empty monitored_directories means: always sync
if len(config['monitored_directories']) == 0:
do_sync = True
for excluded_directory in config['excluded_directories']:
if path.startswith(excluded_directory):
do_sync = False
break
log.debug('do_sync = %s' % (do_sync))
if do_sync:
guess = guessit.guessit(path)
log.debug(guess)
data = get_cached_trakt_data(guess)
if data is not None:
data['progress'] = playback_position
data['app_version'] = '1.0.3'
finished = is_finished(playback_position, duration, start_time)
# closed finished paused trakt action
# False False False start
# False False True pause
# False True False start
# False True True pause
# True False False pause
# True False True pause
# True True False stop
# True True True stop
# is equal to:
if mpv_closed:
if finished:
# trakt is closing and finished watching
# trakt action: stop
url = 'https://api.trakt.tv/scrobble/stop'
else:
# closed before finished watching
# trakt action: pause
url = 'https://api.trakt.tv/scrobble/pause'
elif is_paused:
# paused, while still open
# trakt action: pause
url = 'https://api.trakt.tv/scrobble/pause'
else:
# watching right now
# trakt action: start
url = 'https://api.trakt.tv/scrobble/start'
req = requests.post(url,
json=data,
headers={'trakt-api-version': '2', 'trakt-api-key': trakt_key_holder.get_id(),
'Authorization': 'Bearer ' + trakt_v2_oauth.get_access_token()})
log.info('%s %s %s', url, req.status_code, req.text)
if 200 <= req.status_code < 300:
global is_local_state_dirty
is_local_state_dirty = False
def choose_trakt_id(data, guess):
if guess['type'] == 'episode':
kind = 'show'
else:
kind = 'movie'
## the first ordered show that matches the year is the most likely true match
if 'year' in guess:
for item in data:
if item['type'] == kind:
if item[kind]['year'] == guess['year']:
return item[kind]['ids']['trakt']
else:
return data[0][kind]['ids']['trakt']
def get_cached_trakt_data(guess):
# load cached ids
if os.path.isfile(TRAKT_ID_CACHE_JSON):
with open(TRAKT_ID_CACHE_JSON) as file:
id_cache = json.load(file)
else:
id_cache = {
'movies': {},
'shows': {}
}
# constructing data to be sent to trakt
# if show or movie name is not found in id_cache, request trakt id from trakt API and cache it.
# then assign dict to data, which has the structure of the json trakt expects for a scrobble call
data = None
if guess['type'] == 'episode':
print(guess)
if 'episode' not in guess and 'episode_title' in guess:
guess['episode'] = guess['episode_title']
if guess['title'].lower() not in id_cache['shows']:
log.info('requesting trakt id for show ' + guess['title'])
req = requests.get('https://api.trakt.tv/search/show?field=title&query=' + guess['title'],
headers={'trakt-api-version': '2', 'trakt-api-key': trakt_key_holder.get_id()})
if 200 <= req.status_code < 300 and len(req.json()) > 0:
trakt_id = choose_trakt_id(req.json(), guess)
else:
# write n/a into cache, so that unknown shows are only requested once.
# without n/a unknown shows would be requested each time get_cached_trakt_data_from_guess() is called
trakt_id = 'n/a'
log.warning('trakt request failed or unknown show ' + str(guess))
id_cache['shows'][guess['title'].lower()] = trakt_id
trakt_id = id_cache['shows'][guess['title'].lower()]
if trakt_id != 'n/a':
data = {'show': {'ids': {'trakt': id_cache['shows'][guess['title'].lower()]}},
'episode': {'season': guess['season'], 'number': guess['episode']}}
elif guess['type'] == 'movie':
if guess['title'].lower() not in id_cache['movies']:
log.info('requesting trakt id for movie ' + guess['title'])
req = requests.get('https://api.trakt.tv/search/movie?field=title&query=' + guess['title'],
headers={'trakt-api-version': '2', 'trakt-api-key': trakt_key_holder.get_id()})
if 200 <= req.status_code < 300 and len(req.json()) > 0:
trakt_id = choose_trakt_id(req.json(), guess)
else:
# write n/a into cache, so that unknown movies are only requested once.
# without n/a unknown movies would be requested each time get_cached_trakt_data_from_guess() is called
trakt_id = 'n/a'
log.warning('trakt request failed or unknown movie ' + str(guess))
id_cache['movies'][guess['title'].lower()] = trakt_id
trakt_id = id_cache['movies'][guess['title'].lower()]
if trakt_id != 'n/a':
data = {'movie': {'ids': {'trakt': id_cache['movies'][guess['title'].lower()]}}}
else:
log.warning('Unknown guessit type ' + str(guess))
# update cached ids file
with open(TRAKT_ID_CACHE_JSON, mode='w') as file:
json.dump(id_cache, file)
return data
def main():
log.info('launched')
with open('config.json') as file:
global config
config = json.load(file)
monitor = mpv.MpvMonitor.create(on_connected, on_event, on_command_response, on_disconnected)
try:
trakt_v2_oauth.get_access_token() # prompts authentication, if necessary
while True:
if monitor.can_open():
# call monitor.run() as a daemon thread, so that all SIGTERMs are handled here
# Daemon threads die automatically, when the main process ends
thread = threading.Thread(target=monitor.run, daemon=True)
thread.start()
thread.join()
# If thread joins, mpv was closed.
log.info('mpv closed')
else:
# mpv not open
# sleep before next attempt
time.sleep(config['seconds_between_mpv_running_checks'])
except KeyboardInterrupt:
log.info('terminating')
logging.shutdown()
def register_exception_handler():
def error_catcher(*exc_info):
log.critical("Unhandled exception", exc_info=exc_info)
sys.excepthook = error_catcher
# from http://stackoverflow.com/a/31622038
"""
Workaround for `sys.excepthook` thread bug from:
http://bugs.python.org/issue1230540
Call once from the main thread before creating any threads.
"""
init_original = threading.Thread.__init__
def init(self, *args, **kwargs):
init_original(self, *args, **kwargs)
run_original = self.run
def run_with_except_hook(*args2, **kwargs2):
try:
run_original(*args2, **kwargs2)
except Exception:
sys.excepthook(*sys.exc_info())
self.run = run_with_except_hook
threading.Thread.__init__ = init
if __name__ == '__main__':
import logging.config
logging.config.fileConfig('log.conf')
register_exception_handler()
main()