Skip to content

Conversation

@ryck
Copy link
Contributor

@ryck ryck commented May 20, 2025

Add Plex integration (using webhooks, so a PlexPass is needed)

I must say that python is not my thing and this is the first time I touch Django, so please don't be afraid to correct me to oblivion...

image

@codecov
Copy link

codecov bot commented May 20, 2025

Codecov Report

Attention: Patch coverage is 86.66667% with 2 lines in your changes missing coverage. Please review.

Project coverage is 83.80%. Comparing base (c702241) to head (09f155f).
Report is 3 commits behind head on dev.

Files with missing lines Patch % Lines
src/app/providers/tmdb.py 81.81% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##              dev     #516   +/-   ##
=======================================
  Coverage   83.80%   83.80%           
=======================================
  Files          63       63           
  Lines        5741     5741           
=======================================
  Hits         4811     4811           
  Misses        930      930           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ryck
Copy link
Contributor Author

ryck commented May 20, 2025

Notes

  1. Plex doesn't expose ["Item"]["UserData"]["Played"], so I needed to get creative
  2. I couldn't make add_anime work, I don't know how big of a dealbreaker that is...
  3. I do an extra request when playing an episode to find the show's TMDB id
  4. There are no tests (yet)
  5. I don't know why the previous test are failing...

@FuzzyGrim
Copy link
Owner

FuzzyGrim commented May 20, 2025

Hey, thanks for working on this!

I couldn't make add_anime work, I don't know how big of a dealbreaker that is...

It's okay, we can add a message in plex integration section.

I don't know why the previous test are failing...

My tests in Github are failing too, there seems to be some problem with this external library django-allauth, #4398.

Edit: Fixed tests with (d54fdd0)

@psyciknz
Copy link
Contributor

a request from a user, can you debug log the payload always (I see you have it commented out). As the plex webhooks can be a little temperamental. So it would be great if they were always dumped into the log.

@ryck ryck marked this pull request as ready for review May 20, 2025 23:28
@ryck
Copy link
Contributor Author

ryck commented May 20, 2025

I think I am done, but happy to receive comments and suggestions :)

@ryck
Copy link
Contributor Author

ryck commented May 21, 2025

Once we are happy with this, I think adding support for tautulli hooks (so a plexpass is not neccesary) should be easy, we could even reuse one of the existing plex/jellyfin hooks (because we can mimic payloads in tautulli)

@psyciknz
Copy link
Contributor

I’ve actually been looking at how you can get media at events when playing off a remote server. As if you play media from someone else’s server a local webhook won’t do it (it doesn’t even show in your Plex logs)

@ryck
Copy link
Contributor Author

ryck commented May 21, 2025

I’ve actually been looking at how you can get media at events when playing off a remote server. As if you play media from someone else’s server a local webhook won’t do it (it doesn’t even show in your Plex logs)

I think the only way around this is

  1. expose you local hook to the internet
  2. ask the server owner to add your exposed hook to his server config

@psyciknz
Copy link
Contributor

Yeah. Something like that. Will play once this integration is completed (awesome work by the dev). As I run cloudflare I could do it that way.

@ryck
Copy link
Contributor Author

ryck commented May 21, 2025

We may need to add a setting to specify which user need to be tracked using the webhook, if not all media played by anyone in your server will be tracked in your yamtrack...

@FuzzyGrim
Copy link
Owner

You can't have a different webhook URL for each user in Plex?

@psyciknz
Copy link
Contributor

We may need to add a setting to specify which user need to be tracked using the webhook, if not all media played by anyone in your server will be tracked in your yamtrack...

Yeah. As the default is all content off the server.

@FuzzyGrim
Copy link
Owner

@ryck thanks again for working on this!

In the jellyfin webhook, I just added detect an episode as rewatched (7130368)

I also added detecting when something started playing with Jellyfin's Play event, so media gets created with the correct start datetime. Maybe you could use Plex's media.play for this?

I have also seen that there is media.rate in Plex, how feasible would it be to implement this? In my application, it would be something like this:

tv_instance, created = app.models.TV.objects.update_or_create(
    item=tv_item,
    user=user,
    defaults={
        "score": plex_score,
    },
    create_defaults={
        "status": Media.Status.IN_PROGRESS.value,
    },
)
season_instance, created = app.models.Season.objects.update_or_create(
    item=season_item,
    user=user,
    related_tv=tv_instance,
    defaults={
        "score": plex_score,
    },
    create_defaults={
        "status": Media.Status.IN_PROGRESS.value,
    },
)
movie_instance, created = app.models.Movie.objects.update_or_create(
    item=movie_item,
    user=user,
    defaults={
        "score": plex_score,
    },
    create_defaults={
        "status": Media.Status.COMPLETED.value,
    },
)

We may need to add a setting to specify which user need to be tracked using the webhook, if not all media played by anyone in your server will be tracked in your yamtrack...

Would you like to implement this? I can help with this if needed.

@ryck
Copy link
Contributor Author

ryck commented May 22, 2025

In the jellyfin webhook, I just added detect an episode as rewatched (7130368)

Let me see if I can implement this too!

I also added detecting when something started playing with Jellyfin's Play event, so media gets created with the correct start datetime. Maybe you could use Plex's media.play for this?

Yeah, I think we can

I have also seen that there is media.rate in Plex, how feasible would it be to implement this? In my application, it would be something like this:

tv_instance, created = app.models.TV.objects.update_or_create(
    item=tv_item,
    user=user,
    defaults={
        "score": plex_score,
    },
    create_defaults={
        "status": Media.Status.IN_PROGRESS.value,
    },
)
season_instance, created = app.models.Season.objects.update_or_create(
    item=season_item,
    user=user,
    related_tv=tv_instance,
    defaults={
        "score": plex_score,
    },
    create_defaults={
        "status": Media.Status.IN_PROGRESS.value,
    },
)
movie_instance, created = app.models.Movie.objects.update_or_create(
    item=movie_item,
    user=user,
    defaults={
        "score": plex_score,
    },
    create_defaults={
        "status": Media.Status.COMPLETED.value,
    },
)

yeah, we can, I think we will need to do some requests to find the season, but I think is possible...

We may need to add a setting to specify which user need to be tracked using the webhook, if not all media played by anyone in your server will be tracked in your yamtrack...

Would you like to implement this? I can help with this if needed.

Let me give it a go first, I'll give you a shout if I get blocked
Let me give it a go

@ryck
Copy link
Contributor Author

ryck commented May 22, 2025

In the jellyfin webhook, I just added detect an episode as rewatched (7130368)

Implemented

I also added detecting when something started playing with Jellyfin's Play event, so media gets created with the correct start datetime. Maybe you could use Plex's media.play for this?

Well, the issue is that with plex webhooks the only way I can tell if something is "played" is when I receive the media.scrobble event, as I don't have anything similar to payload["Item"]["UserData"]["Played"]

We may need to add a setting to specify which user need to be tracked using the webhook, if not all media played by anyone in your server will be tracked in your yamtrack...

Would you like to implement this? I can help with this if needed.

I used the user to check against payload["Account"]["title"] so we didn't need to add yet another field, but I am open to suggestions...
I did something similar, but I was using env variables, so I added a multiple ones, comma separated: https://github.com/ryck/scrobblex/blob/f0661e006f7791f7964b63fe48201220f4c59f39/src/index.js#L81
I don't think adding it in a env variable is the answer here, so that's why I compromised and reused a variable. Probably adding a field on the plex webhook integration section is the best option (so you can have a different yamtrack and plex user), but I will need your help there...

@ryck
Copy link
Contributor Author

ryck commented May 22, 2025

Also, I probably would leave rating implementation for later, most people are interested in tracking only, and that would require another setting.

@FuzzyGrim
Copy link
Owner

In the jellyfin webhook, I just added detect an episode as rewatched (7130368)

Implemented

Nice!

I also added detecting when something started playing with Jellyfin's Play event, so media gets created with the correct start datetime. Maybe you could use Plex's media.play for this?

Well, the issue is that with plex webhooks the only way I can tell if something is "played" is when I receive the media.scrobble event, as I don't have anything similar to payload["Item"]["UserData"]["Played"]

In Jellyfin I added the Play event, which is triggered every time a user starts watching something so that we can, for example, set the start date for the movie object when the user starts watching a movie. I thought that Plex's media.play event could be used as an equivalent.

Ideally it should be like with media.play we can for a movie:

  • If the movie already exists, set the status to In Progress or Repeating with Start Date now.
  • If it does not exist, create the media object with In Progress with Start Date now.

Then with media.scrobble set it to Completed with End Date now.

Probably adding a field on the plex webhook integration section is the best option (so you can have a different yamtrack and plex user), but I will need your help there...

I would also like to have this option. The implementation would look like:

src/users/models.py: Add new field for saving plex usernames:

plex_usernames = models.TextField(
    blank=True,
    help_text="Comma-separated list of Plex usernames for webhook matching",
)

Run python manage.py makemigrations and python manage.py migrate.

src/users/urls.py: Add url to manage form submission:

path(
    "update_plex_usernames",
    views.update_plex_usernames,
    name="update_plex_usernames",
),

src/users/views.py: Add something like this:

@require_POST
def update_plex_usernames(request):
    """Update the Plex usernames for the user."""
    usernames = request.POST.get("plex_usernames", "")

    # input validation
    # if there is any error in input: messages.error(request, "Message")

    if cleaned_usernames != request.user.plex_usernames:
        request.user.plex_usernames = cleaned_usernames
        request.user.save(update_fields=["plex_usernames"])
        messages.success(request, "Plex usernames updated successfully")

    return redirect("integrations")

Then in the html:

<form method="POST" action="{% url 'update_plex_usernames' %}">
  {% csrf_token %}
  <input type="text" name="plex_usernames" value="{{ request.user.plex_usernames }}">
  <button type="submit">Save</button>
</form>

@ryck
Copy link
Contributor Author

ryck commented May 22, 2025

I tried to use media.play but the only way I found to tell apart completed from repeating is to use repeats, but that means I need to increment it the first time you play it (which is somewhat true, but then is not really repeats anymore, but play_count...) therefore those ugly user_media.repeats > 1 and {{ user_media.repeats|add:"-1" }}. It works, but at what price...

What do you think?

@FuzzyGrim
Copy link
Owner

FuzzyGrim commented May 22, 2025

You can use the status which can be:

class Status(models.TextChoices):
    """Choices for item status."""

    COMPLETED = "Completed", "Completed"
    IN_PROGRESS = "In progress", "In Progress"
    REPEATING = "Repeating", "Repeating"
    PLANNING = "Planning", "Planning"
    PAUSED = "Paused", "Paused"
    DROPPED = "Dropped", "Dropped"

repeats is the number of times a user has rewatched, so it's incremented only when a user completes their rewatch. While the user is still rewatching, the status should be Media.Status.REPEATING.value.

I think the handle_movie() could look something like this:

def handle_movie(media_id, payload, user):
    """Handle movie object from payload."""
    movie_metadata = app.providers.tmdb.movie(media_id)
    movie_played = payload["event"] == "media.scrobble"
    progress = 1 if movie_played else 0
    now = timezone.now().replace(second=0, microsecond=0)

    # Get or create the movie item
    movie_item, _ = app.models.Item.objects.get_or_create(
        media_id=media_id,
        source=Sources.TMDB.value,
        media_type=MediaTypes.MOVIE.value,
        defaults={
            "title": movie_metadata["title"],
            "image": movie_metadata["image"],
        },
    )

    # Get or create the movie instance
    movie_instance, created = app.models.Movie.objects.get_or_create(
        item=movie_item,
        user=user,
        defaults={
            'progress': progress,
            'status': Media.Status.COMPLETED.value if movie_played else Media.Status.IN_PROGRESS.value,
            'start_date': now if not movie_played else None,
            'end_date': now if movie_played else None
        }
    )

    if not created:
        movie_instance.progress = progress

        if movie_played:
            # Always update end_date when movie is played
            movie_instance.end_date = now

            if movie_instance.status == Media.Status.COMPLETED.value:
                movie_instance.repeats += 1
            elif movie_instance.status == Media.Status.REPEATING.value:
                movie_instance.repeats += 1
                movie_instance.status = Media.Status.COMPLETED.value
            else:  # From IN_PROGRESS/PLANNING/PAUSED/DROPPED to COMPLETED
                movie_instance.status = Media.Status.COMPLETED.value
        else:
            if movie_instance.status == Media.Status.COMPLETED.value:
                # Transition from COMPLETED to REPEATING
                movie_instance.status = Media.Status.REPEATING.value
                movie_instance.start_date = now  # Reset start date
                movie_instance.end_date = None   # Clear completion date
            elif movie_instance.status not in (
                Media.Status.REPEATING.value,
                Media.Status.IN_PROGRESS.value,
            ):
                # For other statuses (except REPEATING and IN_PROGRESS) set to IN_PROGRESS
                movie_instance.status = Media.Status.IN_PROGRESS.value
                if not movie_instance.start_date:
                    movie_instance.start_date = now

        movie_instance.save()

@ryck
Copy link
Contributor Author

ryck commented May 23, 2025

Now I need help on how to access plex_usernames from the hook :(

@FuzzyGrim
Copy link
Owner

user.plex_usernames

@ryck
Copy link
Contributor Author

ryck commented May 23, 2025

Have a look, I think I am done now

We can implement rates (and maybe pause status), but I think that should be another PR

@FuzzyGrim
Copy link
Owner

Looks good! Just some minor stuff related to Django, maybe I'm missing something but I don't think user.plex_usernames.errors exists and can be removed.

<div>
  <p class="text-gray-400 mb-4 text-sm">This username will be used to filter which events are sent to your webhook.</p>
  <input name="plex_usernames"
        class="w-full py-2 px-3 bg-[#39404b] rounded-md text-white text-sm border {% if user.plex_usernames.errors %}border-red-500{% else %}border-gray-600{% endif %} focus:border-indigo-500 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
        type="text"
        value="{{ request.user.plex_usernames }}">
  {% if user.plex_usernames.errors %}
    <p class="mt-1 text-sm text-red-400">{{ user.plex_usernames.errors.0 }}</p>
  {% endif %}
</div>

Also I thought that plex_usernames could be a comma separated string of usernames:

plex_usernames = models.TextField(
    blank=True,
    help_text="Comma-separated list of Plex usernames for webhook matching",
)

Just like in Trakt:

image

So we should manage that format when handling the payload:

# Case-insensitive, trimmed user check (handle User object or string)
if str(user.plex_usernames).strip().lower() not in payload["Account"]["title"].strip().lower():
    logger.info("Ignoring Plex webhook event for user: %s", user)
    return

And validate the input in views.py:

@require_POST
def update_plex_usernames(request):
    """Update the Plex usernames for the user."""
    usernames = request.POST.get("plex_usernames", "")

    # input validation
    # if there is any error in input: messages.error(request, "Message")

    if usernames != request.user.plex_usernames:
        request.user.plex_usernames = usernames
        request.user.save(update_fields=["plex_usernames"])
        messages.success(request, "Plex usernames updated successfully")

    return redirect("integrations")

@ryck
Copy link
Contributor Author

ryck commented May 24, 2025

I'm am sure this can be improved, but the integration form looks decent now:

image

@ryck
Copy link
Contributor Author

ryck commented May 24, 2025

When this is merged (and you are happy with how items get tracked from webhooks in general), adding integrations for emby (#323) and tautulli should be quite straightforward

@FuzzyGrim FuzzyGrim merged commit e47a8ff into FuzzyGrim:dev May 24, 2025
4 checks passed
@FuzzyGrim
Copy link
Owner

It looks good, thanks for your work!

@psyciknz
Copy link
Contributor

Hi, question. And maybe it's a release 2 thing.

But if scrobbling a new show - either the integration add it to the list of tracked shows? Or do you have to have it in your list to start off with?

@ryck
Copy link
Contributor Author

ryck commented May 25, 2025

Hi, question. And maybe it's a release 2 thing.

But if scrobbling a new show - either the integration add it to the list of tracked shows? Or do you have to have it in your list to start off with?

It will create it if you don't have it already

@psyciknz
Copy link
Contributor

Unsure if I should rais:e issue or not. I pulled DEV today, and was trying to figure out what this error is:

Bad Request: /webhook/plex/jP_OrifXKDS25EuOeodSgKDkdKkOvNND
[2025-05-27 20:08:45 +1200] [5853] [WARNING] Bad Request: /webhook/plex/jP_OrifXKDS25EuOeodSgKDkdKkOvNND
192.168.10.41 - - [27/May/2025:20:08:45 +1200] "POST /webhook/plex/jP_OrifXKDS25EuOeodSgKDkdKkOvNND HTTP/1.1" 400 15 "-" "python-requests/2.31.0"

I turned on the env var DEBUG=true, but that seems ot be for redis only, and not the web requests.

dannyvfilms pushed a commit to dannyvfilms/Yamtrack that referenced this pull request Jun 5, 2025
* Add find tndb request

* Add WIP plex integration (just tv for now)

* Improve integration wording

* implement movies

* Remove logs and only trigger on scrobble

* Add tests (WIP)

* Add payload debug message

* Add tests

* Add user checking and handle episode rewatches

* Add user info and reimplement copy-token so it works for any field

* Add formatting to payload debug log

* Attempt to use media.play

* Attempt to use media.play

* revert repeat malarkey

* Pley implementation

* Sdd plex_usernames settings

* Implement plex username filtering

* Remove media.stop

* remove user.plex_usernames.errors

* accepts a comma separated list of plex users

* add settings header
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants