diff --git a/src/apps/stable/routes/legacyRoutes/user.ts b/src/apps/stable/routes/legacyRoutes/user.ts
index 19b87c7cd85..95a3ee07b04 100644
--- a/src/apps/stable/routes/legacyRoutes/user.ts
+++ b/src/apps/stable/routes/legacyRoutes/user.ts
@@ -67,6 +67,12 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
controller: 'user/subtitles/index',
view: 'user/subtitles/index.html'
}
+ }, {
+ path: 'mypreferencesopensubtitles.html',
+ pageProps: {
+ controller: 'user/opensubtitles/index',
+ view: 'user/opensubtitles/index.html'
+ }
}, {
path: 'tv.html',
pageProps: {
diff --git a/src/components/opensubtitlesSettings/opensubtitlesSettings.html b/src/components/opensubtitlesSettings/opensubtitlesSettings.html
new file mode 100644
index 00000000000..5fe91831f56
--- /dev/null
+++ b/src/components/opensubtitlesSettings/opensubtitlesSettings.html
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js
index c5f5182cc89..a2f33895d78 100644
--- a/src/components/playback/playbackmanager.js
+++ b/src/components/playback/playbackmanager.js
@@ -29,6 +29,7 @@ import { toApi } from 'utils/jellyfin-apiclient/compat';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind.js';
import browser from 'scripts/browser.js';
import { bindSkipSegment } from './skipsegment.ts';
+import OpenSubtitlesManager from '../../scripts/opensubtitles/opensubtitles';
const UNLIMITED_ITEMS = -1;
@@ -1252,6 +1253,7 @@ export class PlaybackManager {
mediaStreams.push(currentMediaSource.MediaStreams[i]);
}
}
+ OpenSubtitlesManager.appendSubtitleTracks( mediaStreams, currentMediaSource );
// No known streams, nothing to change
if (!mediaStreams.length) {
@@ -2591,7 +2593,7 @@ export class PlaybackManager {
const getMediaStreams = isLiveTv ? Promise.resolve([]) : apiClient.getItem(apiClient.getCurrentUserId(), mediaSourceId)
.then(fullItem => {
- return fullItem.MediaStreams;
+ return OpenSubtitlesManager.appendSubtitleTracks( fullItem.MediaStreams, fullItem );
});
return Promise.all([promise, player.getDeviceProfile(item), apiClient.getCurrentUser(), getMediaStreams]).then(function (responses) {
@@ -2643,8 +2645,16 @@ export class PlaybackManager {
mediaSource.DefaultSecondarySubtitleStreamIndex = -1;
}
- const subtitleTrack1 = mediaSource.MediaStreams[mediaSource.DefaultSubtitleStreamIndex];
- const subtitleTrack2 = mediaSource.MediaStreams[mediaSource.DefaultSecondarySubtitleStreamIndex];
+ // Append web subtitles
+ if ( mediaSource.DefaultSubtitleStreamIndex >= mediaSource.MediaStreams.length ) {
+ OpenSubtitlesManager.appendSubtitleTracks( mediaSource.MediaStreams, mediaSource );
+ }
+ const subtitleTrack1 = mediaSource.MediaStreams.filter(function (t) {
+ return t.Index === mediaSource.DefaultSubtitleStreamIndex;
+ })[0];
+ const subtitleTrack2 = mediaSource.MediaStreams.filter(function (t) {
+ return t.Index === mediaSource.DefaultSecondarySubtitleStreamIndex;
+ })[0];
if (!self.trackHasSecondarySubtitleSupport(subtitleTrack1, player)
|| !self.trackHasSecondarySubtitleSupport(subtitleTrack2, player)) {
@@ -2874,6 +2884,7 @@ export class PlaybackManager {
format: textStream.Codec
});
}
+ OpenSubtitlesManager.appendSubtitleTracks( tracks, mediaSource );
return tracks;
}
@@ -3866,9 +3877,13 @@ export class PlaybackManager {
return Promise.reject();
}
- getSubtitleUrl(textStream, serverId) {
+ async getSubtitleUrl(textStream, serverId) {
const apiClient = ServerConnections.getApiClient(serverId);
-
+ if ( textStream?.OpenSubstitlesFileId ) {
+ // This is an opensubtitles item
+ await OpenSubtitlesManager.getDownloadLink( textStream.OpenSubstitlesFileId );
+ return OpenSubtitlesManager.downloadData.link;
+ }
return !textStream.IsExternalUrl ? apiClient.getUrl(textStream.DeliveryUrl) : textStream.DeliveryUrl;
}
@@ -3997,8 +4012,8 @@ export class PlaybackManager {
}
const mediaSource = this.currentMediaSource(player);
-
const mediaStreams = mediaSource?.MediaStreams || [];
+ OpenSubtitlesManager.appendSubtitleTracks( mediaStreams, mediaSource );
return mediaStreams.filter(function (s) {
return s.Type === 'Subtitle';
}).sort(itemHelper.sortTracks);
diff --git a/src/controllers/itemDetails/index.js b/src/controllers/itemDetails/index.js
index bdb5a9fe08f..0f5000537df 100644
--- a/src/controllers/itemDetails/index.js
+++ b/src/controllers/itemDetails/index.js
@@ -33,6 +33,7 @@ import { getPortraitShape, getSquareShape } from 'utils/card';
import Dashboard from 'utils/dashboard';
import Events from 'utils/events';
import { getItemBackdropImageUrl } from 'utils/jellyfin-apiclient/backdropImage';
+import OpenSubtitlesManager from '../../scripts/opensubtitles/opensubtitles';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-checkbox/emby-checkbox';
@@ -279,13 +280,15 @@ function renderAudioSelections(page, mediaSources) {
}
}
-function renderSubtitleSelections(page, mediaSources) {
+async function renderSubtitleSelections(page, mediaSources) {
const mediaSource = getSelectedMediaSource(page, mediaSources);
-
+ // Append web subtitles to the mediaSource
+ await OpenSubtitlesManager.appendSubtitleTracks(mediaSource.MediaStreams, mediaSource);
const tracks = mediaSource.MediaStreams.filter(function (m) {
return m.Type === 'Subtitle';
});
tracks.sort(itemHelper.sortTracks);
+
const select = page.querySelector('.selectSubtitles');
select.setLabel(globalize.translate('Subtitles'));
const selectedId = mediaSource.DefaultSubtitleStreamIndex == null ? -1 : mediaSource.DefaultSubtitleStreamIndex;
@@ -293,7 +296,12 @@ function renderSubtitleSelections(page, mediaSources) {
let selected = selectedId === -1 ? ' selected' : '';
select.innerHTML = '' + tracks.map(function (v) {
selected = v.Index === selectedId ? ' selected' : '';
- return '';
+ let out = '';
+ return out;
}).join('');
if (tracks.length > 0) {
@@ -1067,6 +1075,7 @@ function renderTagline(page, item) {
}
function renderDetails(page, item, apiClient, context) {
+ OpenSubtitlesManager.mediaItem = item; // This object may have `ProviderIds`
renderSimilarItems(page, item, context);
renderMoreFromSeason(page, item, apiClient);
renderMoreFromArtist(page, item, apiClient);
@@ -1915,6 +1924,7 @@ export default function (view, params) {
Promise.all([getPromise(apiClient, pageParams), apiClient.getCurrentUser()]).then(([item, user]) => {
currentItem = item;
+ OpenSubtitlesManager.mediaItem = item; // This object may have `ProviderIds`
reloadFromItem(instance, page, pageParams, item, user);
}).catch((error) => {
console.error('failed to get item or current user: ', error);
@@ -1970,7 +1980,7 @@ export default function (view, params) {
playItem(item, item.UserData && mode === 'resume' ? item.UserData.PlaybackPositionTicks : 0);
}
- function onPlayClick() {
+ async function onPlayClick() {
let actionElem = this;
let action = actionElem.getAttribute('data-action');
@@ -1978,6 +1988,21 @@ export default function (view, params) {
actionElem = actionElem.querySelector('[data-action]') || actionElem;
action = actionElem.getAttribute('data-action');
}
+ //-----------------
+ // Make sure the opensubtitle url is ready for the player
+ try {
+ if ( currentItem?.MediaType === 'Video' ) {
+ const select = view.querySelector('.selectSubtitles');
+ const selectedSubtitleOption = select.options[ select.selectedIndex ];
+ if ( Object.hasOwn(selectedSubtitleOption.dataset, 'OpenSubstitlesFileId') ) {
+ OpenSubtitlesManager.appendSubtitleTracks( currentItem.MediaStreams, currentItem );
+ await OpenSubtitlesManager.getDownloadLink( selectedSubtitleOption.dataset.OpenSubstitlesFileId );
+ }
+ }
+ } catch (err) {
+ console.error( err );
+ }
+ //-----------------
playCurrentItem(actionElem, action);
}
@@ -2096,8 +2121,8 @@ export default function (view, params) {
view.querySelector('.selectSource').addEventListener('change', function () {
renderVideoSelections(view, self._currentPlaybackMediaSources);
renderAudioSelections(view, self._currentPlaybackMediaSources);
- renderSubtitleSelections(view, self._currentPlaybackMediaSources);
updateMiscInfo();
+ renderSubtitleSelections(view, self._currentPlaybackMediaSources);
});
view.addEventListener('viewshow', function (e) {
const page = this;
diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js
index e56f8a80487..9a340d3d2c6 100644
--- a/src/controllers/playback/video/index.js
+++ b/src/controllers/playback/video/index.js
@@ -1094,6 +1094,8 @@ export default function (view) {
const secondaryStreams = playbackManager.secondarySubtitleTracks(player);
let currentIndex = playbackManager.getSubtitleStreamIndex(player);
+ playbackManager.pause(player);
+
if (currentIndex == null) {
currentIndex = -1;
}
diff --git a/src/controllers/user/menu/index.html b/src/controllers/user/menu/index.html
index a6b0a2d045d..83f07439cdf 100644
--- a/src/controllers/user/menu/index.html
+++ b/src/controllers/user/menu/index.html
@@ -122,6 +122,17 @@ ${HeaderUser}
+
diff --git a/src/controllers/user/menu/index.js b/src/controllers/user/menu/index.js
index 035d07492b8..2e0996f5c55 100644
--- a/src/controllers/user/menu/index.js
+++ b/src/controllers/user/menu/index.js
@@ -31,6 +31,7 @@ export default function (view, params) {
page.querySelector('.lnkHomePreferences').setAttribute('href', '#/mypreferenceshome.html?userId=' + userId);
page.querySelector('.lnkPlaybackPreferences').setAttribute('href', '#/mypreferencesplayback.html?userId=' + userId);
page.querySelector('.lnkSubtitlePreferences').setAttribute('href', '#/mypreferencessubtitles.html?userId=' + userId);
+ page.querySelector('.lnkOpenSubtitlePreferences').setAttribute('href', '#/mypreferencesopensubtitles.html?userId=' + userId);
page.querySelector('.lnkQuickConnectPreferences').setAttribute('href', '#/quickconnect?userId=' + userId);
page.querySelector('.lnkControlsPreferences').setAttribute('href', '#/mypreferencescontrols.html?userId=' + userId);
diff --git a/src/controllers/user/opensubtitles/index.html b/src/controllers/user/opensubtitles/index.html
new file mode 100644
index 00000000000..2e30ca70629
--- /dev/null
+++ b/src/controllers/user/opensubtitles/index.html
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/src/controllers/user/opensubtitles/index.js b/src/controllers/user/opensubtitles/index.js
new file mode 100644
index 00000000000..c9184e6fc00
--- /dev/null
+++ b/src/controllers/user/opensubtitles/index.js
@@ -0,0 +1,180 @@
+import OpenSubtitlesManager from '../../../scripts/opensubtitles/opensubtitles';
+import autoFocuser from '../../../components/autoFocuser';
+//-----------------------------------------------------------
+import globalize from '../../../lib/globalize';
+import loading from '../../../components/loading/loading';
+import template from '../../../components/opensubtitlesSettings/opensubtitlesSettings.html';
+//-----------------------------------------------------------
+
+export default function (view) {
+ function showStatusMessage( element, code, reqStatus = null ) {
+ const status = element.querySelector('.loginStatus');
+
+ let statusMsg = '';
+ switch ( code ) {
+ case 0:
+ statusMsg = globalize.translate('OpenSubtitlesStatusLoggedOut');
+ break;
+ case 1:
+ statusMsg = globalize.translate('OpenSubtitlesStatusLoggedIn', OpenSubtitlesManager.allowedDownloads());
+ break;
+ case 2:
+ statusMsg = globalize.translate('OpenSubtitlesStatusFailedToLogIn', reqStatus || 0);
+ break;
+ default: break;
+ }
+ status.innerHTML = '' + statusMsg + '';
+ status.classList.remove('hide');
+ }
+
+ async function onSubmit( event ) {
+ event.preventDefault();
+ loading.show();
+
+ const element = event.srcElement.ownerDocument;
+ const enabled = element.querySelector('#chkEnableOpenSubtitles').checked;
+ const selectLanguages = element.querySelectorAll('.selectPreferredSubtitleLanguage');
+ let languages = '';
+ for (const select of selectLanguages) {
+ if ( select.value != -1 ) {
+ if ( languages ) {
+ languages += ',';
+ }
+ languages += select.value;
+ }
+ }
+ const user = element.querySelector('#txtOpenSubtitlesUser').value;
+ const pwd = element.querySelector('#txtOpenSubtitlesPassword').value;
+ const tokenElement = element.querySelector('#txtOpenSubtitlesApiToken');
+
+ await OpenSubtitlesManager.setSettings(enabled, user, pwd, languages, tokenElement.value);
+ if ( !enabled ) {
+ showStatusMessage( element, 0 );
+ } else {
+ const resStatus = OpenSubtitlesManager.api.last_response?.status || 200;
+ if ( resStatus == 200 ) {
+ showStatusMessage( element, 1 );
+ } else {
+ showStatusMessage( element, 2, resStatus );
+ // Let user pass his API token directly (found at opensubtitles.com/users/profile)
+ tokenElement.parentElement.classList.remove('hide');
+ }
+ }
+ loading.hide();
+ }
+
+ function selectLanguageOnChange( event ) {
+ const src = event.srcElement;
+ const element = src.ownerDocument;
+ const selectLanguages = element.querySelectorAll('.selectPreferredSubtitleLanguage');
+
+ // Hide and disable 3rd when turning off the 2nd
+ const curIndex = Array.from(selectLanguages).findIndex(s => s.id == src.id);
+ if ( src.value == -1 ) {
+ for (let i = curIndex + 1; i < selectLanguages.length; i++) {
+ const select = selectLanguages[i];
+ select.value = -1;
+ select.disabled = true;
+ select.parentElement.classList.add('hide');
+ }
+ } else if ( (curIndex + 1) < selectLanguages.length ) {
+ const select = selectLanguages[curIndex + 1];
+ select.disabled = false;
+ select.parentElement.classList.remove('hide');
+ }
+
+ // Get languages
+ let curSelectedLanguages = '';
+ for (const select of selectLanguages) {
+ if ( select.value != -1 ) {
+ if ( curSelectedLanguages ) {
+ curSelectedLanguages += ',';
+ }
+ curSelectedLanguages += select.value;
+ }
+ }
+ curSelectedLanguages = curSelectedLanguages.split(',');
+
+ // Disable already selected option from other select elements
+ for (const select of selectLanguages) {
+ for (const option of select.options) {
+ option.disabled = curSelectedLanguages.includes( option.value ) && (option.value !== select.value);
+ }
+ }
+ }
+
+ function chkEnableOnChange( event ) {
+ const src = event.srcElement;
+ const container = src.ownerDocument.querySelector('#OpenSubtitlesContainerOnlyIfAvailable');
+ if ( src.checked ) {
+ container.classList.remove('hide');
+ } else {
+ container.classList.add('hide');
+ }
+ }
+
+ view.addEventListener('viewshow', function () {
+ const element = view.querySelector('.settingsContainer');
+ if ( !element ) {
+ console.error('settingsContainer not found!');
+ return;
+ }
+ element.classList.add('opensubtitlesettings');
+ element.innerHTML = globalize.translateHtml(template, 'core');
+
+ // Fill user credentials
+ const credentials = OpenSubtitlesManager.credentials();
+ if ( credentials?.username ) {
+ element.querySelector('#txtOpenSubtitlesUser').value = credentials.username;
+ element.querySelector('#txtOpenSubtitlesPassword').value = credentials.password;
+ element.querySelector('#txtOpenSubtitlesApiToken').value = credentials.token;
+ }
+
+ // Fill user language preferences
+ const curSelectedLanguages = ( OpenSubtitlesManager.settings?.languages || 'en' ).split(',');
+ const selectLanguages = element.querySelectorAll('.selectPreferredSubtitleLanguage');
+ for (let i = 0; i < selectLanguages.length; i++) {
+ const select = selectLanguages[i];
+ const language = (curSelectedLanguages.length > i) ? (curSelectedLanguages[i]) : '';
+ // Must have at least one language preference
+ if ( i != 0) {
+ select.innerHTML = '';
+ } else {
+ select.innerHTML = '';
+ }
+ select.innerHTML += OpenSubtitlesManager.utils.Languages.map(function (v) {
+ const selected = (language === v.language_code) ? ' selected' : '';
+ let out = '';
+ return out;
+ }).join('');
+ if ( !language && i && !curSelectedLanguages[i - 1] ) {
+ select.disabled = true;
+ select.parentElement.classList.add('hide');
+ }
+ select.addEventListener('change', selectLanguageOnChange);
+ }
+
+ // Load enable information
+ const chkEnable = element.querySelector('#chkEnableOpenSubtitles');
+ chkEnable.checked = credentials?.username;
+ if ( chkEnable.checked ) {
+ element.querySelector('#OpenSubtitlesContainerOnlyIfAvailable').classList.remove('hide');
+ }
+ chkEnable.addEventListener('change', chkEnableOnChange);
+ element.querySelector('form').addEventListener('submit', onSubmit);
+ autoFocuser.autoFocus(view);
+
+ // Show current status
+ OpenSubtitlesManager.refreshUserInfo().then( ()=>{
+ showStatusMessage( element, OpenSubtitlesManager.isLoggedIn ? 1 : 0 );
+ });
+ });
+
+ view.addEventListener('viewdestroy', function () {
+ //
+ });
+}
diff --git a/src/index.jsx b/src/index.jsx
index 1db2bcebef4..cabf4bee692 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -39,6 +39,7 @@ import './scripts/autoThemes';
import './scripts/mouseManager';
import './scripts/screensavermanager';
import './scripts/serverNotifications';
+import OpenSubtitlesManager from './scripts/opensubtitles/opensubtitles';
// Import site styles
import './styles/site.scss';
@@ -113,6 +114,9 @@ build: ${__JF_BUILD_VERSION__}`);
// Connect to server
ServerConnections.firstConnection = await ServerConnections.connect();
+ // Start OpenSubtitlesManager
+ OpenSubtitlesManager.start();
+
// Render the app
await renderApp();
diff --git a/src/plugins/htmlVideoPlayer/plugin.js b/src/plugins/htmlVideoPlayer/plugin.js
index 4bc0d60a377..15170187a20 100644
--- a/src/plugins/htmlVideoPlayer/plugin.js
+++ b/src/plugins/htmlVideoPlayer/plugin.js
@@ -39,6 +39,7 @@ import { includesAny } from '../../utils/container.ts';
import { isHls } from '../../utils/mediaSource.ts';
import debounce from 'lodash-es/debounce';
import { MediaError } from 'types/mediaError';
+import OpenSubtitlesManager from 'scripts/opensubtitles/opensubtitles';
/**
* Returns resolved URL.
@@ -151,12 +152,12 @@ function normalizeTrackEventText(text, useHtml) {
return useHtml ? result.replace(/\n/gi, '
') : result;
}
-function getTextTrackUrl(track, item, format) {
+async function getTextTrackUrl(track, item, format) {
if (itemHelper.isLocalItem(item) && track.Path) {
return track.Path;
}
- let url = playbackManager.getSubtitleUrl(track, item.ServerId);
+ let url = await playbackManager.getSubtitleUrl(track, item.ServerId);
if (format) {
url = url.replace('.vtt', format);
}
@@ -484,9 +485,13 @@ export class HtmlVideoPlayer {
let secondaryTrackValid = true;
- this.#subtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSubtitleStreamIndex;
+ const subtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSubtitleStreamIndex;
+ this.#subtitleTrackIndexToSetOnPlaying = subtitleTrackIndexToSetOnPlaying;
if (this.#subtitleTrackIndexToSetOnPlaying != null && this.#subtitleTrackIndexToSetOnPlaying >= 0) {
- const initialSubtitleStream = options.mediaSource.MediaStreams[this.#subtitleTrackIndexToSetOnPlaying];
+ // web subtitles uses out of range indexes, so we need to filter by property `Index`
+ const initialSubtitleStream = options.mediaSource.MediaStreams.filter(function (t) {
+ return t.Index === subtitleTrackIndexToSetOnPlaying;
+ })[0];
if (!initialSubtitleStream || initialSubtitleStream.DeliveryMethod === 'Encode') {
this.#subtitleTrackIndexToSetOnPlaying = -1;
secondaryTrackValid = false;
@@ -1209,12 +1214,23 @@ export class HtmlVideoPlayer {
this.incrementFetchQueue();
try {
- const response = await fetch(getTextTrackUrl(track, item, '.js'));
+ const textTrackUrl = await getTextTrackUrl(track, item, '.js');
+ console.debug('[fetchSubtitles] textTrackUrl', textTrackUrl);
+ if ( textTrackUrl.includes('opensubtitles.com') ) {
+ const response = await fetch(textTrackUrl);
+ if (!response.ok) {
+ throw new Error(response);
+ }
+ console.debug('[opensubtitles] fetch(textTrackUrl)', response );
+ const srtText = await response.text();
+ // Special handling of the SRT file from opensubtitles
+ return OpenSubtitlesManager.utils.srtToJson( srtText );
+ }
+ const response = await fetch(textTrackUrl);
if (!response.ok) {
throw new Error(response);
}
-
return response.json();
} finally {
this.decrementFetchQueue();
diff --git a/src/scripts/opensubtitles/api.js b/src/scripts/opensubtitles/api.js
new file mode 100644
index 00000000000..93fe898b079
--- /dev/null
+++ b/src/scripts/opensubtitles/api.js
@@ -0,0 +1,125 @@
+/**
+ * This is a modified version from https://github.com/vankasteelj/opensubtitles.com
+ */
+const methods = require('./methods.json');
+
+class OpenSubtitlesApiClass {
+ /**
+ * Class constructor
+ * @param {*} settings API Key and optional settings
+ */
+ constructor(settings = {}) {
+ if (!settings.apikey) throw Error('requires an apikey');
+
+ this._authentication = {};
+ const userAgent = __PACKAGE_JSON_NAME__ + ' v' + __PACKAGE_JSON_VERSION__ ;
+ this._settings = {
+ apikey: settings.apikey,
+ endpoint: settings.endpoint || 'https://api.opensubtitles.com/api/v1',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': '*/*',
+ 'User-Agent': userAgent,
+ 'X-User-Agent': userAgent // https://forum.opensubtitles.org/viewtopic.php?t=18251
+ }
+ };
+ this.last_response = null;
+ this._construct();
+ }
+
+ /**
+ * Creates methods for all requests
+ */
+ _construct() {
+ for (const url in methods) {
+ const urlParts = url.split('/');
+ const name = urlParts.pop(); // key for function
+
+ let tmp = this;
+ for (let p = 1; p < urlParts.length; ++p) { // acts like mkdir -p
+ tmp = tmp[urlParts[p]] || (tmp[urlParts[p]] = {});
+ }
+
+ tmp[name] = (() => {
+ const method = methods[url]; // closure forces copy
+ return (params) => {
+ return this._call(method, params);
+ };
+ })();
+ }
+ }
+
+ /**
+ * Parse url before api call
+ * @param {*} method REST API Method
+ * @param {*} params Request parameters
+ * @returns url
+ */
+ _parse(method, params = {}) {
+ let url = this._settings.endpoint + method.url.split('?')[0];
+
+ // ?Part
+ const queryParts = [];
+ const queryPart = method.url.split('?')[1];
+ if (queryPart) {
+ const queryParams = queryPart.split('&');
+ for (const i in queryParams) {
+ const name = queryParams[i].split('=')[0]; // that ; is needed
+ (params[name] || params[name] === 0) && queryParts.push(`${name}=${encodeURIComponent(params[name])}`);
+ }
+ }
+
+ if (queryParts.length) url += '?' + queryParts.join('&');
+
+ return url;
+ }
+
+ /**
+ * Parse methods then hit API
+ * @param {*} method REST API Method
+ * @param {*} params Request parameters
+ * @returns Response JSON
+ */
+ async _call(method, params = {}) {
+ const url = this._parse(method, params);
+ const req = {
+ method: method.method,
+ headers: Object.assign({}, this._settings.headers)
+ };
+
+ // HEADERS Authorization
+ if ( method.opts?.auth ) {
+ if (!this._authentication.token && !params.token) throw Error('requires a bearer token, login first');
+ req.headers['Authorization'] = 'Bearer ' + (this._authentication.token || params.token);
+ }
+
+ // HEADERS Api-Key
+ req.headers['Api-Key'] = this._settings.apikey;
+
+ // JSON body
+ if (req.method !== 'GET') {
+ req.body = (method.body ? Object.assign({}, method.body) : {});
+ for (const k in params) {
+ if (k in req.body) req.body[k] = params[k];
+ }
+ for (const k in req.body) {
+ if (!req.body[k]) delete req.body[k];
+ }
+ req.body = JSON.stringify(req.body);
+ }
+
+ // Actual call
+ try {
+ this.last_response = await fetch(url, req);
+ if (this.last_response.status == 200) {
+ return ( this.last_response ).json();
+ }
+ console.error('[opensubtitles] last_response', this.last_response.status, this.last_response);
+ } catch (err) {
+ console.error('[opensubtitles]', err, url, req);
+ }
+ return {};
+ }
+}
+
+export default OpenSubtitlesApiClass;
diff --git a/src/scripts/opensubtitles/languages.json b/src/scripts/opensubtitles/languages.json
new file mode 100644
index 00000000000..1aa2c29dcdb
--- /dev/null
+++ b/src/scripts/opensubtitles/languages.json
@@ -0,0 +1,300 @@
+{
+ "data": [
+ {
+ "language_code": "af",
+ "language_name": "Afrikaans"
+ },
+ {
+ "language_code": "sq",
+ "language_name": "Albanian"
+ },
+ {
+ "language_code": "ar",
+ "language_name": "Arabic"
+ },
+ {
+ "language_code": "an",
+ "language_name": "Aragonese"
+ },
+ {
+ "language_code": "hy",
+ "language_name": "Armenian"
+ },
+ {
+ "language_code": "at",
+ "language_name": "Asturian"
+ },
+ {
+ "language_code": "eu",
+ "language_name": "Basque"
+ },
+ {
+ "language_code": "be",
+ "language_name": "Belarusian"
+ },
+ {
+ "language_code": "bn",
+ "language_name": "Bengali"
+ },
+ {
+ "language_code": "bs",
+ "language_name": "Bosnian"
+ },
+ {
+ "language_code": "br",
+ "language_name": "Breton"
+ },
+ {
+ "language_code": "bg",
+ "language_name": "Bulgarian"
+ },
+ {
+ "language_code": "my",
+ "language_name": "Burmese"
+ },
+ {
+ "language_code": "ca",
+ "language_name": "Catalan"
+ },
+ {
+ "language_code": "zh-cn",
+ "language_name": "Chinese (simplified)"
+ },
+ {
+ "language_code": "cs",
+ "language_name": "Czech"
+ },
+ {
+ "language_code": "da",
+ "language_name": "Danish"
+ },
+ {
+ "language_code": "nl",
+ "language_name": "Dutch"
+ },
+ {
+ "language_code": "en",
+ "language_name": "English"
+ },
+ {
+ "language_code": "eo",
+ "language_name": "Esperanto"
+ },
+ {
+ "language_code": "et",
+ "language_name": "Estonian"
+ },
+ {
+ "language_code": "fi",
+ "language_name": "Finnish"
+ },
+ {
+ "language_code": "fr",
+ "language_name": "French"
+ },
+ {
+ "language_code": "ka",
+ "language_name": "Georgian"
+ },
+ {
+ "language_code": "de",
+ "language_name": "German"
+ },
+ {
+ "language_code": "gl",
+ "language_name": "Galician"
+ },
+ {
+ "language_code": "el",
+ "language_name": "Greek"
+ },
+ {
+ "language_code": "he",
+ "language_name": "Hebrew"
+ },
+ {
+ "language_code": "hi",
+ "language_name": "Hindi"
+ },
+ {
+ "language_code": "hr",
+ "language_name": "Croatian"
+ },
+ {
+ "language_code": "hu",
+ "language_name": "Hungarian"
+ },
+ {
+ "language_code": "is",
+ "language_name": "Icelandic"
+ },
+ {
+ "language_code": "id",
+ "language_name": "Indonesian"
+ },
+ {
+ "language_code": "it",
+ "language_name": "Italian"
+ },
+ {
+ "language_code": "ja",
+ "language_name": "Japanese"
+ },
+ {
+ "language_code": "kk",
+ "language_name": "Kazakh"
+ },
+ {
+ "language_code": "km",
+ "language_name": "Khmer"
+ },
+ {
+ "language_code": "ko",
+ "language_name": "Korean"
+ },
+ {
+ "language_code": "lv",
+ "language_name": "Latvian"
+ },
+ {
+ "language_code": "lt",
+ "language_name": "Lithuanian"
+ },
+ {
+ "language_code": "lb",
+ "language_name": "Luxembourgish"
+ },
+ {
+ "language_code": "mk",
+ "language_name": "Macedonian"
+ },
+ {
+ "language_code": "ml",
+ "language_name": "Malayalam"
+ },
+ {
+ "language_code": "ms",
+ "language_name": "Malay"
+ },
+ {
+ "language_code": "ma",
+ "language_name": "Manipuri"
+ },
+ {
+ "language_code": "mn",
+ "language_name": "Mongolian"
+ },
+ {
+ "language_code": "no",
+ "language_name": "Norwegian"
+ },
+ {
+ "language_code": "oc",
+ "language_name": "Occitan"
+ },
+ {
+ "language_code": "fa",
+ "language_name": "Persian"
+ },
+ {
+ "language_code": "pl",
+ "language_name": "Polish"
+ },
+ {
+ "language_code": "pt-pt",
+ "language_name": "Portuguese (Portugal)"
+ },
+ {
+ "language_code": "ru",
+ "language_name": "Russian"
+ },
+ {
+ "language_code": "sr",
+ "language_name": "Serbian"
+ },
+ {
+ "language_code": "si",
+ "language_name": "Sinhalese"
+ },
+ {
+ "language_code": "sk",
+ "language_name": "Slovak"
+ },
+ {
+ "language_code": "sl",
+ "language_name": "Slovenian"
+ },
+ {
+ "language_code": "es",
+ "language_name": "Spanish"
+ },
+ {
+ "language_code": "sw",
+ "language_name": "Swahili"
+ },
+ {
+ "language_code": "sv",
+ "language_name": "Swedish"
+ },
+ {
+ "language_code": "sy",
+ "language_name": "Syriac"
+ },
+ {
+ "language_code": "ta",
+ "language_name": "Tamil"
+ },
+ {
+ "language_code": "te",
+ "language_name": "Telugu"
+ },
+ {
+ "language_code": "tl",
+ "language_name": "Tagalog"
+ },
+ {
+ "language_code": "th",
+ "language_name": "Thai"
+ },
+ {
+ "language_code": "tr",
+ "language_name": "Turkish"
+ },
+ {
+ "language_code": "uk",
+ "language_name": "Ukrainian"
+ },
+ {
+ "language_code": "ur",
+ "language_name": "Urdu"
+ },
+ {
+ "language_code": "uz",
+ "language_name": "Uzbek"
+ },
+ {
+ "language_code": "vi",
+ "language_name": "Vietnamese"
+ },
+ {
+ "language_code": "ro",
+ "language_name": "Romanian"
+ },
+ {
+ "language_code": "pt-br",
+ "language_name": "Portuguese (Brazilian)"
+ },
+ {
+ "language_code": "me",
+ "language_name": "Montenegrin"
+ },
+ {
+ "language_code": "zh-tw",
+ "language_name": "Chinese (traditional)"
+ },
+ {
+ "language_code": "ze",
+ "language_name": "Chinese bilingual"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/scripts/opensubtitles/methods.json b/src/scripts/opensubtitles/methods.json
new file mode 100644
index 00000000000..9ac5504f62d
--- /dev/null
+++ b/src/scripts/opensubtitles/methods.json
@@ -0,0 +1,75 @@
+{
+ "/infos/formats": {
+ "url": "/infos/formats",
+ "method": "GET"
+ },
+ "/infos/user": {
+ "opts": {
+ "auth": true
+ },
+ "url": "/infos/user",
+ "method": "GET"
+ },
+ "infos/languages": {
+ "url": "/infos/languages",
+ "method": "GET"
+ },
+ "/user/login": {
+ "url": "/login",
+ "method": "POST",
+ "body": {
+ "username": null,
+ "password": null
+ }
+ },
+ "/user/logout": {
+ "url": "/logout",
+ "method": "DELETE"
+ },
+ "/discover/popular": {
+ "url": "/discover/popular?languages=&type=",
+ "optional": ["languages", "type"],
+ "method": "GET"
+ },
+ "/discover/latest": {
+ "url": "/discover/latest?languages=&type=",
+ "optional": ["languages", "type"],
+ "method": "GET"
+ },
+ "/discover/most_downloaded": {
+ "url": "/discover/most_downloaded?languages=&type=",
+ "optional": ["languages", "type"],
+ "method": "GET"
+ },
+ "/download": {
+ "opts": {
+ "auth": true
+ },
+ "url": "/download",
+ "method": "POST",
+ "body": {
+ "file_id": null,
+ "sub_format": "srt",
+ "file_name": null,
+ "in_fps": null,
+ "out_fps": null,
+ "timeshift": null,
+ "force_download": null
+ }
+ },
+ "/subtitles": {
+ "url": "/subtitles?ai_translated=&episode_number=&foreign_parts_only=&hearing_impaired=&id=&imdb_id=&languages=&machine_translated=&moviehash=&moviehash_match=&order_by=&order_direction=&page=&parent_feature_id=&parent_imdb_id=&parent_tmdb_id=&query=&season_number=&tmdb_id=&trusted_sources=&type=&user_id=&year=",
+ "optional": ["ai_translated", "episode_number", "foreign_parts_only", "hearing_impaired", "id", "imdb_id", "languages", "machine_translated", "moviehash", "moviehash_match", "order_by", "order_direction", "page", "parent_feature_id", "parent_imdb_id", "parent_tmdb_id", "query", "season_number", "tmdb_id", "trusted_sources", "type", "user_id", "year"],
+ "method": "GET"
+ },
+ "/features": {
+ "url": "/features?query=&feature_id=&imdb_id=&tmdb_id=&type=&year=",
+ "optional": ["feature_id", "imdb_id", "tmdb_id", "type", "year"],
+ "method": "GET"
+ },
+ "/guessit": {
+ "url": "/guessit?filename=",
+ "optional": ["filename"],
+ "method": "GET"
+ }
+}
\ No newline at end of file
diff --git a/src/scripts/opensubtitles/opensubtitles.js b/src/scripts/opensubtitles/opensubtitles.js
new file mode 100644
index 00000000000..6c5d61f2365
--- /dev/null
+++ b/src/scripts/opensubtitles/opensubtitles.js
@@ -0,0 +1,434 @@
+// eslint-disable-next-line @eslint-community/eslint-comments/disable-enable-pair
+/* eslint-disable @typescript-eslint/naming-convention */
+import OpenSubtitlesApiClass from './api';
+import Utils from './utils';
+
+const _OpenSubtitlesClientSettingsKey = 'opensubtitles_clientSettings';
+
+class OpenSubtitlesManagerClass {
+ /**
+ * Class constructor
+ */
+ constructor() {
+ this.api = new OpenSubtitlesApiClass( { apikey: 'gUCLWGoAg2PmyseoTM0INFFVPcDCeDlT' } );
+ this.utils = Utils;
+ this.isLoggedIn = false;
+ this.downloadData = { link: '', fileId: null };
+ this.mediaItem = null;
+ this.searchResults = null;
+ this.JellyfinMediaStreamTemplate = {
+ Type: 'Subtitle',
+ Codec: 'srt',
+ DisplayTitle: 'OpenSubtitle',
+ Index: 10000, /* Web subtitle indexes will always be out of range for any MediaItem */
+ IsDefault: false,
+ IsExternal: true,
+ IsExternalUrl: true,
+ IsForced: false,
+ IsHearingImpaired: false,
+ IsTextSubtitleStream: true,
+ Language: '',
+ SupportsExternalStream: true,
+ DeliveryMethod: 'External',
+ Path: '',
+ DeliveryUrl: ''
+ };
+ this.settings = {
+ languages: 'en',
+ credentials: { username: '', password: '', token: '' }
+ };
+
+ // Settings are saved locally
+ try {
+ const clientSettings = localStorage.getItem( _OpenSubtitlesClientSettingsKey );
+ if ( clientSettings?.includes('credentials') ) {
+ this.settings = JSON.parse( clientSettings );
+ this.api._authentication.token = this.settings.credentials.token;
+ }
+ } catch ( err ) {
+ console.error('[opensubtitles] ', err);
+ }
+ console.debug('[opensubtitles] instance created', this);
+ }
+
+ /**
+ * Start the instance
+ */
+ async start() {
+ // Login if not already
+ const openSubtitlesCredentials = this.credentials();
+ if ( openSubtitlesCredentials.token ) {
+ const res = await this.refreshUserInfo();
+ if ( this.isLoggedIn ) {
+ console.debug( '[opensubtitles] already logged in ', res );
+ return res;
+ }
+ }
+ if ( openSubtitlesCredentials.username ) {
+ const resLogin = await this.login( openSubtitlesCredentials.username, openSubtitlesCredentials.password );
+ console.debug( '[opensubtitles] login ', resLogin );
+ } else {
+ console.debug( '[opensubtitles] no credentials found' );
+ }
+ }
+
+ /**
+ * Set settings
+ * @param {boolean} en Enable opensubtitles
+ * @param {string} user User
+ * @param {string} pwd Password
+ * @param {string} languages Languages (comma-separated)
+ * @param (string) token API Token
+ * @returns Response JSON
+ */
+ async setSettings(en, user, pwd, languages, token = null) {
+ if ( !en ) {
+ return this.logout();
+ }
+ this.settings.languages = languages;
+ if ( token ) {
+ // Check if provided token is still valid
+ this.settings.credentials = {
+ username: user,
+ password: pwd,
+ token: token
+ };
+ this.api._authentication.token = token;
+ const res = await this.refreshUserInfo();
+ if ( res.user ) {
+ localStorage.setItem( _OpenSubtitlesClientSettingsKey, JSON.stringify(this.settings) );
+ return res;
+ }
+ }
+ // get token using `/login`
+ return this.login(user, pwd);
+ }
+
+ /**
+ * Create a token to authenticate a user
+ * @param {string} username OpenSubtitles User
+ * @param {string} password OpenSubtitles Password
+ * @returns Response JSON
+ */
+ login(username, password) {
+ if (!username || !password) {
+ // opensubtitles.com api does not work anonymously
+ return null;
+ }
+
+ console.debug('[opensubtitles] trying to login at', Date.now());
+ const credentials = { username: username, password: password };
+ this.settings.credentials.username = credentials.username;
+ this.settings.credentials.password = credentials.password;
+
+ return this.api.user.login(credentials).then((response) => {
+ if ( response?.user && response?.token ) {
+ this.api._authentication.user = response.user;
+ this.api._authentication.token = response.token;
+ this.api._settings.endpoint = 'https://' + response.base_url + '/api/v1';
+ this.settings.credentials.token = response.token;
+ localStorage.setItem( _OpenSubtitlesClientSettingsKey, JSON.stringify(this.settings) );
+ this.isLoggedIn = true;
+ }
+ console.debug('[opensubtitles] login', this.api.last_response?.status, response);
+ return response;
+ }, (err) => {
+ console.error('[opensubtitles] login failed', err);
+ return null;
+ });
+ }
+
+ /**
+ * Gather informations about the user authenticated by a bearer token
+ * @returns Response JSON
+ */
+ refreshUserInfo() {
+ if ( !this.api?._authentication?.token ) {
+ console.debug('[opensubtitles] refreshUserInfo was called without a token');
+ return Promise.resolve();
+ }
+ return this.api.infos.user().then( (res) => {
+ if ( res?.data?.allowed_downloads != null ) {
+ this.api._authentication.user = res.data;
+ this.isLoggedIn = true;
+ return res;
+ }
+ }, ( err ) => {
+ console.error('[opensubtitles] ', err);
+ return null;
+ });
+ }
+
+ /**
+ * Current user credentials
+ * @returns JSON
+ */
+ credentials() {
+ if ( this.settings.credentials?.username ) {
+ return this.settings.credentials;
+ }
+ return { username: '', password: '', token: '' };
+ }
+
+ /**
+ * Check remaining allowed downloads for the user
+ * @returns Number of allowed downloads
+ */
+ allowedDownloads() {
+ if ( this.api._authentication?.user?.allowed_downloads ) {
+ return this.api._authentication.user.allowed_downloads;
+ }
+ return 0;
+ }
+
+ /**
+ * Request a download url for a subtitle
+ * @param {number} fileId from /subtitles search results
+ * @returns Download url
+ */
+ async getDownloadLink( fileId ) {
+ console.debug('[opensubtitles] getDownloadLink for fileId = ', fileId);
+ if ( fileId == OpenSubtitlesManager.downloadData?.fileId ) {
+ // Already have the download link for this file
+ return OpenSubtitlesManager.downloadData.link;
+ }
+
+ // Check if it is possible to adjust FPS
+ let inFps = null;
+ let outFps = null;
+ try {
+ for (const dataItem of this.searchResults.data) {
+ if ( fileId == dataItem.attributes.files[0].fileId ) {
+ inFps = dataItem.attributes.fps;
+ const videoSrc = this.searchResults.item.MediaStreams.filter(function (s) {
+ return s.Type === 'Video';
+ });
+ if ( videoSrc.length == 1 ) {
+ outFps = videoSrc[0].AverageFrameRate;
+ }
+ break;
+ }
+ }
+ } catch ( err ) {
+ console.error('[opensubtitles] unable to detect inFps outFps', err);
+ }
+
+ // Request from API
+ const options = { file_id: fileId };
+ if ( inFps && outFps ) {
+ console.debug('[opensubtitles] inFps = ', inFps, 'outFps = ', outFps);
+ options.in_fps = inFps;
+ options.out_fps = outFps;
+ }
+ await this.api.download( options ).then( response => {
+ this.downloadData.link = response.link;
+ this.downloadData.fileId = fileId;
+ }).catch(console.error);
+
+ return OpenSubtitlesManager.downloadData.link;
+ }
+
+ /**
+ * Destroy a user token to end a session
+ * @returns Response JSON
+ */
+ async logout() {
+ try {
+ let res = null;
+ if ( this.isLoggedIn ) {
+ res = await this.api.user.logout( { token: this.api._authentication.token } );
+ }
+
+ this.api._authentication = {};
+ this.settings.credentials = { username: '', password: '', token: '' };
+ localStorage.removeItem( _OpenSubtitlesClientSettingsKey );
+
+ return res;
+ } catch (err) {
+ console.error( '[opensubtitles] ', err );
+ this.api._authentication = {};
+ return null;
+ }
+ }
+
+ /**
+ * Parse filename from mediaItem
+ * @param {*} mediaItem item
+ * @returns filename
+ */
+ getFilenameFromMedia( mediaItem ) {
+ return mediaItem.Path.split('/').slice(-1)[0];
+ }
+
+ /**
+ * Find subtitle for a video file
+ * @param {Object} mediaItem Jellyfin media item
+ * @param {string} languages comma-separated languages
+ * @returns Search results
+ */
+ async searchForSubtitles( mediaItem, languages = null ) {
+ if ( !this.isLoggedIn ) {
+ return this.searchResults;
+ }
+ try {
+ if ( this.allowedDownloads() < 1 ) {
+ // If the user can't download, then don't even search
+ return {};
+ }
+
+ // Check filename (don't research for the same file)
+ const filename = this.getFilenameFromMedia(mediaItem);
+ if ( filename == this.searchResults?.filename ) {
+ return this.searchResults;
+ }
+
+ // Search options
+ const options = {
+ gzip: false,
+ order_by: 'upload_date', order_direction: 'desc',
+ ai_translated: 'include',
+ foreign_parts_only: 'include',
+ hearing_impaired: 'include'
+ };
+
+ // Find imdb_id
+ let imdb_id = mediaItem?.ProviderIds?.Imdb;
+ if ( !imdb_id ) {
+ const imdbRegex = /(?:\x69\x6d\x64\x62\x69\x64\x2d\x74\x74)([0-9]*)/;
+ if ( imdbRegex.test(mediaItem.Path) ) {
+ imdb_id = mediaItem.Path.match( imdbRegex ).at(-1);
+ }
+ }
+
+ // Fing tmdb_id
+ let tmdb_id = mediaItem?.ProviderIds?.Tmdb;
+ if ( !tmdb_id ) {
+ const tmdbRegex = /(?:\x74\x6d\x64\x62\x69\x64\x2d\x74\x74)([0-9]*)/;
+ if ( tmdbRegex.test(mediaItem.Path) ) {
+ tmdb_id = mediaItem.Path.match( tmdbRegex ).at(-1);
+ }
+ }
+
+ // Query (worst results)
+ let query = filename;
+ let titleName = null;
+ try {
+ if ( mediaItem.Type == 'Episode' ) {
+ // seriesName S##E##
+ const showName = mediaItem.SeriesName;
+ const aux = mediaItem.SortName.split('-').map((i)=>{
+ return Number(i.trim());
+ });
+ titleName = showName + ' S' + aux[0] + 'E' + aux[1];
+ } else if ( mediaItem.Type == 'Movie') {
+ // movieName (year)
+ titleName = mediaItem.Name + ' (' + mediaItem.ProductionYear + ')';
+ }
+ } catch (err) {
+ console.debug('[opensubtitles] unable to detect video information', err);
+ }
+
+ if ( titleName ) {
+ query = titleName;
+ }
+
+ // Search mode (using ID is more precise then query)
+ if ( imdb_id ) {
+ options.imdb_id = imdb_id;
+ } else if ( tmdb_id ) {
+ options.tmdb_id = tmdb_id;
+ } else {
+ options.query = query;
+ }
+
+ // Try to get at least one result per language, in order of preference
+ languages = languages || this.settings.languages || 'en';
+ const responses = await Promise.all( languages.split(',').map((i)=>{
+ return this.api.subtitles( { ...options, languages: i } );
+ }) );
+ let data = [];
+ for (const res of responses) {
+ data = data.concat( res.data );
+ }
+ this.searchResults = { data: data, item: mediaItem, filename: filename, options: options };
+ console.debug( '[opensubtitles] searchForSubtitles', this.searchResults );
+ } catch ( err ) {
+ console.error( '[opensubtitles] searchForSubtitles', err);
+ }
+ return this.searchResults;
+ }
+
+ /**
+ * Append OpenSubtitles text tracks to Jellyfin media item
+ * @param {Object} streams Array of streams
+ * @param {Object} mediaSource Full item
+ * @returns Array of streams
+ */
+ async appendSubtitleTracks( streams, mediaSource = null ) {
+ if ( !this.isLoggedIn ) {
+ return streams;
+ }
+
+ if ( mediaSource ) {
+ // OpenSubtitles is only for videos
+ if ( !Object.hasOwn(mediaSource, 'VideoType') ) {
+ return streams;
+ }
+
+ // Keep information from mediaSource
+ if ( (!this.mediaItem?.ProviderIds && mediaSource.ProviderIds)
+ || (this.mediaItem?.Path != mediaSource.Path) ) {
+ console.debug('[opensubtitles] mediaItem changed from ', this.mediaItem, ' to ', mediaSource);
+ this.mediaItem = mediaSource;
+ }
+ } else if ( !this.mediaItem?.VideoType ) {
+ return streams;
+ }
+
+ // Search for subtitles (if this has not been done already)
+ if ( this.getFilenameFromMedia(this.mediaItem) != this.searchResults?.filename ) {
+ await this.searchForSubtitles( this.mediaItem );
+ }
+
+ // Do nothing if there are no search results
+ if ( !this.searchResults?.data?.length ) {
+ console.debug('[opensubtitles] appendSubtitleTracks empty searchResults.data');
+ return streams;
+ }
+
+ // Append web `textTracks` to `streams`
+ const refIndex = Number( this.JellyfinMediaStreamTemplate.Index );
+ let numCount = 0;
+ for (const dataItem of this.searchResults.data) {
+ const textTrack = JSON.parse(JSON.stringify( this.JellyfinMediaStreamTemplate ));
+ textTrack.OpenSubstitlesFileId = dataItem.attributes.files[0].file_id;
+ textTrack.Language = dataItem.attributes.language;
+ textTrack.IsHearingImpaired = dataItem.attributes.hearing_impaired;
+ textTrack.DisplayTitle = dataItem.attributes.language + ' (web) ' + dataItem.attributes.files[0].file_name;
+ textTrack.OpenSubtitlesData = dataItem.attributes;
+ // playbackmanager.getSubtitleUrl was modified to get the download link, so this is probably extra
+ if ( OpenSubtitlesManager.downloadData?.link
+ && textTrack.OpenSubstitlesFileId == OpenSubtitlesManager.downloadData.fileId ) {
+ textTrack.Path = OpenSubtitlesManager.downloadData.link;
+ textTrack.DeliveryUrl = OpenSubtitlesManager.downloadData.link;
+ }
+
+ // Only append this item, if it has not been appended before
+ let found = false;
+ for (const streamItem of streams) {
+ if ( streamItem.OpenSubstitlesFileId === textTrack.OpenSubstitlesFileId ) {
+ found = true;
+ break;
+ }
+ }
+ if ( !found ) {
+ textTrack.Index = refIndex + numCount++;
+ streams.push( textTrack );
+ }
+ }
+ return streams;
+ }
+}
+
+const OpenSubtitlesManager = new OpenSubtitlesManagerClass();
+export default OpenSubtitlesManager;
diff --git a/src/scripts/opensubtitles/utils.js b/src/scripts/opensubtitles/utils.js
new file mode 100644
index 00000000000..2f25fcdd936
--- /dev/null
+++ b/src/scripts/opensubtitles/utils.js
@@ -0,0 +1,76 @@
+const languages = require('./languages.json');
+
+/**
+ * OpenSubtitles supported languages and codes
+ */
+const Languages = languages.data.toSorted( function(a, b) {
+ if (a.language_code < b.language_code) {
+ return -1;
+ } else if (a.language_code > b.language_code) {
+ return 1;
+ }
+ return 0;
+});
+
+/**
+ * Converts srt text to Jellyfin Html Video Player json
+ * @param {string} srt srt text
+ * @returns JSON Object
+ */
+function srtToJson( srt ) {
+ const data = srt.split('\n');
+ const out = { TrackEvents: [] };
+ let obj = { Text: '' };
+ try {
+ for (const dataLine of data) {
+ const line = dataLine.trim();
+ if ( !line.length && obj.EndPositionTicks && obj.StartPositionTicks && obj.Text ) {
+ if ( !obj.Id ) {
+ obj.Id = 1 + out.TrackEvents.length;
+ }
+ if ( out.TrackEvents.length
+ && (obj.StartPositionTicks == out.TrackEvents.at(-1).StartPositionTicks) ) {
+ // Some srt files do multiline like this
+ out.TrackEvents.at(-1).Text += '\n' + obj.Text;
+ } else {
+ out.TrackEvents.push(obj);
+ }
+ obj = { Text: '' };
+ } else if ( !isNaN(line) ) {
+ //obj.Id = String( parseInt(line) );
+ } else if (line.includes('-->')) {
+ const part = line.split('-->');
+ let start = part[0].split(',')[0].trim();
+ const timer1 = part[0].split(',')[1].trim();
+ let end = part[1].split(',')[0].trim();
+ const timer2 = part[1].split(',')[1].replace('\r', '').trim();
+
+ let startMillis = Number(timer1);
+ start = start.split(':');
+ startMillis += start[2] * 1000;
+ startMillis += start[1] * 1000 * 60;
+ startMillis += start[0] * 1000 * 60 * 60;
+
+ let endMillis = Number(timer2);
+ end = end.split(':');
+ endMillis += end[2] * 1000;
+ endMillis += end[1] * 1000 * 60;
+ endMillis += end[0] * 1000 * 60 * 60;
+
+ obj.StartPositionTicks = startMillis * 10000;
+ obj.EndPositionTicks = endMillis * 10000;
+ } else {
+ if ( obj.Text.length > 0 ) {
+ obj.Text += '\n';
+ }
+ obj.Text += line;
+ }
+ }
+ } catch (err) { console.error( 'srt_to_json parse error', err ); }
+ return out;
+}
+
+export default {
+ Languages,
+ srtToJson
+};
diff --git a/src/strings/en-us.json b/src/strings/en-us.json
index a2ef7d98437..e84a13a4169 100644
--- a/src/strings/en-us.json
+++ b/src/strings/en-us.json
@@ -836,6 +836,8 @@
"LabelPostProcessorArgumentsHelp": "Use {path} as the path to the recording file.",
"LabelPreferredDisplayLanguage": "Preferred display language",
"LabelPreferredSubtitleLanguage": "Preferred subtitle language",
+ "LabelSecondarySubtitleLanguage": "Secondary subtitle language",
+ "LabelTertiarySubtitleLanguage": "Tertiary subtitle language",
"LabelProfileContainer": "Container",
"LabelProtocol": "Protocol",
"LabelPublicHttpPort": "Public HTTP port number",
@@ -1218,6 +1220,9 @@
"OnlyForcedSubtitlesHelp": "Only subtitles marked as forced will be loaded.",
"OnlyImageFormats": "Only Image Formats (VobSub, PGS, SUB)",
"OnWakeFromSleep": "On wake from sleep",
+ "OpenSubtitlesStatusLoggedOut": "Status: Logged out",
+ "OpenSubtitlesStatusLoggedIn": "Status: Logged in ({0} allowed downloads)",
+ "OpenSubtitlesStatusFailedToLogIn": "Status: Failed to login (Error Code: {0})",
"Option3D": "3D",
"OptionAllowAudioPlaybackTranscoding": "Allow audio playback that requires transcoding",
"OptionAllowBrowsingLiveTv": "Allow Live TV access",
diff --git a/src/strings/pt-br.json b/src/strings/pt-br.json
index 3189bc376a3..33205fbe3ec 100644
--- a/src/strings/pt-br.json
+++ b/src/strings/pt-br.json
@@ -619,6 +619,8 @@
"LabelPostProcessorArgumentsHelp": "Usar {path} como o local do arquivo de gravação.",
"LabelPreferredDisplayLanguage": "Idioma preferido de exibição",
"LabelPreferredSubtitleLanguage": "Idioma de legendas preferido",
+ "LabelSecondarySubtitleLanguage": "Idioma de legendas secundário",
+ "LabelTertiarySubtitleLanguage": "Idioma de legendas terciário",
"LabelProfileAudioCodecs": "Codecs de áudio",
"LabelProfileCodecsHelp": "Separados por vírgula. Deixe em branco para aplicar a todos os codecs.",
"LabelProfileContainersHelp": "Separados por vírgula. Deixe em branco para aplicar a todos os formatos.",
@@ -1252,6 +1254,9 @@
"EveryHour": "A cada hora",
"EveryXMinutes": "A cada {0} minutos",
"OnWakeFromSleep": "Ao acordar da suspensão",
+ "OpenSubtitlesStatusLoggedOut": "Status: Deslogado",
+ "OpenSubtitlesStatusLoggedIn": "Status: Logado ({0} downloads disponíveis)",
+ "OpenSubtitlesStatusFailedToLogIn": "Status: Falha ao logar (Código de Erro: {0})",
"WeeklyAt": "{0} às {1}",
"DailyAt": "Diariamente à {0}",
"LastSeen": "Última atividade {0}",
@@ -1895,7 +1900,7 @@
"Penciller": "Lápis",
"LabelSelectPreferredTranscodeVideoCodec": "Codec de vídeo transcodificado preferido",
"SelectPreferredTranscodeVideoCodecHelp": "Seleccione o codec de vídeo preferido para transcodificar. Se o codec preferido não for suportado, o servidor irá usar o melhor codec disponível a seguir.",
- "HeaderNextItemPlayingInValue": "Próximo{0} Tocando em {1}",
+ "HeaderNextItemPlayingInValue": "Próximo {0} Tocando em {1}",
"LibraryInvalidItemIdError": "A biblioteca está em um estado inválido e não pode ser editada. Você possivelmente está encontrando um bug: o caminho no banco de dados não é o caminho correto no sistema de arquivos.",
"Reset": "Resetar",
"PasswordMissingSaveError": "A nova senha não pode ser em branco.",
diff --git a/src/strings/pt-pt.json b/src/strings/pt-pt.json
index e9775ecf4dc..133210f7c55 100644
--- a/src/strings/pt-pt.json
+++ b/src/strings/pt-pt.json
@@ -966,6 +966,8 @@
"LabelPostProcessor": "Aplicação de pós-processamento",
"LabelPostProcessorArguments": "Argumentos de linha de comandos para a aplicação de pós-processamento",
"LabelPreferredSubtitleLanguage": "Idioma preferido das legendas",
+ "LabelSecondarySubtitleLanguage": "Idioma secundário das legendas",
+ "LabelTertiarySubtitleLanguage": "Idioma terciário das legendas",
"LabelProfileCodecs": "Codecs",
"LabelReasonForTranscoding": "Razão para transcodificação",
"LabelScreensaver": "Proteção de Ecrã",
@@ -1311,6 +1313,9 @@
"EveryHour": "A cada hora",
"EveryXMinutes": "A cada {0} minutos",
"OnWakeFromSleep": "Ao acordar da suspensão",
+ "OpenSubtitlesStatusLoggedOut": "Status: Deslogado",
+ "OpenSubtitlesStatusLoggedIn": "Status: Logado ({0} downloads disponíveis)",
+ "OpenSubtitlesStatusFailedToLogIn": "Status: Falha ao logar (Código de Erro: {0})",
"DailyAt": "Diariamente às {0}",
"LastSeen": "Última atividade a {0}",
"PersonRole": "como {0}",