scrobblers.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import re
  2. import logging
  3. from typing import Optional
  4. import pendulum
  5. import pytz
  6. from boardgames.models import BoardGame
  7. from books.models import Book
  8. from dateutil.parser import parse
  9. from django.conf import settings
  10. from django.utils import timezone
  11. from locations.constants import LOCATION_PROVIDERS
  12. from locations.models import GeoLocation
  13. from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
  14. from music.models import Track
  15. from music.utils import get_or_create_track
  16. from podcasts.utils import get_or_create_podcast
  17. from scrobbles.constants import JELLYFIN_AUDIO_ITEM_TYPES
  18. from scrobbles.models import Scrobble
  19. from sports.models import SportEvent
  20. from sports.thesportsdb import lookup_event_from_thesportsdb
  21. from videogames.howlongtobeat import lookup_game_from_hltb
  22. from videogames.models import VideoGame
  23. from videos.models import Video
  24. from scrobbles.constants import (
  25. MANUAL_SCROBBLE_FNS,
  26. SCROBBLE_CONTENT_URLS,
  27. )
  28. from tasks.models import Task
  29. from vrobbler.apps.tasks.constants import (
  30. TODOIST_TITLE_PREFIX_LABELS,
  31. TODOIST_TITLE_SUFFIX_LABELS,
  32. )
  33. from webpages.models import WebPage
  34. logger = logging.getLogger(__name__)
  35. def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
  36. media_type = Scrobble.MediaType.TRACK
  37. if "podcast" in post_data.get("mopidy_uri", ""):
  38. media_type = Scrobble.MediaType.PODCAST_EPISODE
  39. if settings.DUMP_REQUEST_DATA:
  40. print("MOPIDY_DATA: ", post_data)
  41. logger.info(
  42. "[scrobblers] webhook mopidy scrobble request received",
  43. extra={
  44. "user_id": user_id,
  45. "post_data": post_data,
  46. "media_type": media_type,
  47. },
  48. )
  49. if media_type == Scrobble.MediaType.PODCAST_EPISODE:
  50. media_obj = get_or_create_podcast(post_data)
  51. else:
  52. media_obj = get_or_create_track(post_data, MOPIDY_POST_KEYS)
  53. log = {}
  54. try:
  55. log = {"mopidy_source": post_data.get("mopidy_uri", "").split(":")[0]}
  56. except IndexError:
  57. pass
  58. return media_obj.scrobble_for_user(
  59. user_id,
  60. source="Mopidy",
  61. playback_position_seconds=int(
  62. post_data.get(MOPIDY_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
  63. / 1000
  64. ),
  65. status=post_data.get(MOPIDY_POST_KEYS.get("STATUS"), ""),
  66. log=log,
  67. )
  68. def jellyfin_scrobble_media(
  69. post_data: dict, user_id: int
  70. ) -> Optional[Scrobble]:
  71. media_type = Scrobble.MediaType.VIDEO
  72. if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES:
  73. media_type = Scrobble.MediaType.TRACK
  74. if settings.DUMP_REQUEST_DATA:
  75. print("JELLYFIN_DATA: ", post_data)
  76. logger.info(
  77. "[jellyfin_scrobble_media] called",
  78. extra={
  79. "user_id": user_id,
  80. "post_data": post_data,
  81. "media_type": media_type,
  82. },
  83. )
  84. null_position_on_progress = (
  85. post_data.get("PlaybackPosition") == "00:00:00"
  86. and post_data.get("NotificationType") == "PlaybackProgress"
  87. )
  88. # Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
  89. if null_position_on_progress:
  90. logger.info(
  91. "[jellyfin_scrobble_media] no playback position tick, aborting",
  92. extra={"post_data": post_data},
  93. )
  94. return
  95. timestamp = parse(
  96. post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "")
  97. ).replace(tzinfo=pytz.utc)
  98. playback_position_seconds = int(
  99. post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
  100. / 10000000
  101. )
  102. if media_type == Scrobble.MediaType.VIDEO:
  103. media_obj = Video.find_or_create(post_data)
  104. else:
  105. media_obj = get_or_create_track(
  106. post_data, post_keys=JELLYFIN_POST_KEYS
  107. )
  108. # A hack because we don't worry about updating music ... we either finish it or we don't
  109. playback_position_seconds = 0
  110. if not media_obj:
  111. logger.info(
  112. "[jellyfin_scrobble_media] no video found from POST data",
  113. extra={"post_data": post_data},
  114. )
  115. return
  116. playback_status = "resumed"
  117. if post_data.get("IsPaused"):
  118. playback_status = "paused"
  119. elif post_data.get("NotificationType") == "PlaybackStop":
  120. playback_status = "stopped"
  121. return media_obj.scrobble_for_user(
  122. user_id,
  123. source=post_data.get(JELLYFIN_POST_KEYS.get("SOURCE")),
  124. playback_position_seconds=playback_position_seconds,
  125. status=playback_status,
  126. )
  127. def manual_scrobble_video(imdb_id: str, user_id: int):
  128. if "tt" not in imdb_id:
  129. imdb_id = "tt" + imdb_id
  130. video = Video.find_or_create({JELLYFIN_POST_KEYS.get("IMDB_ID"): imdb_id})
  131. # When manually scrobbling, try finding a source from the series
  132. source = "Vrobbler"
  133. if video.tv_series:
  134. source = video.tv_series.preferred_source
  135. scrobble_dict = {
  136. "user_id": user_id,
  137. "timestamp": timezone.now(),
  138. "playback_position_seconds": 0,
  139. "source": source,
  140. }
  141. logger.info(
  142. "[scrobblers] manual video scrobble request received",
  143. extra={
  144. "video_id": video.id,
  145. "user_id": user_id,
  146. "scrobble_dict": scrobble_dict,
  147. "media_type": Scrobble.MediaType.VIDEO,
  148. },
  149. )
  150. return Scrobble.create_or_update(video, user_id, scrobble_dict)
  151. def manual_scrobble_event(thesportsdb_id: str, user_id: int):
  152. data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
  153. event = SportEvent.find_or_create(data_dict)
  154. scrobble_dict = {
  155. "user_id": user_id,
  156. "timestamp": timezone.now(),
  157. "playback_position_seconds": 0,
  158. "source": "TheSportsDB",
  159. }
  160. return Scrobble.create_or_update(event, user_id, scrobble_dict)
  161. def manual_scrobble_video_game(hltb_id: str, user_id: int):
  162. game = VideoGame.objects.filter(hltb_id=hltb_id).first()
  163. if not game:
  164. data_dict = lookup_game_from_hltb(hltb_id)
  165. if not data_dict:
  166. logger.info(
  167. "[manual_scrobble_video_game] game not found on hltb",
  168. extra={
  169. "hltb_id": hltb_id,
  170. "user_id": user_id,
  171. "media_type": Scrobble.MediaType.VIDEO_GAME,
  172. },
  173. )
  174. return
  175. game = VideoGame.find_or_create(data_dict)
  176. scrobble_dict = {
  177. "user_id": user_id,
  178. "timestamp": timezone.now(),
  179. "playback_position_seconds": 0,
  180. "source": "Vrobbler",
  181. "long_play_complete": False,
  182. }
  183. logger.info(
  184. "[scrobblers] manual video game scrobble request received",
  185. extra={
  186. "videogame_id": game.id,
  187. "user_id": user_id,
  188. "scrobble_dict": scrobble_dict,
  189. "media_type": Scrobble.MediaType.VIDEO_GAME,
  190. },
  191. )
  192. return Scrobble.create_or_update(game, user_id, scrobble_dict)
  193. def manual_scrobble_book(openlibrary_id: str, user_id: int):
  194. book = Book.find_or_create(openlibrary_id)
  195. scrobble_dict = {
  196. "user_id": user_id,
  197. "timestamp": timezone.now(),
  198. "playback_position_seconds": 0,
  199. "source": "Vrobbler",
  200. "long_play_complete": False,
  201. }
  202. logger.info(
  203. "[scrobblers] manual book scrobble request received",
  204. extra={
  205. "book_id": book.id,
  206. "user_id": user_id,
  207. "scrobble_dict": scrobble_dict,
  208. "media_type": Scrobble.MediaType.BOOK,
  209. },
  210. )
  211. return Scrobble.create_or_update(book, user_id, scrobble_dict)
  212. def manual_scrobble_board_game(bggeek_id: str, user_id: int):
  213. boardgame = BoardGame.find_or_create(bggeek_id)
  214. if not boardgame:
  215. logger.error(f"No board game found for ID {bggeek_id}")
  216. return
  217. scrobble_dict = {
  218. "user_id": user_id,
  219. "timestamp": timezone.now(),
  220. "playback_position_seconds": 0,
  221. "source": "Vrobbler",
  222. }
  223. logger.info(
  224. "[webhook] board game scrobble request received",
  225. extra={
  226. "boardgame_id": boardgame.id,
  227. "user_id": user_id,
  228. "scrobble_dict": scrobble_dict,
  229. "media_type": Scrobble.MediaType.BOARD_GAME,
  230. },
  231. )
  232. return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
  233. def manual_scrobble_from_url(url: str, user_id: int) -> Scrobble:
  234. """We have scrobblable media URLs, and then any other webpages that
  235. we want to scrobble as a media type in and of itself. This checks whether
  236. we know about the content type, and routes it to the appropriate media
  237. scrobbler. Otherwise, return nothing."""
  238. content_key = ""
  239. try:
  240. domain = url.split("//")[-1].split("/")[0]
  241. except IndexError:
  242. domain = None
  243. for key, content_url in SCROBBLE_CONTENT_URLS.items():
  244. if domain in content_url:
  245. content_key = key
  246. item_id = None
  247. if not content_key:
  248. content_key = "-w"
  249. item_id = url
  250. if not item_id:
  251. try:
  252. item_id = re.findall("\d+", url)[0]
  253. except IndexError:
  254. pass
  255. if content_key == "-t":
  256. item_id = url
  257. scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
  258. return eval(scrobble_fn)(item_id, user_id)
  259. def todoist_scrobble_task_finish(todoist_task: dict, user_id: int) -> Scrobble:
  260. scrobble = Scrobble.objects.filter(
  261. user_id=user_id, log__todoist_id=todoist_task.get("todoist_id")
  262. ).first()
  263. if not scrobble.in_progress or scrobble.played_to_completion:
  264. logger.warning(
  265. "[todoist_scrobble_task_finish] todoist webhook finish called on finished task"
  266. )
  267. scrobble.stop(force_finish=True)
  268. return scrobble
  269. def todoist_scrobble_task(todoist_task: dict, user_id: int) -> Scrobble:
  270. prefix = ""
  271. suffix = ""
  272. for label in todoist_task["todoist_label_list"]:
  273. if label in TODOIST_TITLE_PREFIX_LABELS:
  274. prefix = label
  275. if label in TODOIST_TITLE_SUFFIX_LABELS:
  276. suffix = label
  277. title = " ".join([prefix.capitalize(), suffix.capitalize()])
  278. task = Task.find_or_create(title)
  279. scrobble_dict = {
  280. "user_id": user_id,
  281. "timestamp": todoist_task.get("timestamp_utc", timezone.now()),
  282. "playback_position_seconds": 0,
  283. "source": "Todoist Webhook",
  284. "log": todoist_task,
  285. }
  286. logger.info(
  287. "[todoist_scrobble_task] task scrobble request received",
  288. extra={
  289. "task_id": task.id,
  290. "user_id": user_id,
  291. "scrobble_dict": scrobble_dict,
  292. "media_type": Scrobble.MediaType.TASK,
  293. },
  294. )
  295. scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
  296. return scrobble
  297. def manual_scrobble_task(url: str, user_id: int):
  298. source_id = re.findall("\d+", url)[0]
  299. if "todoist" in url:
  300. source = "Todoist"
  301. title = "Generic Todoist task"
  302. description = " ".join(url.split("/")[-1].split("-")[:-1]).capitalize()
  303. task = Task.find_or_create(title)
  304. scrobble_dict = {
  305. "user_id": user_id,
  306. "timestamp": timezone.now(),
  307. "playback_position_seconds": 0,
  308. "source": source,
  309. "log": {"description": description, "source_id": source_id},
  310. }
  311. logger.info(
  312. "[webhook] webpage scrobble request received",
  313. extra={
  314. "task_id": task.id,
  315. "user_id": user_id,
  316. "scrobble_dict": scrobble_dict,
  317. "media_type": Scrobble.MediaType.WEBPAGE,
  318. },
  319. )
  320. scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
  321. return scrobble
  322. def manual_scrobble_webpage(url: str, user_id: int):
  323. webpage = WebPage.find_or_create({"url": url})
  324. scrobble_dict = {
  325. "user_id": user_id,
  326. "timestamp": timezone.now(),
  327. "playback_position_seconds": 0,
  328. "source": "Vrobbler",
  329. }
  330. logger.info(
  331. "[webhook] webpage scrobble request received",
  332. extra={
  333. "webpage_id": webpage.id,
  334. "user_id": user_id,
  335. "scrobble_dict": scrobble_dict,
  336. "media_type": Scrobble.MediaType.WEBPAGE,
  337. },
  338. )
  339. scrobble = Scrobble.create_or_update(webpage, user_id, scrobble_dict)
  340. # possibly async this?
  341. scrobble.push_to_archivebox()
  342. return scrobble
  343. def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
  344. location = GeoLocation.find_or_create(data_dict)
  345. timestamp = pendulum.parse(data_dict.get("time", timezone.now()))
  346. extra_data = {
  347. "user_id": user_id,
  348. "timestamp": timestamp,
  349. "source": "GPSLogger",
  350. "media_type": Scrobble.MediaType.GEO_LOCATION,
  351. }
  352. scrobble = Scrobble.create_or_update_location(
  353. location,
  354. extra_data,
  355. user_id,
  356. )
  357. provider = LOCATION_PROVIDERS[data_dict.get("prov")]
  358. if "gps_updates" not in scrobble.log.keys():
  359. scrobble.log["gps_updates"] = []
  360. scrobble.log["gps_updates"].append(
  361. {
  362. "timestamp": data_dict.get("time"),
  363. "position_provider": provider,
  364. }
  365. )
  366. if scrobble.timestamp:
  367. scrobble.playback_position_seconds = (
  368. timezone.now() - scrobble.timestamp
  369. ).seconds
  370. scrobble.save(update_fields=["log", "playback_position_seconds"])
  371. logger.info(
  372. "[webhook] gpslogger scrobble request received",
  373. extra={
  374. "scrobble_id": scrobble.id,
  375. "provider": provider,
  376. "user_id": user_id,
  377. "timestamp": extra_data.get("timestamp"),
  378. "raw_timestamp": data_dict.get("time"),
  379. "media_type": Scrobble.MediaType.GEO_LOCATION,
  380. },
  381. )
  382. return scrobble
  383. def web_scrobbler_scrobble_video_or_song(
  384. data_dict: dict, user_id: Optional[int]
  385. ) -> Scrobble:
  386. # We're not going to create music tracks, because the only time
  387. # we'd hit this is if we're listening to a concert or something.
  388. artist_name = data_dict.get("artist")
  389. track_name = data_dict.get("track")
  390. tracks = Track.objects.filter(
  391. artist__name=data_dict.get("artist"), title=data_dict.get("track")
  392. )
  393. if tracks.count() > 1:
  394. logger.warning(
  395. "Multiple tracks found for Web Scrobbler",
  396. extra={"artist": artist_name, "track": track_name},
  397. )
  398. track = tracks.first()
  399. # No track found, create a Video
  400. if not track:
  401. Video.find_or_create(data_dict)
  402. # Now we run off a scrobble
  403. mopidy_data = {
  404. "user_id": user_id,
  405. "timestamp": timezone.now(),
  406. "playback_position_seconds": data_dict.get("playback_time_ticks"),
  407. "source": "Mopidy",
  408. "mopidy_status": data_dict.get("status"),
  409. }
  410. logger.info(
  411. "[scrobblers] webhook mopidy scrobble request received",
  412. extra={
  413. "episode_id": episode.id if episode else None,
  414. "user_id": user_id,
  415. "scrobble_dict": mopidy_data,
  416. "media_type": Scrobble.MediaType.PODCAST_EPISODE,
  417. },
  418. )
  419. scrobble = None
  420. if episode:
  421. scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
  422. return scrobble