Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Suggestion: Plex Webhook Integration #67

Closed
teamcoltra opened this issue May 26, 2023 · 5 comments · Fixed by #281
Closed

Suggestion: Plex Webhook Integration #67

teamcoltra opened this issue May 26, 2023 · 5 comments · Fixed by #281

Comments

@teamcoltra
Copy link

This might be tough to use for all the people hosting within their own network but for anyone who uses Plex (with the Plex Pass) they can use Webhooks to sync their watch history.

https://support.plex.tv/articles/115002267687-webhooks/

https://app.plex.tv/desktop/#!/settings/webhooks

This would help track Plex media (which for me covers TV, Movies, and Audiobooks)

@teamcoltra teamcoltra changed the title Plex Webhook Integration Suggestion: Plex Webhook Integration May 26, 2023
@IgnisDa
Copy link
Owner

IgnisDa commented May 26, 2023

Thanks for raising this issue. I don't use Plex myself, so it would be difficult for me to add integration since webhooks seem to be a premium feature. I would appreciate contributions though.

@zanish
Copy link

zanish commented May 26, 2023

It looks like @andrey-yantsen had done some work with plex webhooks and api with Rust. This may help whoever picks up this task. Either to reach out or integrate some code.
https://github.com/andrey-yantsen/plex-api.rs/tree/main

@andrey-yantsen
Copy link

Well hello to you too :) I have some basic structs implemented in plex-api.rs, but unfortunately, I didn't test this functionality at all.

If somebody will be willing to use the lib — please do pop by, I'm always happy to see new contributors.

@teamcoltra
Copy link
Author

Just popping in here is the code for Plex Webhooks -> Slack

app.post('/', upload.single('thumb'), async (req, res, next) => {
  const payload = JSON.parse(req.body.payload);

  const isVideo = (['movie', 'episode'].includes(payload.Metadata.type));
  const isAudio = (payload.Metadata.type === 'track');
  const key = sha1(payload.Server.uuid + payload.Metadata.ratingKey);

  // missing required properties
  if (!payload.user || !payload.Metadata || !(isAudio || isVideo)) {
    return res.sendStatus(400);
  }

  // retrieve cached image
  let image = await redis.getBuffer(key);

  // save new image
  if (payload.event === 'media.play' || payload.event === 'media.rate') {
    if (image) {
      console.log('[REDIS]', `Using cached image ${key}`);
    } else {
      let buffer;
      if (req.file && req.file.buffer) {
        buffer = req.file.buffer;
      } else if (payload.thumb) {
        console.log('[REDIS]', `Retrieving image from  ${payload.thumb}`);
        buffer = await request.get({
          uri: payload.thumb,
          encoding: null
        });
      }
      if (buffer) {
        image = await sharp(buffer)
          .resize({
            height: 75,
            width: 75,
            fit: 'contain',
            background: 'white'
          })
          .toBuffer();

        console.log('[REDIS]', `Saving new image ${key}`);
        redis.set(key, image, 'EX', SEVEN_DAYS);
      }
    }
  }

  // post to slack
  if ((payload.event === 'media.scrobble' && isVideo) || payload.event === 'media.rate') {
    const location = await getLocation(payload.Player.publicAddress);

    let action;

    if (payload.event === 'media.scrobble') {
      action = 'played';
    } else if (payload.rating > 0) {
      action = 'rated ';
      for (var i = 0; i < payload.rating / 2; i++) {
        action += ':star:';
      }
    } else {
      action = 'unrated';
    }

    if (image) {
      console.log('[SLACK]', `Sending ${key} with image`);
      notifySlack(appURL + '/images/' + key, payload, location, action);
    } else {
      console.log('[SLACK]', `Sending ${key} without image`);
      notifySlack(null, payload, location, action);
    }
  }

  res.sendStatus(200);

});

app.get('/images/:key', async (req, res, next) => {
  const exists = await redis.exists(req.params.key);

  if (!exists) {
    return next();
  }

  const image = await redis.getBuffer(req.params.key);
  sharp(image).jpeg().pipe(res);
});

//
// error handlers

app.use((req, res, next) => {
  const err = new Error('Not Found');
  err.status = 404;
  next(err);
});

app.use((err, req, res, next) => {
  res.status(err.status || 500);
  res.send(err.message);
});

//
// helpers

function getLocation(ip) {
  return request.get(`http://api.ipstack.com/${ip}?access_key=${process.env.IPSTACK_KEY}`, { json: true });
}

function formatTitle(metadata) {
  if (metadata.grandparentTitle) {
    return metadata.grandparentTitle;
  } else {
    let ret = metadata.title;
    if (metadata.year) {
      ret += ` (${metadata.year})`;
    }
    return ret;
  }
}

function formatSubtitle(metadata) {
  let ret = '';

  if (metadata.grandparentTitle) {
    if (metadata.type === 'track') {
      ret = metadata.parentTitle;
    } else if (metadata.index && metadata.parentIndex) {
      ret = `S${metadata.parentIndex} E${metadata.index}`;
    } else if (metadata.originallyAvailableAt) {
      ret = metadata.originallyAvailableAt;
    }

    if (metadata.title) {
      ret += ' - ' + metadata.title;
    }
  } else if (metadata.type === 'movie') {
    ret = metadata.tagline;
  }

  return ret;
}

function notifySlack(imageUrl, payload, location, action) {
  let locationText = '';

  if (location) {
    const state = location.country_code === 'US' ? location.region_name : location.country_name;
    locationText = `near ${location.city}, ${state}`;
  }

  slack.webhook({
    channel,
    username: 'Plex',
    icon_emoji: ':plex:',
    attachments: [{
      fallback: 'Required plain-text summary of the attachment.',
      color: '#a67a2d',
      title: formatTitle(payload.Metadata),
      text: formatSubtitle(payload.Metadata),
      thumb_url: imageUrl,
      footer: `${action} by ${payload.Account.title} on ${payload.Player.title} from ${payload.Server.title} ${locationText}`,
      footer_icon: payload.Account.thumb
    }]
  }, () => {});
}

If you were to update the route to be something like /api/plex_webhook or something, and then "Notify Slack" could just be changed into a database call or however Ryot is saving data it feels like this gets you 90% of the way there. I'm also happy to test anything.

Javascript isn't my primary language and I haven't actually dug too deep into the Ryot code base (and I largely wouldn't understand what I was looking for) but hopefully this can be useful.

@IgnisDa
Copy link
Owner

IgnisDa commented Jul 24, 2023

I don't have Plex premium, so it won't be possible for me to develop this feature. But I would appreciate a PR.

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 a pull request may close this issue.

4 participants