Skip to content

Commit d4426da

Browse files
authored
Add support for on-demand subtitles (#1305)
* Add support for on-demand subtitle search * Clean up uploadSubtitles and removeSubtitles methods * Add test for on-demand subtitles Account is required for on-demand subtitles * Add hearingImpaired and perfectMatch to SubtitleStream attributes * Update subtitle score doc string The score is the OpenSubtitles download count Ref: https://forums.plex.tv/t/subtitle-search-update-opensubtitles/862746
1 parent b0ac5ea commit d4426da

File tree

3 files changed

+93
-15
lines changed

3 files changed

+93
-15
lines changed

plexapi/media.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,10 @@ class SubtitleStream(MediaPartStream):
444444
forced (bool): True if this is a forced subtitle.
445445
format (str): The format of the subtitle stream (ex: srt).
446446
headerCompression (str): The header compression of the subtitle stream.
447+
hearingImpaired (bool): True if this is a hearing impaired (SDH) subtitle.
448+
perfectMatch (bool): True if the on-demand subtitle is a perfect match.
447449
providerTitle (str): The provider title where the on-demand subtitle is downloaded from.
448-
score (int): The match score of the on-demand subtitle.
450+
score (int): The match score (download count) of the on-demand subtitle.
449451
sourceKey (str): The source key of the on-demand subtitle.
450452
transient (str): Unknown.
451453
userID (int): The user id of the user that downloaded the on-demand subtitle.
@@ -460,6 +462,8 @@ def _loadData(self, data):
460462
self.forced = utils.cast(bool, data.attrib.get('forced', '0'))
461463
self.format = data.attrib.get('format')
462464
self.headerCompression = data.attrib.get('headerCompression')
465+
self.hearingImpaired = utils.cast(bool, data.attrib.get('hearingImpaired', '0'))
466+
self.perfectMatch = utils.cast(bool, data.attrib.get('perfectMatch'))
463467
self.providerTitle = data.attrib.get('providerTitle')
464468
self.score = utils.cast(int, data.attrib.get('score'))
465469
self.sourceKey = data.attrib.get('sourceKey')

plexapi/video.py

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,27 +98,87 @@ def _defaultSyncTitle(self):
9898
return self.title
9999

100100
def uploadSubtitles(self, filepath):
101-
""" Upload Subtitle file for video. """
101+
""" Upload a subtitle file for the video.
102+
103+
Parameters:
104+
filepath (str): Path to subtitle file.
105+
"""
102106
url = f'{self.key}/subtitles'
103107
filename = os.path.basename(filepath)
104108
subFormat = os.path.splitext(filepath)[1][1:]
109+
params = {
110+
'title': filename,
111+
'format': subFormat,
112+
}
113+
headers = {'Accept': 'text/plain, */*'}
105114
with open(filepath, 'rb') as subfile:
106-
params = {'title': filename,
107-
'format': subFormat
108-
}
109-
headers = {'Accept': 'text/plain, */*'}
110115
self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers)
111116
return self
112117

113-
def removeSubtitles(self, streamID=None, streamTitle=None):
114-
""" Remove Subtitle from movie's subtitles listing.
118+
def searchSubtitles(self, language='en', hearingImpaired=0, forced=0):
119+
""" Search for on-demand subtitles for the video.
120+
See https://support.plex.tv/articles/subtitle-search/.
115121
116-
Note: If subtitle file is located inside video directory it will bbe deleted.
117-
Files outside of video directory are not effected.
122+
Parameters:
123+
language (str, optional): Language code (ISO 639-1) of the subtitles to search for.
124+
Default 'en'.
125+
hearingImpaired (int, optional): Search option for SDH subtitles.
126+
Default 0.
127+
(0 = Prefer non-SDH subtitles, 1 = Prefer SDH subtitles,
128+
2 = Only show SDH subtitles, 3 = Only show non-SDH subtitles)
129+
forced (int, optional): Search option for forced subtitles.
130+
Default 0.
131+
(0 = Prefer non-forced subtitles, 1 = Prefer forced subtitles,
132+
2 = Only show forced subtitles, 3 = Only show non-forced subtitles)
133+
134+
Returns:
135+
List<:class:`~plexapi.media.SubtitleStream`>: List of SubtitleStream objects.
136+
"""
137+
params = {
138+
'language': language,
139+
'hearingImpaired': hearingImpaired,
140+
'forced': forced,
141+
}
142+
key = f'{self.key}/subtitles{utils.joinArgs(params)}'
143+
return self.fetchItems(key)
144+
145+
def downloadSubtitles(self, subtitleStream):
146+
""" Download on-demand subtitles for the video.
147+
See https://support.plex.tv/articles/subtitle-search/.
148+
149+
Note: This method is asynchronous and returns immediately before subtitles are fully downloaded.
150+
151+
Parameters:
152+
subtitleStream (:class:`~plexapi.media.SubtitleStream`):
153+
Subtitle object returned from :func:`~plexapi.video.Video.searchSubtitles`.
154+
"""
155+
key = f'{self.key}/subtitles'
156+
params = {'key': subtitleStream.key}
157+
self._server.query(key, self._server._session.put, params=params)
158+
return self
159+
160+
def removeSubtitles(self, subtitleStream=None, streamID=None, streamTitle=None):
161+
""" Remove an upload or downloaded subtitle from the video.
162+
163+
Note: If the subtitle file is located inside video directory it will be deleted.
164+
Files outside of video directory are not affected.
165+
Embedded subtitles cannot be removed.
166+
167+
Parameters:
168+
subtitleStream (:class:`~plexapi.media.SubtitleStream`, optional): Subtitle object to remove.
169+
streamID (int, optional): ID of the subtitle stream to remove.
170+
streamTitle (str, optional): Title of the subtitle stream to remove.
118171
"""
119-
for stream in self.subtitleStreams():
120-
if streamID == stream.id or streamTitle == stream.title:
121-
self._server.query(stream.key, self._server._session.delete)
172+
if subtitleStream is None:
173+
try:
174+
subtitleStream = next(
175+
stream for stream in self.subtitleStreams()
176+
if streamID == stream.id or streamTitle == stream.title
177+
)
178+
except StopIteration:
179+
raise BadRequest(f"Subtitle stream with ID '{streamID}' or title '{streamTitle}' not found.") from None
180+
181+
self._server.query(subtitleStream.key, self._server._session.delete)
122182
return self
123183

124184
def optimize(self, title='', target='', deviceProfile='', videoQuality=None,

tests/test_video.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,6 @@ def test_video_Episode_subtitleStreams(episode):
404404

405405

406406
def test_video_Movie_upload_select_remove_subtitle(movie, subtitle):
407-
408407
filepath = os.path.realpath(subtitle.name)
409408

410409
movie.uploadSubtitles(filepath)
@@ -414,7 +413,6 @@ def test_video_Movie_upload_select_remove_subtitle(movie, subtitle):
414413

415414
movie.subtitleStreams()[0].setSelected()
416415
movie.reload()
417-
418416
subtitleSelection = movie.subtitleStreams()[0]
419417
assert subtitleSelection.selected
420418

@@ -429,6 +427,22 @@ def test_video_Movie_upload_select_remove_subtitle(movie, subtitle):
429427
pass
430428

431429

430+
def test_video_Movie_on_demand_subtitles(movie, account):
431+
movie_subtitles = movie.subtitleStreams()
432+
subtitles = movie.searchSubtitles()
433+
assert subtitles != []
434+
435+
subtitle = subtitles[0]
436+
437+
movie.downloadSubtitles(subtitle)
438+
utils.wait_until(lambda: len(movie.reload().subtitleStreams()) > len(movie_subtitles))
439+
subtitle_sourceKeys = {stream.sourceKey: stream for stream in movie.subtitleStreams()}
440+
assert subtitle.sourceKey in subtitle_sourceKeys
441+
442+
movie.removeSubtitles(subtitleStream=subtitle_sourceKeys[subtitle.sourceKey]).reload()
443+
assert subtitle.sourceKey not in [stream.sourceKey for stream in movie.subtitleStreams()]
444+
445+
432446
def test_video_Movie_match(movies):
433447
sectionAgent = movies.agent
434448
sectionAgents = [agent.identifier for agent in movies.agents() if agent.shortIdentifier != 'none']

0 commit comments

Comments
 (0)