Translate Trakt

Translates titles, plots, taglines and posters of movies, TV series and episodes in the choice language

// ==UserScript==
// @name               Translate Trakt
// @name:de            Übersetzen Trakt
// @name:es            Traducir Trakt
// @name:fr            Traduire Trakt
// @name:it            Traduci Trakt
// @name:ru            Перевести Trakt
// @name:zh-CN         翻译Trakt
// @author             Davide <iFelix18@protonmail.com>
// @namespace          https://github.com/iFelix18
// @icon               https://www.google.com/s2/favicons?sz=64&domain=https://trakt.tv
// @description        Translates titles, plots, taglines and posters of movies, TV series and episodes in the choice language
// @description:de     Übersetzt Titel, Plots, Taglines und Plakate von Filmen, TV-Serien und Episoden in die gewünschte Sprache
// @description:es     Traduce títulos, argumentos, eslóganes y carteles de películas, series de televisión y episodios en el idioma elegido
// @description:fr     Traduire les titres, les intrigues, les slogans et les affiches de films, de séries télévisées et d'épisodes dans la langue choisie
// @description:it     Traduce titoli, trame, tagline e poster di film, serie TV ed episodi nella lingua scelta
// @description:ru     Переводит названия, сюжеты, теглайны и постеры фильмов, сериалов и эпизодов на выбранный язык
// @description:zh-CN  翻译电影、电视剧和剧集的标题、剧情、标语和海报的选择语言
// @copyright          2019, Davide (https://github.com/iFelix18)
// @license            MIT
// @version            5.0.0
// @homepage           https://github.com/iFelix18/Trakt-Userscripts#readme
// @homepageURL        https://github.com/iFelix18/Trakt-Userscripts#readme
// @supportURL         https://github.com/iFelix18/Trakt-Userscripts/issues
// @require            https://cdn.jsdelivr.net/gh/sizzlemctwizzle/GM_config@2207c5c1322ebb56e401f03c2e581719f909762a/gm_config.min.js
// @require            https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js
// @require            https://cdn.jsdelivr.net/npm/@ifelix18/utils@6.2.1/lib/index.min.js
// @require            https://cdn.jsdelivr.net/npm/@ifelix18/trakt@2.3.1/lib/index.min.js
// @require            https://cdn.jsdelivr.net/npm/@ifelix18/tmdb@2.2.1/lib/index.min.js
// @match              *://trakt.tv/*
// @connect            api.trakt.tv
// @connect            api.themoviedb.org
// @compatible         chrome
// @compatible         edge
// @compatible         firefox
// @compatible         safari
// @grant              GM_getValue
// @grant              GM_setValue
// @grant              GM.deleteValue
// @grant              GM.getValue
// @grant              GM.listValues
// @grant              GM.registerMenuCommand
// @grant              GM.setValue
// @grant              GM.xmlHttpRequest
// @run-at             document-idle
// @inject-into        content
// ==/UserScript==

/* global $, GM_config, TMDb, Trakt, UU */

(() => {
  //* Constants
  const tml = 3600 // 3600 seconds = 1 hour
  const id = GM.info.script.name.toLowerCase().replace(/\s/g, '-')
  const title = `${GM.info.script.name} v${GM.info.script.version} Settings`
  const fields = {
    TMDbApiKey: {
      label: 'TMDb API Key',
      section: ['TMDb', 'Get one at: https://developers.themoviedb.org/3/'],
      labelPos: 'left',
      type: 'text',
      title: 'Your TMDb API Key',
      size: 70,
      default: ''
    },
    TraktClientID: {
      label: 'Trakt Client ID',
      section: ['Trakt', 'Get one at: https://trakt.tv/oauth/applications/new'],
      labelPos: 'left',
      type: 'text',
      title: 'Your Trakt Client ID',
      size: 70,
      default: ''
    },
    language: {
      label: 'Language code',
      section: ['Language', 'More info at: https://developers.themoviedb.org/3/configuration/get-primary-translations'],
      labelPos: 'left',
      type: 'select',
      title: 'Your language code',
      options: ['af-ZA', 'ar-AE', 'ar-SA', 'be-BY', 'bg-BG', 'bn-BD', 'ca-ES', 'ch-GU', 'cn-CN', 'cs-CZ', 'cy-GB', 'da-DK', 'de-AT', 'de-CH', 'de-DE', 'el-GR', 'en-AU', 'en-CA', 'en-GB', 'en-IE', 'en-NZ', 'en-US', 'eo-EO', 'es-ES', 'es-MX', 'et-EE', 'eu-ES', 'fa-IR', 'fi-FI', 'fr-CA', 'fr-FR', 'ga-IE', 'gd-GB', 'gl-ES', 'he-IL', 'hi-IN', 'hr-HR', 'hu-HU', 'id-ID', 'it-IT', 'ja-JP', 'ka-GE', 'kk-KZ', 'kn-IN', 'ko-KR', 'ky-KG', 'lt-LT', 'lv-LV', 'ml-IN', 'mr-IN', 'ms-MY', 'ms-SG', 'nb-NO', 'nl-BE', 'nl-NL', 'no-NO', 'pa-IN', 'pl-PL', 'pt-BR', 'pt-PT', 'ro-RO', 'ru-RU', 'si-LK', 'sk-SK', 'sl-SI', 'sq-AL', 'sr-RS', 'sv-SE', 'ta-IN', 'te-IN', 'th-TH', 'tl-PH', 'tr-TR', 'uk-UA', 'vi-VN', 'zh-CN', 'zh-HK', 'zh-SG', 'zh-TW', 'zu-ZA'],
      default: 'en-US'
    },
    logging: {
      label: 'Logging',
      section: ['Develop'],
      labelPos: 'left',
      type: 'checkbox',
      default: true
    },
    debugging: {
      label: 'Debugging',
      labelPos: 'left',
      type: 'checkbox',
      default: false
    },
    visualDebugging: {
      label: 'Visual debugging',
      labelPos: 'left',
      type: 'checkbox',
      default: false
    },
    clearCache: {
      label: 'Clear all the cache',
      type: 'button',
      click: async () => {
        const values = await GM.listValues()

        for (const value of values) {
          const cache = await GM.getValue(value) // get cache
          if (cache.time) { GM.deleteValue(value) } // delete cache
        }

        UU.alert('cache cleared')
      }
    }
  }

  //* GM_config
  UU.migrateConfig('trakt-config', id) // migrate to the new GM_config ID
  GM_config.init({
    id,
    title,
    fields,
    css: ':root{--font:"Montserrat",sans-serif;--background-grey:rgb(29, 29, 29);--black:rgb(0, 0, 0);--dark-grey:rgb(22, 22, 22);--grey:rgb(51, 51, 51);--light-grey:rgb(102, 102, 102);--red:rgb(237, 34, 36);--white:rgb(255, 255, 255)}#translate-trakt *{color:var(--white)!important;font-family:var(--font)!important;font-size:14px!important;font-weight:400!important}#translate-trakt{background:var(--background-grey)!important}#translate-trakt .config_header{font-size:34px!important;line-height:1.1!important;text-shadow:0 0 20px var(--black)!important}#translate-trakt .section_header_holder{background:var(--dark-grey)!important;border:1px solid var(--grey)!important;margin-bottom:1em!important}#translate-trakt .section_header{background:var(--grey)!important;border:1px solid var(--grey)!important;padding:8px!important;text-align:left!important;text-transform:uppercase!important}#translate-trakt .section_desc{background:var(--black)!important;border:1px solid var(--grey)!important;border-left:0!important;border-right:0!important;font-size:13px!important;margin:0!important;padding:10px 8px!important;text-align:left!important}#translate-trakt .config_var{align-items:center!important;display:flex!important;margin:0!important;padding:15px!important}#translate-trakt .field_label{margin-left:6px!important}#translate-trakt select,#translate-trakt_field_TMDbApiKey,#translate-trakt_field_TraktClientID{background-color:var(--grey)!important;border:1px solid var(--light-grey)!important;box-shadow:inset 0 1px 1px rgba(0,0,0,.075)!important;flex:1!important;padding:6px 12px!important}#translate-trakt select:focus,#translate-trakt_field_TMDbApiKey:focus,#translate-trakt_field_TraktClientID:focus{box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)!important;outline:0!important}#translate-trakt button,#translate-trakt input[type=button]{background:var(--grey)!important;border:1px solid transparent!important;padding:10px 16px!important}#translate-trakt button:hover,#translate-trakt input[type=button]:hover{filter:brightness(85%)!important}#translate-trakt_buttons_holder button{background-color:var(--red)!important}#translate-trakt .reset{margin-right:10px!important}',
    events: {
      init: () => {
        window.addEventListener('load', () => { // add style
          $('head').append('<style>@import url(https://fonts.googleapis.com/css2?family=Montserrat&display=swap);header#top-nav .navbar-nav.navbar-user:hover #user-menu{max-height:max-content}</style>')
        })
        if (GM.info.scriptHandler !== 'Userscripts') { //! Userscripts Safari: GM.registerMenuCommand is missing
          GM.registerMenuCommand('Configure', () => GM_config.open())
        }
        if (GM_config.get('TMDbApiKey') === '' || GM_config.get('TraktClientID') === '') { // first configuration
          window.addEventListener('load', () => GM_config.open())
        }
      },
      save: () => {
        if (GM_config.get('TMDbApiKey') === '' || GM_config.get('TraktClientID') === '') {
          UU.alert('check your settings and save')
        } else {
          UU.alert('settings saved')
          GM_config.close()
          setTimeout(window.location.reload(false), 500)
        }
      }
    }
  })

  //* Utils
  UU.init({ id, logging: GM_config.get('logging') })

  //* Trakt API
  const trakt = new Trakt({
    client_id: GM_config.get('TraktClientID'),
    debug: GM_config.get('debugging'),
    cache: {
      active: true,
      time_to_live: tml
    }
  })

  //* TMDb API
  const tmdb = new TMDb({
    api_key: GM_config.get('TMDbApiKey'),
    language: GM_config.get('language'),
    debug: GM_config.get('debugging'),
    cache: {
      active: true,
      time_to_live: tml
    }
  })

  //* Functions
  /**
   * Adds a link to the menu to access the script configuration
   */
  const addSettingsToMenu = () => {
    const menu = `<li class=${id}><a href=""onclick=return!1>${GM.info.script.name}</a>`

    $('#user-menu ul li.separator').last().after(menu)
    $(`.${id}`).click(() => GM_config.open())
  }

  /**
   * Clear old data from the cache
   */
  const clearOldCache = async () => {
    const values = await GM.listValues()

    for (const value of values) {
      const cache = await GM.getValue(value) // get cache
      if ((Date.now() - cache.time) > tml * 1000) { GM.deleteValue(value) } // delete old cache
    }
  }

  /**
   * Returns if translated
   *
   * @param {object} item Item to be translated
   * @returns {boolean} Is translated
   */
  const isTranslated = (item) => $(item).is('.translate, .untranslatable, .translated')

  /**
   * Translate poster
   *
   * @param {object} item Item to be translated
   * @param {string} path Poster path
   * @param {string} size Poster size
   */
  const translatePoster = (item, path, size) => {
    const url = `https://image.tmdb.org/t/p/${size}${path}`
    const $node = $(item).find('.poster .real').filter((index, node) => node.nodeType === 1 && !node.closest('#actors') && !node.closest('#lists'))

    if ($node && path) {
      $.each($node, (key, value) => {
        $(value).removeAttr('data-original').removeAttr('src').attr('src', url)
        if (GM_config.get('visualDebugging')) $(value).css('border', '4px solid #FF00CC')
      })
    }
  }

  /**
   * Translate backdrop
   *
   * @param {object} item Item to be translated
   * @param {string} path Backdrop path
   * @param {string} size Backdrop size
   */
  const translateBackdrop = (item, path, size) => {
    const url = `https://image.tmdb.org/t/p/${size}${path}`
    const $node = $(item).find('.fanart:not(.poster) .real, .screenshot .real, #summary-wrapper .full-screenshot')

    if ($node && path) {
      $.each($node, (key, value) => {
        $(value).css('background-image') !== 'none'
          ? $(value).css('background-image', `url('${url}')`)
          : $(value).removeAttr('data-original').removeAttr('src').attr('src', url)
        if (GM_config.get('visualDebugging')) $node.css('border', '4px solid #FF9933')
      })
    }
  }

  /**
   * Translate title
   *
   * @param {object} item Item to be translated
   * @param {string} originalTitle Original title
   * @param {string} translatedTitle Translated title
   */
  const translateTitle = (item, originalTitle, translatedTitle) => {
    const $node = $(item).parent().find('*').contents().filter((index, node) => node.nodeType === 3 && node.textContent.normalize().replace(/[\p{P}\p{Z}]/gu, '') === originalTitle.normalize().replace(/[\p{P}\p{Z}]/gu, ''))

    if ($node && originalTitle !== translatedTitle) {
      $.each($node, (key, value) => {
        value.textContent.endsWith(': ')
          ? value.textContent = `${translatedTitle}: `
          : (value.textContent.endsWith(' ')
              ? value.textContent = `${translatedTitle} `
              : value.textContent = translatedTitle)
        if (GM_config.get('visualDebugging')) $(value).parent().css('color', '#50BFE6')
      })
    }
  }

  /**
   * Translate tagline
   *
   * @param {object} item Item to be translated
   * @param {string} tagline Tagline
   */
  const translateTagline = (item, tagline) => {
    const $node = $(item).find('#info-wrapper .info #tagline')

    if ($node && tagline) {
      $node.text(tagline)
      if (GM_config.get('visualDebugging')) $($node).parent().css('color', '#50BFE6')
    }
  }

  /**
   * Translate overview
   *
   * @param {object} item Item to be translated
   * @param {string} overview Overview
   */
  const translateOverview = (item, overview) => {
    const $node = $(item).parent().find('#info-wrapper .info #overview, > .under-info > .overview p')

    if ($node && overview) {
      $node.text(overview)
      if (GM_config.get('visualDebugging')) $($node).parent().css('color', '#50BFE6')
    }
  }

  /**
   * Translate movie
   *
   * @param {object} item Item to be translated
   * @param {object} data Data from Trakt API
   */
  const translateMovie = async (item, data) => {
    const id = data.movie.ids.tmdb // TMDb IDk

    if (!id) { // untranslatable
      $(item).removeClass('translate').addClass('untranslatable')
      return
    }

    const movie = await tmdb.movie.details({ movie_id: id }).then().catch(error => UU.error(error)) // show details

    if (!movie) { // untranslatable
      $(item).removeClass('translate').addClass('untranslatable')
      return
    }

    translatePoster(item, movie.poster_path, 'w300') // translate movie poster
    translateBackdrop(item, movie.backdrop_path, 'original') // translate movie backdrop
    translateTitle(item, movie.original_title, movie.title) // translate movie title
    translateTagline(item, movie.tagline) // translate movie tagline
    translateOverview(item, movie.overview) // translate movie overview

    $(item).removeClass('translate').addClass('translated') // is now translated

    UU.log(`the movie "${movie.original_title}" is translated`)
  }

  /**
   * Translate show
   *
   * @param {object} item Item to be translated
   * @param {object} data Data from Trakt API
   */
  const translateShow = async (item, data) => {
    const id = data.show.ids.tmdb // TMDb IDk

    if (!id) { // untranslatable
      $(item).removeClass('translate').addClass('untranslatable')
      return
    }

    const show = await tmdb.tv.details({ tv_id: id }).then().catch(error => UU.error(error)) // show details

    if (!show) { // untranslatable
      $(item).removeClass('translate').addClass('untranslatable')
      return
    }

    translatePoster(item, show.poster_path, 'w300') // translate show poster
    translateBackdrop(item, show.backdrop_path, 'original') // translate episode backdrop
    translateTitle(item, data.show.title, show.name) // translate show title
    translateOverview(item, show.overview) // translate show overview

    $(item).removeClass('translate').addClass('translated') // is now translated

    UU.log(`the show "${data.show.title}" is translated`)
  }

  /**
   * Translate season
   *
   * @param {object} item Item to be translated
   * @param {object} data Data from Trakt API
   */
  const translateSeason = async (item, data) => {
    const id = data.show.ids.tmdb // TMDb ID

    if (!id) { // untranslatable
      $(item).removeClass('translate').addClass('untranslatable')
      return
    }

    const show = await tmdb.tv.details({ tv_id: id }).then().catch(error => UU.error(error)) // show details
    const season = show.seasons.map((season) => season).find((season) => Number(season.season_number) === Number(data.number)) // season details

    if (!show && !season) { // untranslatable
      $(item).removeClass('translate').addClass('untranslatable')
      return
    }

    translatePoster(item, season.poster_path, 'w300') // translate season poster
    translateBackdrop(item, show.backdrop_path, 'original') // translate show backdrop
    translateTitle(item, data.show.title, show.name) // translate show title
    translateTitle(item, data.title, season.name) // translate season title
    translateOverview(item, season.overview) // translate season overview

    $(item).removeClass('translate').addClass('translated') // is now translated

    UU.log(`the season "${data.show.title} - ${data.title}" is translated`)
  }

  /**
   * Translate episode
   *
   * @param {object} item Item to be translated
   * @param {object} data Data from Trakt API
   */
  const translateEpisode = async (item, data) => {
    const id = data.show.ids.tmdb // TMDb IDk

    if (!id) { // untranslatable
      $(item).removeClass('translate').addClass('untranslatable')
      return
    }

    const show = await tmdb.tv.details({ tv_id: id }).then().catch(error => UU.error(error)) // show details
    const season = show.seasons.map((season) => season).find((season) => Number(season.season_number) === Number(data.episode.season)) // season details
    const episode = await tmdb.tv.episode.details({ tv_id: id, season_number: data.episode.season, episode_number: data.episode.number }).then().catch(error => UU.error(error)) // episode details

    if (!show && !season && !episode) { // untranslatable
      $(item).removeClass('translate').addClass('untranslatable')
      return
    }

    translatePoster(item, season.poster_path, 'w300') // translate season poster
    translateBackdrop(item, episode.still_path, 'original') // translate episode backdrop
    translateTitle(item, data.show.title, show.name) // translate show title
    translateTitle(item, data.title, season.name) // translate season title
    translateTitle(item, data.episode.title, episode.name) // translate episode title
    translateOverview(item, episode.overview) // translate episode overview

    $(item).removeClass('translate').addClass('translated') // is now translated

    UU.log(`the episode "${data.show.title} ${episode.season_number}x${episode.episode_number} - ${data.episode.title}" is translated`)
  }

  /**
   * Translate items
   *
   * @param {object} item Item to be translated
   */
  const translateItem = async (item) => {
    clearOldCache() // clear old cache

    if (isTranslated(item)) return

    $(item).addClass('translate') // translate item...

    const infos = $(item).data('movieId') || $(item).data('showId') || $(item).data('seasonId')
      ? $(item).data()
      : $(item).find('*[data-movie-id], *[data-show-id], *[data-season-id]').first().data()

    if (infos.type === 'movie') { // translate movie
      if (!infos.movieId) { // untranslatable
        $(item).removeClass('translate').addClass('untranslatable')
        return
      } else {
        const data = await trakt.search.id({ id_type: 'trakt', id: infos.movieId, type: 'movie' }).then(response => response[0]).catch(error => UU.error(error)) // get movie data from Trakt API
        translateMovie(item, data)
      }
    }
    if (infos.type === 'show') { // translate show
      if (!infos.showId) { // untranslatable
        $(item).removeClass('translate').addClass('untranslatable')
        return
      } else {
        const data = await trakt.search.id({ id_type: 'trakt', id: infos.showId, type: 'show' }).then(response => response[0]).catch(error => UU.error(error)) // get show data from Trakt API
        translateShow(item, data)
      }
    }
    if (infos.type === 'season') { // translate seasons
      if (!infos.showId) { // untranslatable
        $(item).removeClass('translate').addClass('untranslatable')
        return
      } else {
        const data1 = await trakt.search.id({ id_type: 'trakt', id: infos.showId, type: 'show' }).then(response => response[0]).catch(error => UU.error(error)) // get show data from Trakt API
        const data2 = await trakt.seasons.summary({ id: infos.showId, extended: 'full' }).then(response => response.map((season) => season).find((season) => season.number === Number(infos.seasonNumber))).catch(error => UU.error(error)) // get season data from Trakt API
        const data = $.extend({}, data1, data2) // merge data
        translateSeason(item, data)
      }
    }
    if (infos.type === 'episode') { // translate episodes
      if (!infos.episodeId) { // untranslatable
        $(item).removeClass('translate').addClass('untranslatable')
      } else {
        const data1 = await trakt.search.id({ id_type: 'trakt', id: infos.episodeId, type: 'episode' }).then(response => response[0]).catch(error => UU.error(error)) // get episode data from Trakt API
        const data2 = await trakt.seasons.summary({ id: infos.showId, extended: 'full' }).then(response => response.map((season) => season).find((season) => season.number === Number(infos.seasonNumber))).catch(error => UU.error(error)) // get season data from Trakt API
        const data = $.extend({}, data1, data2) // merge data
        translateEpisode(item, data)
      }
    }

    if (GM_config.get('visualDebugging') && !$(item).hasClass('untranslatable')) $(item).css('border', '1px solid #66FF66')
  }

  //* Script
  UU.observe.creation('#user-menu ul', () => addSettingsToMenu()) // link settings to trakt menu
  UU.observe.creation('body:not(.people) .grid-item:visible', (item) => translateItem(item), true) // grid items
  UU.observe.creation('.movies.show, .shows.show, .shows.season, .shows.episode', (item) => translateItem(item)) // main pages
})()