[scrobbles] Update webhook view permissions and small template changes

This commit is contained in:
2026-03-30 12:54:21 -04:00
parent 1e21bd9481
commit 2ca3dd1ed9
9 changed files with 429 additions and 334 deletions

View File

@ -48,6 +48,7 @@ from rest_framework.decorators import (
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from scrobbles.api import serializers
from scrobbles.constants import (
LONG_PLAY_MEDIA,
@ -80,6 +81,14 @@ User = get_user_model()
logger = logging.getLogger(__name__)
class WebhookView(APIView):
permission_classes = [IsAuthenticated]
@property
def logger(self):
return logger
class ScrobbleableListView(ListView):
model = None
paginate_by = 200
@ -427,8 +436,8 @@ class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, Create
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
@permission_classes([IsAuthenticated])
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def lastfm_import(request):
lfm_import, created = LastFmImport.objects.get_or_create(
user=request.user, processed_finished__isnull=True
@ -439,155 +448,143 @@ def lastfm_import(request):
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
@csrf_exempt
@api_view(["POST"])
def web_scrobbler_webhook(request):
"""Note, this does not work as implemented. For some reason the web
scrobbler tool kicks back a bad response code and does not seem to POST data
as expected.
class WebScrobblerWebhookView(WebhookView):
def post(self, request):
"""Note, this does not work as implemented. For some reason the web
scrobbler tool kicks back a bad response code and does not seem to POST data
as expected.
"""
user_id = request.user.id
logger.info(
"[web_scrobbler_webhook] called",
extra={
"post_data": request.POST,
"user_id": user_id,
},
)
youtube_id = request.POST.get("uniqueID")
if not youtube_id:
logger.warning(
"[web_scrobbler_webhook] failed",
extra={
"youtube_id": youtube_id,
"user_id": user_id,
},
)
return JsonResponse(
{"errors": ["No youtube ID provided"]}, status=status.HTTP_200_OK
)
playing_state = "started"
if request.POST.get("isPlaying") == "false":
playing_state = "stopped"
scrobble = web_scrobbler_scrobble_media(youtube_id, user_id, status=playing_state)
if not scrobble:
logger.warning(
"[web_scrobbler_webhook] failed",
extra={
"youtube_id": youtube_id,
"user_id": user_id,
},
)
return JsonResponse(
{"errors": ["No scrobble found for user or video"]},
status=status.HTTP_200_OK,
)
logger.info(
"[web_scrobbler_webhook] finished",
extra={"scrobble_id": scrobble.id},
)
return JsonResponse({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(["POST"])
def jellyfin_webhook(request):
post_data = request.data
logger.info(
"[jellyfin_webhook] called",
extra={
"post_data": post_data,
"user_id": request.user.id,
},
)
in_progress = post_data.get("NotificationType", "") == "PlaybackProgress"
is_music = post_data.get("ItemType", "") == "Audio"
# Disregard progress updates
if in_progress and is_music:
logger.info(
"[jellyfin_webhook] ignoring update of music in progress",
extra={"post_data": post_data},
)
return Response({}, status=status.HTTP_304_NOT_MODIFIED)
scrobble = jellyfin_scrobble_media(post_data, request.user.id)
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
logger.info(
"[jellyfin_webhook] finished",
extra={"scrobble_id": scrobble.id},
)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(["POST"])
def mopidy_webhook(request):
try:
data_dict = json.loads(request.data)
except TypeError:
data_dict = request.data
logger.info(
"[mopidy_webhook] called",
extra={
"post_data": data_dict,
"user_id": request.user.id,
},
)
scrobble = mopidy_scrobble_media(data_dict, request.user.id)
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(["POST"])
def gps_webhook(request):
try:
data_dict = json.loads(request.data)
except TypeError:
data_dict = request.data
logger.info(
"[geolocation_webhook] called",
extra={
"post_data": data_dict,
"user_id": 1,
},
)
# TODO Fix this so we have to authenticate!
user_id = 1
if request.user.id:
"""
user_id = request.user.id
scrobble = gpslogger_scrobble_location(data_dict, user_id)
self.logger.info(
"[web_scrobbler_webhook] called",
extra={
"post_data": request.POST,
"user_id": user_id,
},
)
youtube_id = request.POST.get("uniqueID")
if not youtube_id:
self.logger.warning(
"[web_scrobbler_webhook] failed",
extra={
"youtube_id": youtube_id,
"user_id": user_id,
},
)
return JsonResponse(
{"errors": ["No youtube ID provided"]}, status=status.HTTP_200_OK
)
if not scrobble:
return Response({}, status=status.HTTP_200_OK)
playing_state = "started"
if request.POST.get("isPlaying") == "false":
playing_state = "stopped"
scrobble = web_scrobbler_scrobble_media(
youtube_id, user_id, status=playing_state
)
if not scrobble:
self.logger.warning(
"[web_scrobbler_webhook] failed",
extra={
"youtube_id": youtube_id,
"user_id": user_id,
},
)
return JsonResponse(
{"errors": ["No scrobble found for user or video"]},
status=status.HTTP_200_OK,
)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
self.logger.info(
"[web_scrobbler_webhook] finished",
extra={"scrobble_id": scrobble.id},
)
return JsonResponse({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
class JellyfinWebhookView(WebhookView):
def post(self, request):
post_data = request.data
self.logger.info(
"[jellyfin_webhook] called",
extra={
"post_data": post_data,
"user_id": request.user.id,
},
)
in_progress = post_data.get("NotificationType", "") == "PlaybackProgress"
is_music = post_data.get("ItemType", "") == "Audio"
if in_progress and is_music:
self.logger.info(
"[jellyfin_webhook] ignoring update of music in progress",
extra={"post_data": post_data},
)
return Response({}, status=status.HTTP_304_NOT_MODIFIED)
scrobble = jellyfin_scrobble_media(post_data, request.user.id)
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
self.logger.info(
"[jellyfin_webhook] finished",
extra={"scrobble_id": scrobble.id},
)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
class MopidyWebhookView(WebhookView):
def post(self, request):
try:
data_dict = json.loads(request.data)
except TypeError:
data_dict = request.data
self.logger.info(
"[mopidy_webhook] called",
extra={
"post_data": data_dict,
"user_id": request.user.id,
},
)
scrobble = mopidy_scrobble_media(data_dict, request.user.id)
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
class GPSWebhookView(WebhookView):
def post(self, request):
try:
data_dict = json.loads(request.data)
except TypeError:
data_dict = request.data
self.logger.info(
"[geolocation_webhook] called",
extra={
"post_data": data_dict,
"user_id": request.user.id,
},
)
scrobble = gpslogger_scrobble_location(data_dict, request.user.id)
if not scrobble:
return Response({}, status=status.HTTP_200_OK)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(["POST"])
@parser_classes([MultiPartParser])
@permission_classes([IsAuthenticated])
def import_audioscrobbler_file(request):
"""Takes a TSV file in the Audioscrobbler format, saves it and processes the
scrobbles.
@ -603,8 +600,8 @@ def import_audioscrobbler_file(request):
return Response(file_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@permission_classes([IsAuthenticated])
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def scrobble_start(request, uuid):
logger.info(
"[scrobble_start] called",
@ -694,8 +691,8 @@ def scrobble_longplay_finish(request, uuid):
return HttpResponseRedirect(success_url)
@permission_classes([IsAuthenticated])
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def scrobble_finish(request, uuid):
user = request.user
success_url = request.META.get("HTTP_REFERER")
@ -718,8 +715,8 @@ def scrobble_finish(request, uuid):
return HttpResponseRedirect(success_url)
@permission_classes([IsAuthenticated])
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def scrobble_cancel(request, uuid):
user = request.user
success_url = reverse_lazy("vrobbler-home")
@ -740,8 +737,8 @@ def scrobble_cancel(request, uuid):
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
@permission_classes([IsAuthenticated])
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def export(request):
format = request.GET.get("export_type", "csv")
start = request.GET.get("start")
@ -1020,23 +1017,23 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
template_name = "scrobbles/scrobble_search.html"
MEDIA_FIELDS = {
"Video": ("video__title", "video__overview"),
"Track": ("track__title", None),
"PodcastEpisode": ("podcast_episode__title", None),
"Book": ("book__title", "book__description"),
"Paper": ("paper__title", None),
"VideoGame": ("video_game__title", "video_game__description"),
"BoardGame": ("board_game__title", "board_game__description"),
"Beer": ("beer__name", "beer__style"),
"Food": ("food__name", "food__description"),
"Puzzle": ("puzzle__name", "puzzle__description"),
"Trail": ("trail__name", "trail__location"),
"GeoLocation": ("geo_location__name", "geo_location__description"),
"Task": ("task__title", "task__description"),
"WebPage": ("web_page__title", None),
"LifeEvent": ("life_event__title", "life_event__description"),
"Mood": ("mood__title", "mood__description"),
"BrickSet": ("brick_set__title", None),
"Video": ["video__title", "video__overview"],
"Track": ["track__title", "track__artist__name", "track__album__name"],
"PodcastEpisode": ["podcast_episode__title", None],
"Book": ["book__title", "book__summary"],
"Paper": ["paper__title", None],
"VideoGame": ["video_game__title", "video_game__summary"],
"BoardGame": ["board_game__title", "board_game__description"],
"Beer": ["beer__name", "beer__style"],
"Food": ["food__name", "food__description"],
"Puzzle": ["puzzle__name", "puzzle__description"],
"Trail": ["trail__name", "trail__location"],
"GeoLocation": ["geo_location__title", None],
"Task": ["task__title", "task__description"],
"WebPage": ["web_page__title", None],
"LifeEvent": ["life_event__title", "life_event__description"],
"Mood": ["mood__title", "mood__description"],
"BrickSet": ["brick_set__title", None],
}
def get(self, request, *args, **kwargs):
@ -1074,11 +1071,10 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
q |= Q(source__icontains=query)
if media_type and media_type in self.MEDIA_FIELDS:
title_field, desc_field = self.MEDIA_FIELDS[media_type]
if title_field:
q |= Q(**{f"{title_field}__icontains": query})
if desc_field:
q |= Q(**{f"{desc_field}__icontains": query})
fields = self.MEDIA_FIELDS[media_type]
for field in fields:
if field:
q |= Q(**{f"{field}__icontains": query})
scrobbles = scrobbles.filter(q).distinct()
else:
@ -1087,12 +1083,11 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
base_matches = scrobbles.filter(q).values_list("id", flat=True)
all_matching_ids.update(base_matches)
for mt, (title_field, desc_field) in self.MEDIA_FIELDS.items():
for mt, fields in self.MEDIA_FIELDS.items():
mt_queries = []
if title_field:
mt_queries.append(Q(**{f"{title_field}__icontains": query}))
if desc_field:
mt_queries.append(Q(**{f"{desc_field}__icontains": query}))
for field in fields:
if field:
mt_queries.append(Q(**{f"{field}__icontains": query}))
if mt_queries:
mt_q = mt_queries[0]