scrobblers.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. import logging
  2. from typing import Optional
  3. import pendulum
  4. from boardgames.bgg import lookup_boardgame_from_bgg
  5. from boardgames.models import BoardGame
  6. from books.models import Book
  7. from books.openlibrary import lookup_book_from_openlibrary
  8. from dateutil.parser import parse
  9. from django.utils import timezone
  10. from locations.constants import LOCATION_PROVIDERS
  11. from locations.models import GeoLocation
  12. from music.constants import JELLYFIN_POST_KEYS
  13. from music.models import Track
  14. from music.utils import (
  15. get_or_create_album,
  16. get_or_create_artist,
  17. get_or_create_track,
  18. )
  19. from podcasts.models import PodcastEpisode
  20. from scrobbles.models import Scrobble
  21. from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
  22. from sports.models import SportEvent
  23. from sports.thesportsdb import lookup_event_from_thesportsdb
  24. from videogames.howlongtobeat import lookup_game_from_hltb
  25. from videogames.models import VideoGame
  26. from videos.models import Video
  27. from webpages.models import WebPage
  28. logger = logging.getLogger(__name__)
  29. def mopidy_scrobble_podcast(
  30. data_dict: dict, user_id: Optional[int]
  31. ) -> Scrobble:
  32. mopidy_uri = data_dict.get("mopidy_uri", "")
  33. parsed_data = parse_mopidy_uri(mopidy_uri)
  34. producer_dict = {"name": data_dict.get("artist")}
  35. podcast_name = data_dict.get("album")
  36. if not podcast_name:
  37. podcast_name = parsed_data.get("podcast_name")
  38. podcast_dict = {"name": podcast_name}
  39. episode_name = parsed_data.get("episode_filename")
  40. episode_dict = {
  41. "title": episode_name,
  42. "run_time_seconds": data_dict.get("run_time"),
  43. "number": parsed_data.get("episode_num"),
  44. "pub_date": parsed_data.get("pub_date"),
  45. "mopidy_uri": mopidy_uri,
  46. }
  47. episode = PodcastEpisode.find_or_create(
  48. podcast_dict, producer_dict, episode_dict
  49. )
  50. # Now we run off a scrobble
  51. mopidy_data = {
  52. "user_id": user_id,
  53. "timestamp": timezone.now(),
  54. "playback_position_seconds": data_dict.get("playback_time_ticks"),
  55. "source": "Mopidy",
  56. "mopidy_status": data_dict.get("status"),
  57. }
  58. logger.info(
  59. "[scrobblers] webhook mopidy scrobble request received",
  60. extra={
  61. "episode_id": episode.id if episode else None,
  62. "user_id": user_id,
  63. "scrobble_dict": mopidy_data,
  64. "media_type": Scrobble.MediaType.PODCAST_EPISODE,
  65. },
  66. )
  67. scrobble = None
  68. if episode:
  69. scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
  70. return scrobble
  71. def mopidy_scrobble_track(
  72. data_dict: dict, user_id: Optional[int]
  73. ) -> Optional[Scrobble]:
  74. artist = get_or_create_artist(
  75. data_dict.get("artist"),
  76. mbid=data_dict.get("musicbrainz_artist_id", None),
  77. )
  78. album = get_or_create_album(
  79. data_dict.get("album"),
  80. artist=artist,
  81. mbid=data_dict.get("musicbrainz_album_id"),
  82. )
  83. track = get_or_create_track(
  84. title=data_dict.get("name"),
  85. mbid=data_dict.get("musicbrainz_track_id"),
  86. artist=artist,
  87. album=album,
  88. run_time_seconds=data_dict.get("run_time"),
  89. )
  90. # Now we run off a scrobble
  91. playback_seconds = data_dict.get("playback_time_ticks") / 1000
  92. mopidy_data = {
  93. "user_id": user_id,
  94. "timestamp": timezone.now(),
  95. "playback_position_seconds": playback_seconds,
  96. "source": "Mopidy",
  97. "mopidy_status": data_dict.get("status"),
  98. }
  99. logger.info(
  100. "[scrobblers] webhook mopidy scrobble request received",
  101. extra={
  102. "track_id": track.id,
  103. "user_id": user_id,
  104. "scrobble_dict": mopidy_data,
  105. "media_type": Scrobble.MediaType.TRACK,
  106. },
  107. )
  108. scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
  109. return scrobble
  110. def build_scrobble_dict(data_dict: dict, user_id: int) -> dict:
  111. jellyfin_status = "resumed"
  112. if data_dict.get("IsPaused"):
  113. jellyfin_status = "paused"
  114. elif data_dict.get("NotificationType") == "PlaybackStop":
  115. jellyfin_status = "stopped"
  116. playback_seconds = convert_to_seconds(
  117. data_dict.get("PlaybackPosition", "")
  118. )
  119. return {
  120. "user_id": user_id,
  121. "timestamp": parse(data_dict.get("UtcTimestamp")),
  122. "playback_position_seconds": playback_seconds,
  123. "source": data_dict.get("ClientName", "Vrobbler"),
  124. "source_id": data_dict.get("MediaSourceId"),
  125. "jellyfin_status": jellyfin_status,
  126. }
  127. def jellyfin_scrobble_track(
  128. data_dict: dict, user_id: Optional[int]
  129. ) -> Optional[Scrobble]:
  130. null_position_on_progress = (
  131. data_dict.get("PlaybackPosition") == "00:00:00"
  132. and data_dict.get("NotificationType") == "PlaybackProgress"
  133. )
  134. # Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
  135. if null_position_on_progress:
  136. logger.error("No playback position tick from Jellyfin, aborting")
  137. return
  138. artist = get_or_create_artist(
  139. data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"]),
  140. mbid=data_dict.get(JELLYFIN_POST_KEYS["ARTIST_MB_ID"]),
  141. )
  142. album = get_or_create_album(
  143. data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"]),
  144. artist=artist,
  145. mbid=data_dict.get(JELLYFIN_POST_KEYS["ALBUM_MB_ID"]),
  146. )
  147. run_time = convert_to_seconds(
  148. data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"])
  149. )
  150. track = get_or_create_track(
  151. title=data_dict.get("Name"),
  152. artist=artist,
  153. album=album,
  154. run_time_seconds=run_time,
  155. )
  156. scrobble_dict = build_scrobble_dict(data_dict, user_id)
  157. # A hack to make Jellyfin work more like Mopidy for music tracks
  158. scrobble_dict["playback_position_seconds"] = 0
  159. return Scrobble.create_or_update(track, user_id, scrobble_dict)
  160. def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
  161. video = Video.find_or_create(data_dict)
  162. scrobble_dict = build_scrobble_dict(data_dict, user_id)
  163. logger.info(
  164. "[scrobblers] webhook video scrobble request received",
  165. extra={
  166. "video_id": video.id,
  167. "user_id": user_id,
  168. "scrobble_dict": scrobble_dict,
  169. "media_type": Scrobble.MediaType.VIDEO,
  170. },
  171. )
  172. return Scrobble.create_or_update(video, user_id, scrobble_dict)
  173. def manual_scrobble_video(imdb_id: str, user_id: int):
  174. video = Video.find_or_create({"imdb_id": imdb_id})
  175. # When manually scrobbling, try finding a source from the series
  176. source = "Vrobbler"
  177. if video.tv_series:
  178. source = video.tv_series.preferred_source
  179. scrobble_dict = {
  180. "user_id": user_id,
  181. "timestamp": timezone.now(),
  182. "playback_position_seconds": 0,
  183. "source": source,
  184. "source_id": "Manually scrobbled from Vrobbler and looked up via IMDB",
  185. }
  186. logger.info(
  187. "[scrobblers] manual video scrobble request received",
  188. extra={
  189. "video_id": video.id,
  190. "user_id": user_id,
  191. "scrobble_dict": scrobble_dict,
  192. "media_type": Scrobble.MediaType.VIDEO,
  193. },
  194. )
  195. return Scrobble.create_or_update(video, user_id, scrobble_dict)
  196. def manual_scrobble_event(thesportsdb_id: str, user_id: int):
  197. data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
  198. event = SportEvent.find_or_create(data_dict)
  199. scrobble_dict = build_scrobble_dict(data_dict, user_id)
  200. return Scrobble.create_or_update(event, user_id, scrobble_dict)
  201. def manual_scrobble_video_game(hltb_id: str, user_id: int):
  202. game = VideoGame.objects.filter(hltb_id=hltb_id).first()
  203. if not game:
  204. data_dict = lookup_game_from_hltb(hltb_id)
  205. game = VideoGame.find_or_create(data_dict)
  206. scrobble_dict = {
  207. "user_id": user_id,
  208. "timestamp": timezone.now(),
  209. "playback_position_seconds": 0,
  210. "source": "Vrobbler",
  211. "source_id": "Manually scrobbled from Vrobbler and looked up via HLTB.com",
  212. "long_play_complete": False,
  213. }
  214. logger.info(
  215. "[scrobblers] manual video game scrobble request received",
  216. extra={
  217. "videogame_id": game.id,
  218. "user_id": user_id,
  219. "scrobble_dict": scrobble_dict,
  220. "media_type": Scrobble.MediaType.VIDEO_GAME,
  221. },
  222. )
  223. return Scrobble.create_or_update(game, user_id, scrobble_dict)
  224. def manual_scrobble_book(openlibrary_id: str, user_id: int):
  225. book = Book.find_or_create(openlibrary_id)
  226. scrobble_dict = {
  227. "user_id": user_id,
  228. "timestamp": timezone.now(),
  229. "playback_position_seconds": 0,
  230. "source": "Vrobbler",
  231. "long_play_complete": False,
  232. }
  233. logger.info(
  234. "[scrobblers] manual book scrobble request received",
  235. extra={
  236. "book_id": book.id,
  237. "user_id": user_id,
  238. "scrobble_dict": scrobble_dict,
  239. "media_type": Scrobble.MediaType.BOOK,
  240. },
  241. )
  242. return Scrobble.create_or_update(book, user_id, scrobble_dict)
  243. def manual_scrobble_board_game(bggeek_id: str, user_id: int):
  244. boardgame = BoardGame.find_or_create(bggeek_id)
  245. if not boardgame:
  246. logger.error(f"No board game found for ID {bggeek_id}")
  247. return
  248. scrobble_dict = {
  249. "user_id": user_id,
  250. "timestamp": timezone.now(),
  251. "playback_position_seconds": 0,
  252. "source": "Vrobbler",
  253. "source_id": "Manually scrobbled from Vrobbler and looked up via boardgamegeek.com",
  254. }
  255. logger.info(
  256. "[webhook] board game scrobble request received",
  257. extra={
  258. "boardgame_id": boardgame.id,
  259. "user_id": user_id,
  260. "scrobble_dict": scrobble_dict,
  261. "media_type": Scrobble.MediaType.BOARD_GAME,
  262. },
  263. )
  264. return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
  265. def manual_scrobble_webpage(url: str, user_id: int):
  266. webpage = WebPage.find_or_create({"url": url})
  267. scrobble_dict = {
  268. "user_id": user_id,
  269. "timestamp": timezone.now(),
  270. "playback_position_seconds": 0,
  271. "source": "Vrobbler",
  272. "source_id": "Manually scrobbled from Vrobbler",
  273. }
  274. logger.info(
  275. "[webhook] webpage scrobble request received",
  276. extra={
  277. "webpage_id": webpage.id,
  278. "user_id": user_id,
  279. "scrobble_dict": scrobble_dict,
  280. "media_type": Scrobble.MediaType.WEBPAGE,
  281. },
  282. )
  283. return Scrobble.create_or_update(webpage, user_id, scrobble_dict)
  284. def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
  285. location = GeoLocation.find_or_create(data_dict)
  286. extra_data = {
  287. "user_id": user_id,
  288. "timestamp": pendulum.parse(data_dict.get("time", timezone.now())),
  289. "source": "GPSLogger",
  290. }
  291. scrobble = Scrobble.create_or_update(location, user_id, extra_data)
  292. provider = f"data source: {LOCATION_PROVIDERS[data_dict.get('prov')]}"
  293. scrobble.notes = f"Last position provided by {provider}"
  294. if scrobble.timestamp:
  295. scrobble.playback_position_seconds = (
  296. timezone.now() - scrobble.timestamp
  297. ).seconds
  298. scrobble.save(update_fields=["notes", "playback_position_seconds"])
  299. logger.info(
  300. "[webhook] gpslogger scrobble request received",
  301. extra={
  302. "scrobble_id": scrobble.id,
  303. "provider": provider,
  304. "user_id": user_id,
  305. "timestamp": extra_data.get("timestamp"),
  306. "raw_timestamp": data_dict.get("time"),
  307. "media_type": Scrobble.MediaType.GEO_LOCATION,
  308. },
  309. )
  310. return scrobble