Streamlnk metadata challenges

Another long-time Tivo user looking for a fresh start. The streamlnk concept is great but am running into one problem. To bring in content from Prime Video, I used TamperMonkey to create an "Add to Channels" button on Prime Video searches. Search for content of interest, click Add to Channels and that program becomes part of Channels. (kinda, sorta, most of the time...)

Since this runs on top of amazon, lots of data is available:

B004KAOODK: 
    duration: 2797
    entityType: "TV Show"
    episodeNumber: 4
    images: Object { packshot: "https://images-na.ssl-images-amazon.com/images/I/41Zl0aOFHGL._RI_.jpg", covershot: "https://images-na.ssl-images-amazon.com/images/S/sgp-catalog…4._UY500_UX667_RI_Ve5d87d99695303b034e28fad036544e4_TTW_.png" }
    isAd: false
    isClosedCaption: true
    isHdr: false
    isPrime: true
    isUhd: false
    isXRay: true
    parentTitle: "Downton Abbey Season 1"
    ratingBadge: Object { __type: "atv.wps#RegulatoryRatingBadge", countryCode: "US", description: "Suitable for ages 14+", … }
    releaseDate: "2010-10-17"
    releaseYear: 2010
    seasonNumber: 1
    subtitles: Array [ "English [CC]" ]
    synopsis: "The fair arrives in the village and Mrs Hughes finds herself the centre of speculation when she meets a former suitor who makes her question her position at Downton."
    title: "Downton Abbey: Original UK Version Episode 4"
    titleType: "episode"

In spite of having high-quality data (and source of truth for anything amazon produced), Channels passes a subset to Gracenote which then returns low-quality data or the wrong data or nothing at all. I can create any streamlnk pathname/filename that Channels wants, but seems like the Gracenote dependence makes it all for naught.

Format of generated link from data above:
Imports/TV/Downton Abbey/Season 1/Downton Abbey (2010) S01E04 2010-10-17 Downton Abbey: Original UK Version Episode 4.strmlnk: http://www.amazon.com/gp/product/B004KAOODK

With Downton Abbey, the resulting metadata differs from Amazon's but is usable. Try My Kitchen Rules (a quirky Australian cooking competition) and its total failure.

An actual DVR endpoint that accepted a JSON blob containing episode metadata would be fantastic since it could bypass Gracenote, would avoid the need for an intermediate web application server, and allow nothing more than a site-specific user script to incorporate "Add to Channels" buttons on popular provider sites.

I could create a fake data.tmsapi.com endpoint to return data from the streamlnk provider instead of Gracenote data, but that seems like extraordinary effort for something that seems like it should be easy. (Plus there is some weird caching via the fancybits website and any change back to HTTPS would break it.) Open to any ideas how to make this work reliably. Thanks.

2 Likes

Thanks for the write up. Clever trick using a user-script for this.

One thing that's been requested before is nfo support. That may be useful here, but you'd still have to generate and write the .nfo xml alongside the .strmlnk

I'm open to an API and simple web UI for adding stream links. This could include the ability to set metadata directly to avoid the gracenote lookup. The common challenge with that approach is the images available may not be in the aspect ratio that the Channels app expects.

2 Likes

Appreciate the quick response!

For me personally, .nfo files are fine. I already write the streamlnk files so adding .nfo is easy. However, the number of customers who could setup an intermediate app server would limit use. (Though .nfo files are valuable for any number of other reasons.)

To make "Add to Channels" a product style feature (rather than a few hackers), you need the JSON endpoint with a defined schema. User scripts are small, easy to review and easy to share. Non-technical folks can install and use them with limited instructions. Suspect you would have scripts available for all the major streaming providers in short order.

I think the image art problem is solvable by ensuring the client display is always max-size bounded. Supply art with the wrong dimensions and worst case is poor scaling. Because image art comes via URL, it would be easy to setup an external art processing server (but you can leave that to the hacker community). Each streaming service will have target art sizes for its own purpose. Just a matter of understanding that and then scaling/cropping for Channels if required. (Prime provides both 4:3 and 16:9 art so easy times there.)

Should you or others wish to experiment with Add to Channels for Prime, here is my script. I have only tested using TamperMonkey on MacOS Firefox. Probably more efficient approaches, but this seems to work.

The button is disabled for non-prime-free content. The script will "re-add" every time you click (app server handles duplicate requests in whatever way it wants). When you click "add" for season based content, subsequently changing the season selection will auto-add the next one.

// ==UserScript==
// @name         Add_to_Channels_Prime_Video
// @namespace    [email protected]
// @version      0.9
// @description  Add to Channels button for Prime Video
// @author       vraz
// @match        https://www.amazon.com/*
// @match        https://www.amazon.co.uk/*
// @match        https://www.amazon.de/*
// @match        https://www.amazon.co.jp/*
// @match        https://www.primevideo.com/*
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function() {
  'use strict';

  let label = "Add to Channels";
  let endpoint = "http://dvr-dvr.local:8080/add";
  let head = null;
  let parm = {};

  // find all injected json templates (how prime video passes data)
  document.querySelectorAll('script[type="text/template"]').forEach(e => {
    if (e.innerHTML.match(/^{"props":/)) {
      let p = JSON.parse(e.innerHTML);
      // pedantically check each element to avoid errors if amazon changes object structure
      p && (p = p.props);
      p && (p = p.state);
      p && (p = p.detail);
      // save contents of any non-empty headerDetail object (never seen more than one on a page)
      p && p.headerDetail && Object.keys(p.headerDetail).forEach(h => { head = {...p.headerDetail[h],"ASIN":h} });
      // filter the many content records to those matching the "parentTitle" of the search
      p && p.detail && head && Object.keys(p.detail).forEach(k => {
        (p.detail[k].parentTitle == head.title) && (parm[k] = {...p.detail[k],"ASIN":k});
      });
    }
  });
  if (!head) { return; }

  // for non-episodic content, the parent object becomes the catalog item
  (Object.keys(parm).length == 0) && head && (parm[head.ASIN] = head);
  console.log(head);
  console.log(parm);

  // post-process the catalog items
  Object.keys(parm).forEach(p => {
    let t;
    // convert release-date to yyyy-mm-dd
    t = new Date(parm[p].releaseDate);
    parm[p].releaseDate = t.toISOString().substr(0,10);
    // add the season number (not included in base record)
    parm[p].seasonNumber = head.seasonNumber;
    // skip content without a duration (season records, etc)
    parm[p].duration || delete parm[p];
  });

  // convert to streamlink requests (temp schema)
  let send = [];
  Object.keys(parm).forEach(p => {
    let item = {};
    item.version = 0.9;
    item.title = parm[p].title;
    item.year = parm[p].releaseYear;
    item.date = parm[p].releaseDate;
    item.type = parm[p].titleType;
    item.url = "http://www.amazon.com/gp/product/"+p;
    if (parm[p].episodeNumber) {
        item.group = head.parentTitle;
        item.parent = parm[p].parentTitle;
        item.season = parm[p].seasonNumber;
        item.episode = parm[p].episodeNumber;
    }
    send.push(item);
  });

  // match button style to site
  let style = document.createElement('style');
  style.appendChild(document.createTextNode(`
    .x-add-dvr {
      color: #000;
      background-color: #ffa724;
      border: none;
      border-radius: 3px;
      padding: 15px 25px;
      margin: 0px 2px 10px 15px;
      font-size: 17px;
      text-align: center;
      text-decoration: none;
      vertical-align: top;
      display: inline-block;
    }
    .x-add-dvr:hover {
      background-color: #ffbb57;
    }
    .x-add-dvr:disabled {
      background-color: #886633;
    }`
  ));
  document.head.appendChild(style);

  // insert the add-to-dvr button
  let button = document.createElement('button');
  button.className = 'x-add-dvr dv-signup-button';
  button.type = "button";
  button.onclick = select;
  button.appendChild(document.createTextNode(label));
  let success = document.createElement('span');
  success.style.display = "none";
  success.appendChild(document.createTextNode("\u00a0\u2714"));
  button.appendChild(success);
  let failure = document.createElement('span');
  failure.style.display = "none";
  failure.appendChild(document.createTextNode("\u00a0\u26a0"));
  button.appendChild(failure);
  let loc = document.querySelector('.dv-dp-node-synopsis, .av-synopsis');
  //loc && loc.parentNode.insertBefore(button, loc.nextSibling);
  loc && loc.parentNode.insertBefore(button, loc);

  // restore old capture state (to select through series quickly)
  let tgt = GM_getValue("capture","");
  head.isPrime || (tgt = "");
  (tgt !== head.parentTitle) && GM_setValue("capture","");
  (tgt === head.parentTitle) && select(null);
  button.disabled = !head.isPrime;

  // button state indicator
  function status(r,log)
  {
    success.style.display = (r&1) ? button.style.display : "none";
    failure.style.display = (r&2) ? button.style.display : "none";
    log && console.log(log);
  }

  // handle click state
  function select(evt)
  {
    // handle disabling button (simple flow)
    if ((success.style.display === button.style.display) || (failure.style.display == button.style.display)) {
      status(0,null);
      GM_setValue("capture","");
      return;
    }

    // mark button as capturing and save content title (for multiple seasons)
    status(1,null);
    GM_setValue("capture",head.parentTitle);

    // add to dvr playlist
    console.log("post data:");
    console.log(parm);
    GM_xmlhttpRequest({
      method:    "POST",
      url:       endpoint,
      data:      JSON.stringify(send),
      headers: { "Content-Type": "application/json" },

      onload:    function(r) { (r.status != 200) && status(2,r); },
      onerror:   function(r) { status(2,"onerror"); },
      ontimeout: function(r) { status(2,"ontimeout"); },
      onabort:   function(r) { status(2,"onabort"); },
    });
  }
}());
3 Likes