// ==UserScript==
// @name Sonarr-Title-i18n
// @name:zh Sonarr 标题国际化
// @description 利用 TMDB 接口把 Sonarr 中的标题替换成其他语言标题
// @namespace https://github.com/LuckyPuppy514
// @version 1.0.4
// @homepage https://github.com/LuckyPuppy514/Sonarr-Title-i18n
// @author LuckyPuppy514
// @copyright 2022, Grant LuckyPuppy514 (https://github.com/LuckyPuppy514)
// @license MIT
// @icon https://github.rn.lckp.top/LuckyPuppy514/dashboard-icons/master/png/sonarr.png
// @include *://*sonarr*
// @include *://*:8989/*
// @run-at document-end
// @require https://unpkg.com/[email protected]/dist/jquery.min.js
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// ==/UserScript==
'use strict';
// 默认语言代码
const DEFAULT_LANGUAGE_CODE = "zh-CN";
// GM_setValue key
const KEY_TMDB_API_KEY = "KEY_TMDB_API_KEY";
const KEY_LANGUAGE_CODE = "KEY_LANGUAGE_CODE";
const KEY_ERROR_MESSAGE = "KEY_ERROR_MESSAGE";
const KEY_TITLE_PREFIX = "TITLE_";
const KEY_TVDBID_PREFIX = "TVDBID_";
const KEY_OVERVIEW_PREFIX = "OVERVIEW_";
// className
const RIGHT_HEADERF_CLASS_NAME = "PageHeader-right-e8LU4";
const POSTER_TITLE_CLASS_NAME = "SeriesIndexPoster-title-rhAQh";
const OVERVIEW_TITLE_CLASS_NAME = "SeriesIndexOverview-title-LQthD SeriesIndexOverview-link-ltHLM Link-link-RInnp Link-link-RInnp Link-to-kylTi";
const DETAILS_TITLE_CLASS_NAME = "SeriesDetails-title-pJv1g";
const CALENDAR_TITLE_CLASS_NAME = "CalendarEvent-seriesTitle-QSWzp";
const CALENDAR_TITLE_AGENDA_CLASS_NAME = "AgendaEvent-seriesTitle-uBPt0";
const DETAILS_OVERVIEW_CLASS_NAME = "SeriesDetails-overview-cQJdA";
const SERIES_TITLE_CLASS_NAME = "Link-link-RInnp Link-to-kylTi";
// url path
const DETAILS_TITLE_PATH = "/series/";
const CALENDAR_TITLE_PATH = "/calendar";
const SERIES_TITLE_PATH = "/serieseditor, /seasonpass, /queue, /history, /blocklist, /missing, /cutoffunmet";
// element id
const i18n_BUTTON_ID = "i18n-button";
const SETTING_HIDDEN_DIV_ID = "setting-hidden-div";
const SETTING_SHOW_DIV_ID = "setting-show-div";
const CLOSE_BUTTON_ID = "close-button";
const SAVE_BUTTON_ID = "save-button";
const CLEAR_CACHE_BUTTON_ID = "clear-cache-button";
const TMDB_API_KEY_INPUT_ID = "tmdb-api-key";
const LANGUAGE_CODE_INPUT_ID = "language-code";
const ERROR_MESSAGE_TEXTAREA_ID = "error-message";
// css
const CSS = `
#i18n-button {
width: 25px;
height: 25px;
margin-top: 17px;
margin-left: -15px;
margin-right: 17px;
border: 0px;
border-radius: 50%;
background: rgba(255,255,255, 0);
background-repeat: no-repeat;
cursor: pointer;
z-index: 999
}
#i18n-button-svg {
width: 23px;
height: 23px;
}
#setting-div {
display: flex;
justify-content: center;
}
#setting-hidden-div {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
background-color: #000000;
opacity: 0.3;
display: none;
}
#setting-show-div {
width: 500px;
height: 260px;
background-color: rgba(64, 68, 84, 0.9);
display: none;
flex-direction: column;
border-radius: 5px;
align-items: center;
padding-top: 40px;
box-sizing: border-box;
position: absolute;
top: 200px;
}
#close-button {
position: absolute;
top: 7px;
right: 7px;
width: 28px;
height: 28px;
border-radius: 50%;
background-size: cover;
background-image: url(https://cdn.jsdelivr.net/gh/LuckyPuppy514/pic-bed/common/icons8-close-48.png);
background-repeat: no-repeat;
background-color: rgba(91, 137, 254, 0);
color: rgba(255, 255, 255, 0);
font-weight: normal;
}
#close-button:hover {
background-color: rgba(255, 255, 255, 0.5);
cursor: pointer;
}
#setting-show-div input {
width: 280px;
height: 25px;
border-radius: 5px;
border: none;
outline: none;
padding-left: 5px;
background-color: rgba(0, 0, 0, 1);
color: rgba(255, 255, 255, 1);
}
#setting-show-div input::-webkit-input-placeholder {
color: rgb(255, 255, 255);
opacity: 0.4;
}
#setting-show-div input:first-child {
margin-top: 5px;
margin-bottom: 5px;
}
#save-button {
cursor: pointer;
width: 300px;
height: 30px;
border-radius: 5px;
border: none;
outline: none;
margin-left: 5px;
padding-left: 5px;
background-color: rgba(0, 255, 0, 0.8);
color: rgba(255, 255, 255, 1);
}
#clear-cache-button:hover {
background-color: rgba(255, 255, 255, 0.5);
cursor: pointer;
}
#clear-cache-button {
position: absolute;
bottom: 8px;
right: 8px;
width: 30px;
height: 30px;
border-radius: 50%;
background-size: cover;
background-image: url(https://cdn.jsdelivr.net/gh/LuckyPuppy514/pic-bed/common/icons8-broom-64.png);
background-repeat: no-repeat;
background-color: rgba(91, 137, 254, 0);
color: rgba(255, 255, 255, 0);
font-weight: normal;
}
strong:hover:after {
position: absolute;
left: 30px;
top: -25px;
padding: 0px;
border: 1px solid rgb(255, 255, 255);
background-color: rgba(0,0,0,0.8);
border-radius: 3px;
color: rgba(255, 255, 255, 1);
content: attr(data-tips);
text-align: center;
z-index: 2;
width: 90px;
height: 30px;
}
#error-message {
width: 280px;
height: 50px;
border-radius: 5px;
border: none;
outline: none;
padding-left: 5px;
margin-top: 5px;
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 1);
color: rgba(255, 255, 255, 1);
}
#setting-table {
width: 420px;
height: 100px;
border: none;
}
`
// html
const i18n_BUTTON = `
<svg id="i18n-button-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px"><path fill="#CFD8DC" d="M15,13h25c1.104,0,2,0.896,2,2v25c0,1.104-0.896,2-2,2H26L15,13z"/><path fill="#546E7A" d="M26.832,34.854l-0.916-1.776l0.889-0.459c0.061-0.031,6.101-3.208,9.043-9.104l0.446-0.895l1.79,0.893l-0.447,0.895c-3.241,6.496-9.645,9.85-9.916,9.989L26.832,34.854z"/><path fill="#546E7A" d="M38.019 34l-.87-.49c-.207-.116-5.092-2.901-8.496-7.667l1.627-1.162c3.139 4.394 7.805 7.061 7.851 7.087L39 32.26 38.019 34zM26 22H40V24H26z"/><path fill="#546E7A" d="M32 20H34V24H32z"/><path fill="#2196F3" d="M33,35H8c-1.104,0-2-0.896-2-2V8c0-1.104,0.896-2,2-2h14L33,35z"/><path fill="#3F51B5" d="M26 42L23 35 33 35z"/><path fill="#FFF" d="M19.172,24h-4.36l-1.008,3H11l4.764-13h2.444L23,27h-2.805L19.172,24z M15.444,22h3.101l-1.559-4.714L15.444,22z"/></svg>
`
const SETTING_DIV = `
<div id="setting-div">
<div id="setting-hidden-div"></div>
<div id="setting-show-div">
<strong id="close-button" data-tips="Close"></strong>
<table id="setting-table">
<tr>
<td>🔑 TMDB API Key</td>
<td><input type="text" id="tmdb-api-key" placeholder="Please input TMDB API Key"></td>
</tr>
<tr>
<td>✨ Language Code</td>
<td><input type="text" id="language-code" placeholder="Please input Language Code"></td>
</tr>
<tr>
<td>😰 Error Message</td>
<td><textarea type="text" id="error-message" readonly></textarea></td>
</tr>
</table>
<button type="button" id="save-button">Save</button>
<a href="https://www.themoviedb.org/settings/api" target="_blank" style="text-decoration: none; color: rgba(0, 255, 0, 0.8); margin-top: 10px">🔑 Get TMDB API Key 🔑<a>
<strong id="clear-cache-button" data-tips="Clear Cache"></strong>
</div>
</div>
`
// 添加按钮和设置组件
function addi18nButtonAndSettingDiv() {
// 添加 CSS
var css = document.createElement("style");
css.innerHTML = CSS.trim();
document.head.appendChild(css);
// 添加 i18n 按钮
var i18nButton = document.createElement("button");
i18nButton.id = i18n_BUTTON_ID;
i18nButton.innerHTML = i18n_BUTTON.trim();
// 延时等待右导航栏加载
setTimeout(function () {
let rightHeader = document.getElementsByClassName(RIGHT_HEADERF_CLASS_NAME)[0];
if (rightHeader) {
rightHeader.appendChild(i18nButton);
}
}, 1000);
// 添加设置组件
var div = document.createElement("div");
div.innerHTML = SETTING_DIV.trim();
document.body.appendChild(div);
// 添加事件
var closeButton = document.getElementById(CLOSE_BUTTON_ID);
var saveButton = document.getElementById(SAVE_BUTTON_ID);
var clearCacheButton = document.getElementById(CLEAR_CACHE_BUTTON_ID);
var tmdbApiKeyInput = document.getElementById(TMDB_API_KEY_INPUT_ID);
var languageCodeInput = document.getElementById(LANGUAGE_CODE_INPUT_ID);
var errorMessageTextarea = document.getElementById(ERROR_MESSAGE_TEXTAREA_ID);
// 打开设置界面
i18nButton.onclick = function () {
let tmdbApiKey = GM_getValue(KEY_TMDB_API_KEY);
let languageCode = GM_getValue(KEY_LANGUAGE_CODE);
if (tmdbApiKey) {
tmdbApiKeyInput.value = tmdbApiKey;
}
if (languageCode) {
languageCodeInput.value = languageCode;
} else {
languageCodeInput.value = DEFAULT_LANGUAGE_CODE;
}
document.getElementById(SETTING_SHOW_DIV_ID).style.display = "flex";
document.getElementById(SETTING_HIDDEN_DIV_ID).style.display = "block";
};
// 关闭设置界面
closeButton.onclick = function () {
let settingShowDiv = document.getElementById(SETTING_SHOW_DIV_ID);
let settingHiddenDiv = document.getElementById(SETTING_HIDDEN_DIV_ID);
settingShowDiv.style.display = 'none';
settingHiddenDiv.style.display = 'none';
settingShowDiv.style.top = '200px';
settingShowDiv.style.left = '';
}
// 保存设置
saveButton.onclick = function () {
GM_setValue(KEY_TMDB_API_KEY, tmdbApiKeyInput.value);
GM_setValue(KEY_LANGUAGE_CODE, languageCodeInput.value);
clearCache();
initSeriesData();
closeButton.click();
}
// 清除缓存
clearCacheButton.onclick = function () {
clearCache();
Toast("Clear Cache Success", 2500);
}
}
// 清除缓存
function clearCache() {
let keys = GM_listValues();
for (let key of keys) {
if (key.indexOf(KEY_TITLE_PREFIX) != -1 || key.indexOf(KEY_OVERVIEW_PREFIX) != -1 || key == KEY_ERROR_MESSAGE) {
GM_deleteValue(key);
}
}
document.getElementById(ERROR_MESSAGE_TEXTAREA_ID).value = "";
}
// 保存错误信息
function saveErrorMessage(errorMessage) {
var errorMessageTextarea = document.getElementById(ERROR_MESSAGE_TEXTAREA_ID);
errorMessageTextarea.value = errorMessage;
GM_setValue(KEY_ERROR_MESSAGE, errorMessage);
}
// 初始化数据
function initSeriesData() {
var tmdbApiKey = GM_getValue(KEY_TMDB_API_KEY);
var languageCode = GM_getValue(KEY_LANGUAGE_CODE);
if (!tmdbApiKey || !languageCode) {
return;
}
// 获取 Sonarr apiKey
$.ajax({
type: "GET",
url: "/initialize.json",
xhrFields: {
withCredentials: true
},
success: function (res) {
let apiKey = res.apiKey;
// 获取所有剧集的标题和 tvdbId
$.ajax({
type: "GET",
url: "/api/v3/series",
xhrFields: {
withCredentials: true
},
headers: {
"X-Api-Key": apiKey
},
success: function (res) {
for (let tv of res) {
// 没有数据则请求 TMDB 接口
if (!GM_getValue(KEY_TITLE_PREFIX + tv.title)) {
GM_setValue(KEY_TVDBID_PREFIX + tv.title, tv.tvdbId);
translate(tv.title, false);
}
if (GM_getValue(KEY_ERROR_MESSAGE)) {
break;
}
}
},
error: function (err) {
let errorMessage = "获取 Sonarr 剧集列表出错: " + JSON.stringify(err);
saveErrorMessage(errorMessage)
console.log(errorMessage);
}
});
},
error: function (err) {
let errorMessage = "获取 Sonarr apiKey 出错: " + JSON.stringify(err);
saveErrorMessage(errorMessage)
console.log(errorMessage);
}
});
}
// 显示消息
function Toast(msg, duration) {
duration = isNaN(duration) ? 3000 : duration;
var m = document.createElement('div');
m.innerHTML = msg;
m.style.cssText = "max-width:60%;min-width: 150px;padding:0 14px;height: 40px;color: rgb(255, 255, 255);line-height: 40px;text-align: center;border-radius: 4px;position: fixed;top: 50%;left: 50%;transform: translate(-50%, -50%);z-index: 999999;background: rgba(0, 0, 0,.7);font-size: 16px;";
document.body.appendChild(m);
setTimeout(function () {
var d = 0.5;
m.style.opacity = '0';
setTimeout(function () { document.body.removeChild(m) }, d * 1000);
}, duration);
}
// 通过 TMDB 接口获取对应语言的标题和简介
function translate(title, saveOverview) {
var tmdbApiKey = GM_getValue(KEY_TMDB_API_KEY);
var languageCode = GM_getValue(KEY_LANGUAGE_CODE);
if (!tmdbApiKey || !languageCode || GM_getValue(KEY_ERROR_MESSAGE)) {
return;
}
GM_setValue(KEY_TITLE_PREFIX + title, title);
let tvdbId = GM_getValue(KEY_TVDBID_PREFIX + title);
$.ajax({
type: "GET",
url: "https://api.themoviedb.org/3/find/" + tvdbId + "?api_key=" + tmdbApiKey + "&language=" + languageCode + "&external_source=tvdb_id",
success: function (res) {
if (res && res.tv_results && res.tv_results.length > 0) {
GM_setValue(KEY_TITLE_PREFIX + title, res.tv_results[0].name);
if (saveOverview) {
let overview = res.tv_results[0].overview;
if (overview) {
GM_setValue(KEY_OVERVIEW_PREFIX + title, overview);
let overviewDiv = document.getElementsByClassName(DETAILS_OVERVIEW_CLASS_NAME)[0];
if (overviewDiv) {
overviewDiv.innerHTML = '<div style="overflow: hidden;"><span>' + overview + '</span></div>';
}
}
}
}
},
error: function (err) {
let errorMessage = "请求 TMDB 接口出错: " + JSON.stringify(err);
saveErrorMessage(errorMessage)
console.log(errorMessage);
}
});
}
// 替换网页中的标题
function replaceTitle() {
var tmdbApiKey = GM_getValue(KEY_TMDB_API_KEY);
var languageCode = GM_getValue(KEY_LANGUAGE_CODE);
if (!tmdbApiKey || !languageCode) {
return;
}
var url = window.location.href;
var className = POSTER_TITLE_CLASS_NAME;
var endPath = url.substring(url.lastIndexOf("/"));
var saveOverview = false;
if (endPath != "/") {
if (CALENDAR_TITLE_PATH.indexOf(endPath) != -1) {
className = CALENDAR_TITLE_CLASS_NAME;
} else if (SERIES_TITLE_PATH.indexOf(endPath) != -1) {
className = SERIES_TITLE_CLASS_NAME;
} else if (url.indexOf(DETAILS_TITLE_PATH) != -1) {
className = DETAILS_TITLE_CLASS_NAME;
saveOverview = true;
}
}
var titleDivs = document.getElementsByClassName(className);
var errorMessageTextarea = document.getElementById(ERROR_MESSAGE_TEXTAREA_ID);
if (className == POSTER_TITLE_CLASS_NAME && (!titleDivs || titleDivs.length == 0)) {
titleDivs = document.getElementsByClassName(SERIES_TITLE_CLASS_NAME);
if (!titleDivs || titleDivs.length == 0) {
titleDivs = document.getElementsByClassName(OVERVIEW_TITLE_CLASS_NAME);
}
}
if (className == CALENDAR_TITLE_CLASS_NAME && (!titleDivs || titleDivs.length == 0)) {
titleDivs = document.getElementsByClassName(CALENDAR_TITLE_AGENDA_CLASS_NAME);
}
for (let titleDiv of titleDivs) {
let title = titleDiv.innerHTML;
if (!title || title.indexOf("<") != -1) {
continue;
}
var translatedTitle = GM_getValue(KEY_TITLE_PREFIX + title);
if (translatedTitle && title != translatedTitle) {
titleDiv.innerHTML = translatedTitle;
if (saveOverview) {
// 详情页重新加载最新数据
translate(title, saveOverview);
}
}
};
}
initSeriesData();
addi18nButtonAndSettingDiv();
setInterval(replaceTitle, 500);