Creating an automated workflow with PipeDream

Trakt to RescueTime workflows on PipeDream

Let me walk through creating a workflow in PipeDream to track watched TV time in Trakt through RescueTime. I use RescueTime to help manage my time and track billable hours for freelancing. Working with some international folks in other timezones, my work hours can be all over the place. I wanted to exclude known personal activities from “work” time so that the odd hours work time wouldn’t be marked as billable. Being a data nerd, I also track TV and movies I watch in Trakt. I should be able to connect the two, right?

Looking at workflow automation options:

  1. IFTTT
  2. Zapier
    • No built in Trakt support
    • It does allow you to “Log offline time”
    • Requires paid plan for more than 2 steps
  3. Activepieces
  4. Huginn
    • I don’t want to set up a deployment/server
    • Doesn’t have built in support for either service
  5. Automatisch
    • Needs selfhosting, no built in support
  6. PipeDream
    • Supports both services
    • Needs call for extended media info for Trakt – not built in
    • Logging offline time on RescueTime is not built in
    • Supports custom code
    • Implementation do-able on the free plan

I went with PipeDream. It might be more technical, but it gives me some flexibility. Also, I wanted to try out a new service other than the first two.

We create a new project. Since watching a show and watching a movie are different triggers, we’ll need two different workflows but both can be stored in this one project.

Let’s start with watching a show since this is more complicated metadata. Create a new workflow.

Add a trigger, search for Trakt, then click “New Show Or Movie Watched“.
Set up your Trakt account (it’s nice that even for the custom code, PipeDream can handle the authentication).
Then select “Shows” as the Type.

You’ll get some events from already watched shows – I selected a show I’ve watched multiple times in the past so we can see what more complex data looks like.

Under “Exports” you’ll see what data the trigger returns. We can see the structure and how it can contain multiple episodes if you were binging a series. Looking at the recent event list, this same event can fire multiple times, so there can be an overlap in the data. It appears we’re only interested in the episodes where the last_watched_at matches the global/show last_watched_at.

Now we’ll add a step. Since we only have the watch time and not the duration from the trigger, we’ll need to get the duration. This isn’t a new “watch” or “rating” so we’ll need to “Use any Trakt API in Node.js“. We get a boilerplate template to work with!

Looking at the Trakt API documentation, we can get extended media information with the Search API. This will give us our duration. The previous step gave us ids.trakt for the show, so we can plug this in. We’ll alter the url to match the API documentation.

Next we’ll loop through the episode watches.
As mentioned above, we don’t want older viewings, only the viewings that match the global/show last_watched_at are relevant for this event.
We don’t want to log old events (past a week, it’s not relevant for my RescueTime data) and especially at first Trakt will hand us some old data.
I’m storing the watch info in the format RescueTime will want it in. This requires some date formatting and adjusting for timezone.

import { axios } from "@pipedream/platform"
export default defineComponent({
  props: {
    trakt: {
      type: "app",
      app: "trakt",
    }
  },
  async run({steps, $}) {
    // filter out old results before querying for show details
    const showWatchDate =  Date.parse(steps.trigger.event.last_watched_at);
    const luDate =  Date.parse(steps.trigger.event.last_updated_at);
    if(
      (Date.now() - showWatchDate) > (8*24*60*60*1000) ||
      (Date.now() - luDate) > (8*24*60*60*1000)
    ) {
      return $.flow.exit("Watched more than 8 days ago");
    }

    const data = await axios($, {
      url: `https://${this.trakt.$auth.environment}.trakt.tv/search/trakt/${steps.trigger.event.show.ids.trakt}?type=show&extended=full`,
      headers: {
        Authorization: `Bearer ${this.trakt.$auth.oauth_access_token}`,
        "Content-Type": `application/json`,
        "trakt-api-version": `2`,
        "trakt-api-key": `${this.trakt.$auth.oauth_client_id}`,
      },
    });

    // in case we couldn't get episode runtime for any reason
    const showDuration = data?.[0]?.show?.runtime;
    if(!showDuration) {
      return $.flow.exit("Couldn't get show runtime");
    }

    let errorMsg = "";
    const plays = [];

    steps.trigger.event.seasons.forEach((season)=>{
      season.episodes.forEach((episode)=>{
        // new viewings should match last_watched_at for show and episode 
        let d = new Date(episode.last_watched_at);
        if(d.getTime() < showWatchDate) {
          errorMsg = "Entry is from a previous viewing";
          return;
        }

        // filter out old viewings
        if((Date.now() - d.getTime()) > (8*24*60*60*1000)) {
          errorMsg = "Watched more than 8 days ago";
          return;
        }

        // convert end time to start time, then change timezone
        d = new Date(d.getTime() - showDuration);
        d = new Date(d.toLocaleString("en-US", {
          timeZone: "America/New_York"
        }));

        // build datetime string in desired RescueTime format
        let mo = d.getMonth();
        mo = mo < 10 ? `0${mo}` : mo;
        let day = d.getDate();
        day = day < 10 ? `0${day}` : day;
        let hr = d.getHours();
        hr = hr < 10 ? `0${hr}` : hr;
        let min = d.getMinutes();
        min = min < 10 ? `0${min}` : min;
        let s = d.getSeconds();
        s = s < 10 ? `0${s}` : s;

        // store in desired RescueTime structure
        plays.push({
          "start_time": `${d.getFullYear()}-${mo}-${day} ${hr}:${min}:${s}`,
          "duration": showDuration,
          "activity_name": "Watch TV Show",
          "activity_details": `${data[0].show.title} S${season.number}E${episode.number}`
        });
      });
    });

    // exit early if there were no good viewings to submit
    if(errorMsg && !plays.length) {
      return $.flow.exit(errorMsg);
    }

    return plays;
  },
})

Now we’ll add the RescueTime step. There’s not a built in step for offline logging, so we’ll “Use any RescueTime API in Node.js“. The boilerplate template is for a single HTTP request and the API doesn’t support multiple offline entries, so we’ll need to alter this significantly. The API documentation lists a specific format for a POST request; we’ll loop through the logs we have stored from the previous step and do serial calls to submit each.

import { axios } from "@pipedream/platform"
export default defineComponent({
  props: {
    rescuetime: {
      type: "app",
      app: "rescuetime",
    }
  },
  async run({steps, $}) {
    const rdata = [];

    for(let v = 0; v < steps.trakt.$return_value.length; v++) {
      // do we have any data to submit?
      if(!steps.trakt.$return_value || !steps.trakt.$return_value.length) {
        return $.flow.exit();
      }
      if(!steps.trakt.$return_value[v]) continue;

      // submit like the docs specify, make sure it's interpreting as JSON data
      const view = steps.trakt.$return_value[v];

      // an error will block subsequent logs but that is okay
      const r = await axios($, {
        method: "POST",
        url: `https://www.rescuetime.com/anapi/offline_time_post`,
        data: view,
        params: {
          key: process.env.RT_API_KEY,
        },
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json',
        },
      });

      // store the return data, but not necessary since no more steps
      rdata.push(r);
    }

    //  for debugging purposes
    return rdata;
  },
});

Now we can deploy! In the project view you can hit “Copy” to duplicate it and then you can alter it to support Movie views.