scrobblers.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  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 vrobbler.apps.scrobbles.constants import (
  25. MANUAL_SCROBBLE_FNS,
  26. SCROBBLE_CONTENT_URLS,
  27. )
  28. from webpages.models import WebPage
  29. logger = logging.getLogger(__name__)
  30. def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
  31. media_type = Scrobble.MediaType.TRACK
  32. if "podcast" in post_data.get("mopidy_uri", ""):
  33. media_type = Scrobble.MediaType.PODCAST_EPISODE
  34. if settings.DUMP_REQUEST_DATA:
  35. print("MOPIDY_DATA: ", post_data)
  36. logger.info(
  37. "[scrobblers] webhook mopidy scrobble request received",
  38. extra={
  39. "user_id": user_id,
  40. "post_data": post_data,
  41. "media_type": media_type,
  42. },
  43. )
  44. if media_type == Scrobble.MediaType.PODCAST_EPISODE:
  45. media_obj = get_or_create_podcast(post_data)
  46. else:
  47. media_obj = get_or_create_track(post_data, MOPIDY_POST_KEYS)
  48. log = {}
  49. try:
  50. log = {"mopidy_source": post_data.get("mopidy_ur").split(":")[0]}
  51. except IndexError:
  52. pass
  53. return media_obj.scrobble_for_user(
  54. user_id,
  55. source="Mopidy",
  56. playback_position_seconds=int(
  57. post_data.get(MOPIDY_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
  58. / 1000
  59. ),
  60. status=post_data.get(MOPIDY_POST_KEYS.get("STATUS"), ""),
  61. log=log,
  62. )
  63. def jellyfin_scrobble_media(
  64. post_data: dict, user_id: int
  65. ) -> Optional[Scrobble]:
  66. media_type = Scrobble.MediaType.VIDEO
  67. if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES:
  68. media_type = Scrobble.MediaType.TRACK
  69. if settings.DUMP_REQUEST_DATA:
  70. print("JELLYFIN_DATA: ", post_data)
  71. logger.info(
  72. "[jellyfin_scrobble_media] called",
  73. extra={
  74. "user_id": user_id,
  75. "post_data": post_data,
  76. "media_type": media_type,
  77. },
  78. )
  79. null_position_on_progress = (
  80. post_data.get("PlaybackPosition") == "00:00:00"
  81. and post_data.get("NotificationType") == "PlaybackProgress"
  82. )
  83. # Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
  84. if null_position_on_progress:
  85. logger.info(
  86. "[jellyfin_scrobble_media] no playback position tick, aborting",
  87. extra={"post_data": post_data},
  88. )
  89. return
  90. timestamp = parse(
  91. post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "")
  92. ).replace(tzinfo=pytz.utc)
  93. playback_position_seconds = int(
  94. post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
  95. / 10000000
  96. )
  97. if media_type == Scrobble.MediaType.VIDEO:
  98. media_obj = Video.find_or_create(post_data)
  99. else:
  100. media_obj = get_or_create_track(
  101. post_data, post_keys=JELLYFIN_POST_KEYS
  102. )
  103. # A hack because we don't worry about updating music ... we either finish it or we don't
  104. playback_position_seconds = 0
  105. if not media_obj:
  106. logger.info(
  107. "[jellyfin_scrobble_media] no video found from POST data",
  108. extra={"post_data": post_data},
  109. )
  110. return
  111. playback_status = "resumed"
  112. if post_data.get("IsPaused"):
  113. playback_status = "paused"
  114. elif post_data.get("NotificationType") == "PlaybackStop":
  115. playback_status = "stopped"
  116. return media_obj.scrobble_for_user(
  117. user_id,
  118. source=post_data.get(JELLYFIN_POST_KEYS.get("SOURCE")),
  119. playback_position_seconds=playback_position_seconds,
  120. status=playback_status,
  121. )
  122. def manual_scrobble_video(imdb_id: str, user_id: int):
  123. video = Video.find_or_create({JELLYFIN_POST_KEYS.get("IMDB_ID"): imdb_id})
  124. # When manually scrobbling, try finding a source from the series
  125. source = "Vrobbler"
  126. if video.tv_series:
  127. source = video.tv_series.preferred_source
  128. scrobble_dict = {
  129. "user_id": user_id,
  130. "timestamp": timezone.now(),
  131. "playback_position_seconds": 0,
  132. "source": source,
  133. }
  134. logger.info(
  135. "[scrobblers] manual video scrobble request received",
  136. extra={
  137. "video_id": video.id,
  138. "user_id": user_id,
  139. "scrobble_dict": scrobble_dict,
  140. "media_type": Scrobble.MediaType.VIDEO,
  141. },
  142. )
  143. return Scrobble.create_or_update(video, user_id, scrobble_dict)
  144. def manual_scrobble_event(thesportsdb_id: str, user_id: int):
  145. data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
  146. event = SportEvent.find_or_create(data_dict)
  147. scrobble_dict = {
  148. "user_id": user_id,
  149. "timestamp": timezone.now(),
  150. "playback_position_seconds": 0,
  151. "source": "TheSportsDB",
  152. }
  153. return Scrobble.create_or_update(event, user_id, scrobble_dict)
  154. def manual_scrobble_video_game(hltb_id: str, user_id: int):
  155. game = VideoGame.objects.filter(hltb_id=hltb_id).first()
  156. if not game:
  157. data_dict = lookup_game_from_hltb(hltb_id)
  158. if not data_dict:
  159. logger.info(
  160. "[manual_scrobble_video_game] game not found on hltb",
  161. extra={
  162. "hltb_id": hltb_id,
  163. "user_id": user_id,
  164. "media_type": Scrobble.MediaType.VIDEO_GAME,
  165. },
  166. )
  167. return
  168. game = VideoGame.find_or_create(data_dict)
  169. scrobble_dict = {
  170. "user_id": user_id,
  171. "timestamp": timezone.now(),
  172. "playback_position_seconds": 0,
  173. "source": "Vrobbler",
  174. "long_play_complete": False,
  175. }
  176. logger.info(
  177. "[scrobblers] manual video game scrobble request received",
  178. extra={
  179. "videogame_id": game.id,
  180. "user_id": user_id,
  181. "scrobble_dict": scrobble_dict,
  182. "media_type": Scrobble.MediaType.VIDEO_GAME,
  183. },
  184. )
  185. return Scrobble.create_or_update(game, user_id, scrobble_dict)
  186. def manual_scrobble_book(openlibrary_id: str, user_id: int):
  187. book = Book.find_or_create(openlibrary_id)
  188. scrobble_dict = {
  189. "user_id": user_id,
  190. "timestamp": timezone.now(),
  191. "playback_position_seconds": 0,
  192. "source": "Vrobbler",
  193. "long_play_complete": False,
  194. }
  195. logger.info(
  196. "[scrobblers] manual book scrobble request received",
  197. extra={
  198. "book_id": book.id,
  199. "user_id": user_id,
  200. "scrobble_dict": scrobble_dict,
  201. "media_type": Scrobble.MediaType.BOOK,
  202. },
  203. )
  204. return Scrobble.create_or_update(book, user_id, scrobble_dict)
  205. def manual_scrobble_board_game(bggeek_id: str, user_id: int):
  206. boardgame = BoardGame.find_or_create(bggeek_id)
  207. if not boardgame:
  208. logger.error(f"No board game found for ID {bggeek_id}")
  209. return
  210. scrobble_dict = {
  211. "user_id": user_id,
  212. "timestamp": timezone.now(),
  213. "playback_position_seconds": 0,
  214. "source": "Vrobbler",
  215. }
  216. logger.info(
  217. "[webhook] board game scrobble request received",
  218. extra={
  219. "boardgame_id": boardgame.id,
  220. "user_id": user_id,
  221. "scrobble_dict": scrobble_dict,
  222. "media_type": Scrobble.MediaType.BOARD_GAME,
  223. },
  224. )
  225. return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
  226. def manual_scrobble_from_url(url: str, user_id: int) -> Scrobble:
  227. content_key = ""
  228. for key, content_url in SCROBBLE_CONTENT_URLS.items():
  229. if content_url in url:
  230. content_key = key
  231. if not content_key:
  232. return
  233. try:
  234. item_id = re.findall("\d+", url)[0]
  235. except IndexError:
  236. item_id = None
  237. scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
  238. return eval(scrobble_fn)(item_id, user_id)
  239. def manual_scrobble_webpage(url: str, user_id: int):
  240. webpage = WebPage.find_or_create({"url": url})
  241. scrobble_dict = {
  242. "user_id": user_id,
  243. "timestamp": timezone.now(),
  244. "playback_position_seconds": 0,
  245. "source": "Vrobbler",
  246. }
  247. logger.info(
  248. "[webhook] webpage scrobble request received",
  249. extra={
  250. "webpage_id": webpage.id,
  251. "user_id": user_id,
  252. "scrobble_dict": scrobble_dict,
  253. "media_type": Scrobble.MediaType.WEBPAGE,
  254. },
  255. )
  256. scrobble = Scrobble.create_or_update(webpage, user_id, scrobble_dict)
  257. # possibly async this?
  258. scrobble.push_to_archivebox()
  259. return scrobble
  260. def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
  261. location = GeoLocation.find_or_create(data_dict)
  262. timestamp = pendulum.parse(data_dict.get("time", timezone.now()))
  263. extra_data = {
  264. "user_id": user_id,
  265. "timestamp": timestamp,
  266. "source": "GPSLogger",
  267. "media_type": Scrobble.MediaType.GEO_LOCATION,
  268. }
  269. scrobble = Scrobble.create_or_update_location(
  270. location,
  271. extra_data,
  272. user_id,
  273. )
  274. provider = LOCATION_PROVIDERS[data_dict.get("prov")]
  275. if "gps_updates" not in scrobble.log.keys():
  276. scrobble.log["gps_updates"] = []
  277. scrobble.log["gps_updates"].append(
  278. {
  279. "timestamp": data_dict.get("time"),
  280. "position_provider": provider,
  281. }
  282. )
  283. if scrobble.timestamp:
  284. scrobble.playback_position_seconds = (
  285. timezone.now() - scrobble.timestamp
  286. ).seconds
  287. scrobble.save(update_fields=["log", "playback_position_seconds"])
  288. logger.info(
  289. "[webhook] gpslogger scrobble request received",
  290. extra={
  291. "scrobble_id": scrobble.id,
  292. "provider": provider,
  293. "user_id": user_id,
  294. "timestamp": extra_data.get("timestamp"),
  295. "raw_timestamp": data_dict.get("time"),
  296. "media_type": Scrobble.MediaType.GEO_LOCATION,
  297. },
  298. )
  299. return scrobble
  300. def web_scrobbler_scrobble_video_or_song(
  301. data_dict: dict, user_id: Optional[int]
  302. ) -> Scrobble:
  303. # We're not going to create music tracks, because the only time
  304. # we'd hit this is if we're listening to a concert or something.
  305. artist_name = data_dict.get("artist")
  306. track_name = data_dict.get("track")
  307. tracks = Track.objects.filter(
  308. artist__name=data_dict.get("artist"), title=data_dict.get("track")
  309. )
  310. if tracks.count() > 1:
  311. logger.warning(
  312. "Multiple tracks found for Web Scrobbler",
  313. extra={"artist": artist_name, "track": track_name},
  314. )
  315. track = tracks.first()
  316. # No track found, create a Video
  317. if not track:
  318. Video.find_or_create(data_dict)
  319. # Now we run off a scrobble
  320. mopidy_data = {
  321. "user_id": user_id,
  322. "timestamp": timezone.now(),
  323. "playback_position_seconds": data_dict.get("playback_time_ticks"),
  324. "source": "Mopidy",
  325. "mopidy_status": data_dict.get("status"),
  326. }
  327. logger.info(
  328. "[scrobblers] webhook mopidy scrobble request received",
  329. extra={
  330. "episode_id": episode.id if episode else None,
  331. "user_id": user_id,
  332. "scrobble_dict": mopidy_data,
  333. "media_type": Scrobble.MediaType.PODCAST_EPISODE,
  334. },
  335. )
  336. scrobble = None
  337. if episode:
  338. scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
  339. return scrobble