scrobblers.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. import logging
  2. import re
  3. from typing import Optional
  4. from datetime import datetime
  5. import pendulum
  6. import pytz
  7. from beers.models import Beer
  8. from boardgames.models import BoardGame
  9. from books.models import Book
  10. from dateutil.parser import parse
  11. from django.conf import settings
  12. from django.utils import timezone
  13. from locations.constants import LOCATION_PROVIDERS
  14. from locations.models import GeoLocation
  15. from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
  16. from music.models import Track
  17. from music.utils import get_or_create_track
  18. from podcasts.utils import get_or_create_podcast
  19. from scrobbles.constants import (
  20. JELLYFIN_AUDIO_ITEM_TYPES,
  21. MANUAL_SCROBBLE_FNS,
  22. SCROBBLE_CONTENT_URLS,
  23. )
  24. from scrobbles.models import Scrobble
  25. from sports.models import SportEvent
  26. from sports.thesportsdb import lookup_event_from_thesportsdb
  27. from tasks.models import Task
  28. from videogames.howlongtobeat import lookup_game_from_hltb
  29. from videogames.models import VideoGame
  30. from videos.models import Video
  31. from webpages.models import WebPage
  32. from vrobbler.apps.tasks.constants import (
  33. TODOIST_TITLE_PREFIX_LABELS,
  34. TODOIST_TITLE_SUFFIX_LABELS,
  35. )
  36. logger = logging.getLogger(__name__)
  37. def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
  38. media_type = Scrobble.MediaType.TRACK
  39. if "podcast" in post_data.get("mopidy_uri", ""):
  40. media_type = Scrobble.MediaType.PODCAST_EPISODE
  41. logger.info(
  42. "[mopidy_webhook] called",
  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. null_position_on_progress = (
  75. post_data.get("PlaybackPosition") == "00:00:00"
  76. and post_data.get("NotificationType") == "PlaybackProgress"
  77. )
  78. # Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
  79. if null_position_on_progress:
  80. logger.info(
  81. "[jellyfin_scrobble_media] no playback position tick, aborting",
  82. extra={"post_data": post_data},
  83. )
  84. return
  85. timestamp = parse(
  86. post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "")
  87. ).replace(tzinfo=pytz.utc)
  88. playback_position_seconds = int(
  89. post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
  90. / 10000000
  91. )
  92. if media_type == Scrobble.MediaType.VIDEO:
  93. media_obj = Video.find_or_create(post_data)
  94. else:
  95. media_obj = get_or_create_track(
  96. post_data, post_keys=JELLYFIN_POST_KEYS
  97. )
  98. # A hack because we don't worry about updating music ... we either finish it or we don't
  99. playback_position_seconds = 0
  100. if not media_obj:
  101. logger.info(
  102. "[jellyfin_scrobble_media] no video found from POST data",
  103. extra={"post_data": post_data},
  104. )
  105. return
  106. playback_status = "resumed"
  107. if post_data.get("IsPaused"):
  108. playback_status = "paused"
  109. elif post_data.get("NotificationType") == "PlaybackStop":
  110. playback_status = "stopped"
  111. return media_obj.scrobble_for_user(
  112. user_id,
  113. source=post_data.get(JELLYFIN_POST_KEYS.get("SOURCE")),
  114. playback_position_seconds=playback_position_seconds,
  115. status=playback_status,
  116. )
  117. def web_scrobbler_scrobble_media(
  118. youtube_id: str, user_id: int, status: str = "started"
  119. ) -> Optional[Scrobble]:
  120. video = Video.get_from_youtube_id(youtube_id)
  121. return video.scrobble_for_user(user_id, status, source="Web Scrobbler")
  122. def manual_scrobble_video(video_id: str, user_id: int):
  123. if "tt" in video_id:
  124. video = Video.get_from_imdb_id(video_id)
  125. else:
  126. video = Video.get_from_youtube_id(video_id)
  127. # When manually scrobbling, try finding a source from the series
  128. source = "Vrobbler"
  129. if video.tv_series:
  130. source = video.tv_series.preferred_source
  131. scrobble_dict = {
  132. "user_id": user_id,
  133. "timestamp": timezone.now(),
  134. "playback_position_seconds": 0,
  135. "source": source,
  136. }
  137. logger.info(
  138. "[scrobblers] manual video scrobble request received",
  139. extra={
  140. "video_id": video.id,
  141. "user_id": user_id,
  142. "scrobble_dict": scrobble_dict,
  143. "media_type": Scrobble.MediaType.VIDEO,
  144. },
  145. )
  146. return Scrobble.create_or_update(video, user_id, scrobble_dict)
  147. def manual_scrobble_event(thesportsdb_id: str, user_id: int):
  148. data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
  149. event = SportEvent.find_or_create(data_dict)
  150. scrobble_dict = {
  151. "user_id": user_id,
  152. "timestamp": timezone.now(),
  153. "playback_position_seconds": 0,
  154. "source": "TheSportsDB",
  155. }
  156. return Scrobble.create_or_update(event, user_id, scrobble_dict)
  157. def manual_scrobble_video_game(hltb_id: str, user_id: int):
  158. game = VideoGame.objects.filter(hltb_id=hltb_id).first()
  159. if not game:
  160. data_dict = lookup_game_from_hltb(hltb_id)
  161. if not data_dict:
  162. logger.info(
  163. "[manual_scrobble_video_game] game not found on hltb",
  164. extra={
  165. "hltb_id": hltb_id,
  166. "user_id": user_id,
  167. "media_type": Scrobble.MediaType.VIDEO_GAME,
  168. },
  169. )
  170. return
  171. game = VideoGame.find_or_create(data_dict)
  172. scrobble_dict = {
  173. "user_id": user_id,
  174. "timestamp": timezone.now(),
  175. "playback_position_seconds": 0,
  176. "source": "Vrobbler",
  177. "long_play_complete": False,
  178. }
  179. logger.info(
  180. "[scrobblers] manual video game scrobble request received",
  181. extra={
  182. "videogame_id": game.id,
  183. "user_id": user_id,
  184. "scrobble_dict": scrobble_dict,
  185. "media_type": Scrobble.MediaType.VIDEO_GAME,
  186. },
  187. )
  188. return Scrobble.create_or_update(game, user_id, scrobble_dict)
  189. def manual_scrobble_book(openlibrary_id: str, user_id: int):
  190. book = Book.find_or_create(openlibrary_id)
  191. scrobble_dict = {
  192. "user_id": user_id,
  193. "timestamp": timezone.now(),
  194. "playback_position_seconds": 0,
  195. "source": "Vrobbler",
  196. "long_play_complete": False,
  197. }
  198. logger.info(
  199. "[scrobblers] manual book scrobble request received",
  200. extra={
  201. "book_id": book.id,
  202. "user_id": user_id,
  203. "scrobble_dict": scrobble_dict,
  204. "media_type": Scrobble.MediaType.BOOK,
  205. },
  206. )
  207. return Scrobble.create_or_update(book, user_id, scrobble_dict)
  208. def manual_scrobble_board_game(bggeek_id: str, user_id: int):
  209. boardgame = BoardGame.find_or_create(bggeek_id)
  210. if not boardgame:
  211. logger.error(f"No board game found for ID {bggeek_id}")
  212. return
  213. scrobble_dict = {
  214. "user_id": user_id,
  215. "timestamp": timezone.now(),
  216. "playback_position_seconds": 0,
  217. "source": "Vrobbler",
  218. }
  219. logger.info(
  220. "[vrobbler-scrobble] board game scrobble request received",
  221. extra={
  222. "boardgame_id": boardgame.id,
  223. "user_id": user_id,
  224. "scrobble_dict": scrobble_dict,
  225. "media_type": Scrobble.MediaType.BOARD_GAME,
  226. },
  227. )
  228. return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
  229. def manual_scrobble_from_url(url: str, user_id: int) -> Scrobble:
  230. """We have scrobblable media URLs, and then any other webpages that
  231. we want to scrobble as a media type in and of itself. This checks whether
  232. we know about the content type, and routes it to the appropriate media
  233. scrobbler. Otherwise, return nothing."""
  234. content_key = ""
  235. try:
  236. domain = url.split("//")[-1].split("/")[0]
  237. except IndexError:
  238. domain = None
  239. for key, content_url in SCROBBLE_CONTENT_URLS.items():
  240. if domain in content_url:
  241. content_key = key
  242. item_id = None
  243. if not content_key:
  244. content_key = "-w"
  245. item_id = url
  246. # Try generic search for any URL with digit-based IDs
  247. if not item_id:
  248. try:
  249. item_id = re.findall("\d+", url)[0]
  250. except IndexError:
  251. pass
  252. if content_key == "-i":
  253. item_id = url.split("v=")[1].split("&")[0]
  254. scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
  255. return eval(scrobble_fn)(item_id, user_id)
  256. def todoist_scrobble_task_finish(
  257. todoist_task: dict, user_id: int
  258. ) -> Optional[Scrobble]:
  259. scrobble = Scrobble.objects.filter(
  260. user_id=user_id,
  261. log__todoist_id=todoist_task.get("todoist_id"),
  262. in_progress=True,
  263. played_to_completion=False,
  264. ).first()
  265. if not scrobble:
  266. logger.info(
  267. "[todoist_scrobble_task_finish] todoist webhook finish called on missing task"
  268. )
  269. return
  270. scrobble.stop(force_finish=True)
  271. return scrobble
  272. def todoist_scrobble_update_task(
  273. todoist_note: dict, user_id: int
  274. ) -> Optional[Scrobble]:
  275. scrobble = Scrobble.objects.filter(
  276. in_progress=True,
  277. user_id=user_id,
  278. log__todoist_id=todoist_note.get("task_id"),
  279. ).first()
  280. if not scrobble:
  281. logger.info(
  282. "[todoist_scrobble_update_task] no task found",
  283. extra={
  284. "todoist_note": todoist_note,
  285. "user_id": user_id,
  286. "media_type": Scrobble.MediaType.TASK,
  287. },
  288. )
  289. return
  290. existing_notes = scrobble.log.get("notes", {})
  291. existing_notes[todoist_note.get("todoist_id")] = todoist_note.get("notes")
  292. scrobble.log["notes"] = existing_notes
  293. scrobble.save(update_fields=["log"])
  294. logger.info(
  295. "[todoist_scrobble_update_task] todoist note added",
  296. extra={
  297. "todoist_note": todoist_note,
  298. "user_id": user_id,
  299. "media_type": Scrobble.MediaType.TASK,
  300. },
  301. )
  302. return scrobble
  303. def todoist_scrobble_task(
  304. todoist_task: dict,
  305. user_id: int,
  306. started: bool = False,
  307. stopped: bool = False,
  308. ) -> Scrobble:
  309. prefix = ""
  310. suffix = ""
  311. for label in todoist_task["todoist_label_list"]:
  312. if label in TODOIST_TITLE_PREFIX_LABELS:
  313. prefix = label
  314. if label in TODOIST_TITLE_SUFFIX_LABELS:
  315. suffix = label
  316. if not prefix and suffix:
  317. logger.warning(
  318. "Missing a prefix and suffix tag for task",
  319. extra={"todoist_scrobble_task": todoist_task},
  320. )
  321. title = " ".join([prefix.capitalize(), suffix.capitalize()])
  322. task = Task.find_or_create(title)
  323. timestamp = pendulum.parse(todoist_task.get("updated_at", timezone.now()))
  324. in_progress_scrobble = Scrobble.objects.filter(
  325. user_id=user_id,
  326. in_progress=True,
  327. log__todoist_id=todoist_task.get("todoist_id"),
  328. task=task,
  329. ).last()
  330. if not in_progress_scrobble and stopped:
  331. logger.info(
  332. "[todoist_scrobble_task] cannot stop already stopped task",
  333. extra={
  334. "todoist_type": todoist_task["todoist_type"],
  335. "todoist_event": todoist_task["todoist_event"],
  336. "todoist_id": todoist_task["todoist_id"],
  337. },
  338. )
  339. return
  340. if in_progress_scrobble and started:
  341. logger.info(
  342. "[todoist_scrobble_task] cannot start already started task",
  343. extra={
  344. "todoist_type": todoist_task["todoist_type"],
  345. "todoist_event": todoist_task["todoist_event"],
  346. "todoist_id": todoist_task["todoist_id"],
  347. },
  348. )
  349. return in_progress_scrobble
  350. # Finish an in-progress scrobble
  351. if in_progress_scrobble and stopped:
  352. logger.info(
  353. "[todoist_scrobble_task] finishing",
  354. extra={
  355. "todoist_type": todoist_task["todoist_type"],
  356. "todoist_event": todoist_task["todoist_event"],
  357. "todoist_id": todoist_task["todoist_id"],
  358. },
  359. )
  360. return todoist_scrobble_task_finish(todoist_task, user_id)
  361. # Default to create new scrobble "if not in_progress_scrobble and in_progress_in_todoist"
  362. # TODO Should use updated_at from TOdoist, but parsing isn't working
  363. scrobble_dict = {
  364. "user_id": user_id,
  365. "timestamp": timestamp,
  366. "playback_position_seconds": 0,
  367. "source": "Todoist",
  368. "log": todoist_task,
  369. }
  370. logger.info(
  371. "[todoist_scrobble_task] creating",
  372. extra={
  373. "task_id": task.id,
  374. "user_id": user_id,
  375. "scrobble_dict": scrobble_dict,
  376. "media_type": Scrobble.MediaType.TASK,
  377. },
  378. )
  379. scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
  380. return scrobble
  381. def manual_scrobble_task(url: str, user_id: int):
  382. source_id = re.findall("\d+", url)[0]
  383. if "todoist" in url:
  384. source = "Todoist"
  385. title = "Generic Todoist task"
  386. description = " ".join(url.split("/")[-1].split("-")[:-1]).capitalize()
  387. task = Task.find_or_create(title)
  388. scrobble_dict = {
  389. "user_id": user_id,
  390. "timestamp": timezone.now(),
  391. "playback_position_seconds": 0,
  392. "source": source,
  393. "log": {"description": description, "source_id": source_id},
  394. }
  395. logger.info(
  396. "[vrobbler-scrobble] webpage scrobble request received",
  397. extra={
  398. "task_id": task.id,
  399. "user_id": user_id,
  400. "scrobble_dict": scrobble_dict,
  401. "media_type": Scrobble.MediaType.WEBPAGE,
  402. },
  403. )
  404. scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
  405. return scrobble
  406. def manual_scrobble_webpage(url: str, user_id: int):
  407. webpage = WebPage.find_or_create({"url": url})
  408. scrobble_dict = {
  409. "user_id": user_id,
  410. "timestamp": timezone.now(),
  411. "playback_position_seconds": 0,
  412. "source": "Vrobbler",
  413. }
  414. logger.info(
  415. "[vrobbler-scrobble] webpage scrobble request received",
  416. extra={
  417. "webpage_id": webpage.id,
  418. "user_id": user_id,
  419. "scrobble_dict": scrobble_dict,
  420. "media_type": Scrobble.MediaType.WEBPAGE,
  421. },
  422. )
  423. scrobble = Scrobble.create_or_update(webpage, user_id, scrobble_dict)
  424. # possibly async this?
  425. scrobble.push_to_archivebox()
  426. return scrobble
  427. def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
  428. location = GeoLocation.find_or_create(data_dict)
  429. timestamp = pendulum.parse(data_dict.get("time", timezone.now()))
  430. extra_data = {
  431. "user_id": user_id,
  432. "timestamp": timestamp,
  433. "source": "GPSLogger",
  434. "media_type": Scrobble.MediaType.GEO_LOCATION,
  435. }
  436. scrobble = Scrobble.create_or_update_location(
  437. location,
  438. extra_data,
  439. user_id,
  440. )
  441. provider = LOCATION_PROVIDERS[data_dict.get("prov")]
  442. if "gps_updates" not in scrobble.log.keys():
  443. scrobble.log["gps_updates"] = []
  444. scrobble.log["gps_updates"].append(
  445. {
  446. "timestamp": data_dict.get("time"),
  447. "position_provider": provider,
  448. }
  449. )
  450. if scrobble.timestamp:
  451. scrobble.playback_position_seconds = (
  452. timezone.now() - scrobble.timestamp
  453. ).seconds
  454. scrobble.save(update_fields=["log", "playback_position_seconds"])
  455. logger.info(
  456. "[gpslogger_webhook] gpslogger scrobble request received",
  457. extra={
  458. "scrobble_id": scrobble.id,
  459. "provider": provider,
  460. "user_id": user_id,
  461. "timestamp": extra_data.get("timestamp"),
  462. "raw_timestamp": data_dict.get("time"),
  463. "media_type": Scrobble.MediaType.GEO_LOCATION,
  464. },
  465. )
  466. return scrobble
  467. def web_scrobbler_scrobble_video_or_song(
  468. data_dict: dict, user_id: Optional[int]
  469. ) -> Scrobble:
  470. # We're not going to create music tracks, because the only time
  471. # we'd hit this is if we're listening to a concert or something.
  472. artist_name = data_dict.get("artist")
  473. track_name = data_dict.get("track")
  474. tracks = Track.objects.filter(
  475. artist__name=data_dict.get("artist"), title=data_dict.get("track")
  476. )
  477. if tracks.count() > 1:
  478. logger.warning(
  479. "Multiple tracks found for Web Scrobbler",
  480. extra={"artist": artist_name, "track": track_name},
  481. )
  482. track = tracks.first()
  483. # No track found, create a Video
  484. if not track:
  485. Video.get_from_youtube_id()
  486. # Now we run off a scrobble
  487. mopidy_data = {
  488. "user_id": user_id,
  489. "timestamp": timezone.now(),
  490. "playback_position_seconds": data_dict.get("playback_time_ticks"),
  491. "source": "Mopidy",
  492. "mopidy_status": data_dict.get("status"),
  493. }
  494. logger.info(
  495. "[scrobblers] webhook mopidy scrobble request received",
  496. extra={
  497. "episode_id": episode.id if episode else None,
  498. "user_id": user_id,
  499. "scrobble_dict": mopidy_data,
  500. "media_type": Scrobble.MediaType.PODCAST_EPISODE,
  501. },
  502. )
  503. scrobble = None
  504. if episode:
  505. scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
  506. return scrobble
  507. def manual_scrobble_beer(untappd_id: str, user_id: int):
  508. beer = Beer.find_or_create(untappd_id)
  509. if not beer:
  510. logger.error(f"No beer found for Untappd ID {untappd_id}")
  511. return
  512. scrobble_dict = {
  513. "user_id": user_id,
  514. "timestamp": timezone.now(),
  515. "playback_position_seconds": 0,
  516. "source": "Vrobbler",
  517. }
  518. logger.info(
  519. "[vrobbler-scrobble] beer scrobble request received",
  520. extra={
  521. "beer_id": beer.id,
  522. "user_id": user_id,
  523. "scrobble_dict": scrobble_dict,
  524. "media_type": Scrobble.MediaType.BEER,
  525. },
  526. )
  527. # TODO Kick out a process to enrich the media here, and in every scrobble event
  528. return Scrobble.create_or_update(beer, user_id, scrobble_dict)