scrobblers.py 19 KB

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