Skip to content

Commit 01131c9

Browse files
authored
Cleanup download methods (#847)
* Add utils.cleanFilename * Refactor download methods * Add option to download episodes, tracks, photos into subfolders * Update download tests * Test download keep_original_filename
1 parent 34a4218 commit 01131c9

9 files changed

Lines changed: 150 additions & 161 deletions

File tree

plexapi/audio.py

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# -*- coding: utf-8 -*-
2+
import os
23
from urllib.parse import quote_plus
34

45
from plexapi import library, media, utils
@@ -205,23 +206,20 @@ def get(self, title=None, album=None, track=None):
205206
""" Alias of :func:`~plexapi.audio.Artist.track`. """
206207
return self.track(title, album, track)
207208

208-
def download(self, savepath=None, keep_original_name=False, **kwargs):
209-
""" Downloads all tracks for the artist to the specified location.
209+
def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs):
210+
""" Download all tracks from the artist. See :func:`~plexapi.base.Playable.download` for details.
210211
211212
Parameters:
212-
savepath (str): Title of the track to return.
213-
keep_original_name (bool): Set True to keep the original filename as stored in
214-
the Plex server. False will create a new filename with the format
215-
"<Atrist> - <Album> <Track>".
216-
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL` will
217-
be returned and the additional arguments passed in will be sent to that
218-
function. If kwargs is not specified, the media items will be downloaded
219-
and saved to disk.
213+
savepath (str): Defaults to current working dir.
214+
keep_original_name (bool): True to keep the original filename otherwise
215+
a friendlier filename is generated.
216+
subfolders (bool): True to separate tracks in to album folders.
217+
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
220218
"""
221219
filepaths = []
222-
for album in self.albums():
223-
for track in album.tracks():
224-
filepaths += track.download(savepath, keep_original_name, **kwargs)
220+
for track in self.tracks():
221+
_savepath = os.path.join(savepath, track.parentTitle) if subfolders else savepath
222+
filepaths += track.download(_savepath, keep_original_name, **kwargs)
225223
return filepaths
226224

227225

@@ -314,17 +312,13 @@ def artist(self):
314312
return self.fetchItem(self.parentKey)
315313

316314
def download(self, savepath=None, keep_original_name=False, **kwargs):
317-
""" Downloads all tracks for the artist to the specified location.
315+
""" Download all tracks from the album. See :func:`~plexapi.base.Playable.download` for details.
318316
319317
Parameters:
320-
savepath (str): Title of the track to return.
321-
keep_original_name (bool): Set True to keep the original filename as stored in
322-
the Plex server. False will create a new filename with the format
323-
"<Atrist> - <Album> <Track>".
324-
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL` will
325-
be returned and the additional arguments passed in will be sent to that
326-
function. If kwargs is not specified, the media items will be downloaded
327-
and saved to disk.
318+
savepath (str): Defaults to current working dir.
319+
keep_original_name (bool): True to keep the original filename otherwise
320+
a friendlier filename is generated.
321+
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
328322
"""
329323
filepaths = []
330324
for track in self.tracks():
@@ -398,7 +392,8 @@ def _loadData(self, data):
398392

399393
def _prettyfilename(self):
400394
""" Returns a filename for use in download. """
401-
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
395+
return '%s - %s - %s - %s' % (
396+
self.grandparentTitle, self.parentTitle, str(self.trackNumber).zfill(2), self.title)
402397

403398
def album(self):
404399
""" Return the track's :class:`~plexapi.audio.Album`. """

plexapi/base.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -681,34 +681,50 @@ def play(self, client):
681681
client.playMedia(self)
682682

683683
def download(self, savepath=None, keep_original_name=False, **kwargs):
684-
""" Downloads this items media to the specified location. Returns a list of
684+
""" Downloads the media item to the specified location. Returns a list of
685685
filepaths that have been saved to disk.
686686
687687
Parameters:
688-
savepath (str): Title of the track to return.
689-
keep_original_name (bool): Set True to keep the original filename as stored in
690-
the Plex server. False will create a new filename with the format
691-
"<Artist> - <Album> <Track>".
692-
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL` will
693-
be returned and the additional arguments passed in will be sent to that
694-
function. If kwargs is not specified, the media items will be downloaded
695-
and saved to disk.
688+
savepath (str): Defaults to current working dir.
689+
keep_original_name (bool): True to keep the original filename otherwise
690+
a friendlier filename is generated. See filenames below.
691+
**kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL`
692+
to download a transcoded stream, otherwise the media item will be downloaded
693+
as-is and saved to disk.
694+
695+
**Filenames**
696+
697+
* Movie: ``<title> (<year>)``
698+
* Episode: ``<show title> - s00e00 - <episode title>``
699+
* Track: ``<artist title> - <album title> - 00 - <track title>``
700+
* Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>``
696701
"""
697702
filepaths = []
698-
locations = [i for i in self.iterParts() if i]
699-
for location in locations:
700-
filename = location.file
701-
if keep_original_name is False:
702-
filename = '%s.%s' % (self._prettyfilename(), location.container)
703-
# So this seems to be a alot slower but allows transcode.
703+
parts = [i for i in self.iterParts() if i]
704+
705+
for part in parts:
706+
if not keep_original_name:
707+
filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container))
708+
else:
709+
filename = part.file
710+
704711
if kwargs:
712+
# So this seems to be a alot slower but allows transcode.
705713
download_url = self.getStreamURL(**kwargs)
706714
else:
707-
download_url = self._server.url('%s?download=1' % location.key)
708-
filepath = utils.download(download_url, self._server._token, filename=filename,
709-
savepath=savepath, session=self._server._session)
715+
download_url = self._server.url('%s?download=1' % part.key)
716+
717+
filepath = utils.download(
718+
download_url,
719+
self._server._token,
720+
filename=filename,
721+
savepath=savepath,
722+
session=self._server._session
723+
)
724+
710725
if filepath:
711726
filepaths.append(filepath)
727+
712728
return filepaths
713729

714730
def stop(self, reason=''):

plexapi/photo.py

Lines changed: 17 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# -*- coding: utf-8 -*-
2+
import os
23
from urllib.parse import quote_plus
34

45
from plexapi import media, utils, video
@@ -107,34 +108,21 @@ def get(self, title):
107108
""" Alias to :func:`~plexapi.photo.Photoalbum.photo`. """
108109
return self.episode(title)
109110

110-
def iterParts(self):
111-
""" Iterates over the parts of the media item. """
112-
for album in self.albums():
113-
for photo in album.photos():
114-
for part in photo.iterParts():
115-
yield part
116-
117-
def download(self, savepath=None, keep_original_name=False, showstatus=False):
118-
""" Download photo files to specified directory.
111+
def download(self, savepath=None, keep_original_name=False, subfolders=False):
112+
""" Download all photos and clips from the photo ablum. See :func:`~plexapi.base.Playable.download` for details.
119113
120114
Parameters:
121115
savepath (str): Defaults to current working dir.
122-
keep_original_name (bool): True to keep the original file name otherwise
123-
a friendlier is generated.
124-
showstatus(bool): Display a progressbar.
116+
keep_original_name (bool): True to keep the original filename otherwise
117+
a friendlier filename is generated.
118+
subfolders (bool): True to separate photos/clips in to photo album folders.
125119
"""
126120
filepaths = []
127-
locations = [i for i in self.iterParts() if i]
128-
for location in locations:
129-
name = location.file
130-
if not keep_original_name:
131-
title = self.title.replace(' ', '.')
132-
name = '%s.%s' % (title, location.container)
133-
url = self._server.url('%s?download=1' % location.key)
134-
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
135-
savepath=savepath, session=self._server._session)
136-
if filepath:
137-
filepaths.append(filepath)
121+
for album in self.albums():
122+
_savepath = os.path.join(savepath, album.title) if subfolders else savepath
123+
filepaths += album.download(_savepath, keep_original_name)
124+
for photo in self.photos() + self.clips():
125+
filepaths += photo.download(savepath, keep_original_name)
138126
return filepaths
139127

140128
def _getWebURL(self, base=None):
@@ -218,6 +206,12 @@ def _loadData(self, data):
218206
self.userRating = utils.cast(float, data.attrib.get('userRating'))
219207
self.year = utils.cast(int, data.attrib.get('year'))
220208

209+
def _prettyfilename(self):
210+
""" Returns a filename for use in download. """
211+
if self.parentTitle:
212+
return '%s - %s' % (self.parentTitle, self.title)
213+
return self.title
214+
221215
def photoalbum(self):
222216
""" Return the photo's :class:`~plexapi.photo.Photoalbum`. """
223217
return self.fetchItem(self.parentKey)
@@ -241,12 +235,6 @@ def locations(self):
241235
"""
242236
return [part.file for item in self.media for part in item.parts if part]
243237

244-
def iterParts(self):
245-
""" Iterates over the parts of the media item. """
246-
for item in self.media:
247-
for part in item.parts:
248-
yield part
249-
250238
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
251239
""" Add current photo as sync item for specified device.
252240
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
@@ -283,29 +271,6 @@ def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
283271

284272
return myplex.sync(sync_item, client=client, clientId=clientId)
285273

286-
def download(self, savepath=None, keep_original_name=False, showstatus=False):
287-
""" Download photo files to specified directory.
288-
289-
Parameters:
290-
savepath (str): Defaults to current working dir.
291-
keep_original_name (bool): True to keep the original file name otherwise
292-
a friendlier is generated.
293-
showstatus(bool): Display a progressbar.
294-
"""
295-
filepaths = []
296-
locations = [i for i in self.iterParts() if i]
297-
for location in locations:
298-
name = location.file
299-
if not keep_original_name:
300-
title = self.title.replace(' ', '.')
301-
name = '%s.%s' % (title, location.container)
302-
url = self._server.url('%s?download=1' % location.key)
303-
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
304-
savepath=savepath, session=self._server._session)
305-
if filepath:
306-
filepaths.append(filepath)
307-
return filepaths
308-
309274
def _getWebURL(self, base=None):
310275
""" Get the Plex Web URL with the correct parameters. """
311276
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)

plexapi/utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import logging
55
import os
66
import re
7+
import string
78
import time
9+
import unicodedata
810
import warnings
911
import zipfile
1012
from datetime import datetime
@@ -251,6 +253,13 @@ def toList(value, itemcast=None, delim=','):
251253
return [itemcast(item) for item in value.split(delim) if item != '']
252254

253255

256+
def cleanFilename(filename, replace='_'):
257+
whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits)
258+
cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
259+
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
260+
return cleaned_filename
261+
262+
254263
def downloadSessionImages(server, filename=None, height=150, width=150,
255264
opacity=100, saturation=100): # pragma: no cover
256265
""" Helper to download a bif image or thumb.url from plex.server.sessions.

plexapi/video.py

Lines changed: 16 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ def hasPreviewThumbnails(self):
357357
return any(part.hasPreviewThumbnails for media in self.media for part in media.parts)
358358

359359
def _prettyfilename(self):
360-
# This is just for compat.
361-
return self.title
360+
""" Returns a filename for use in download. """
361+
return '%s (%s)' % (self.title, self.year)
362362

363363
def reviews(self):
364364
""" Returns a list of :class:`~plexapi.media.Review` objects. """
@@ -375,32 +375,6 @@ def hubs(self):
375375
data = self._server.query(self._details_key)
376376
return self.findItems(data, library.Hub, rtag='Related')
377377

378-
def download(self, savepath=None, keep_original_name=False, **kwargs):
379-
""" Download video files to specified directory.
380-
381-
Parameters:
382-
savepath (str): Defaults to current working dir.
383-
keep_original_name (bool): True to keep the original file name otherwise
384-
a friendlier is generated.
385-
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
386-
"""
387-
filepaths = []
388-
locations = [i for i in self.iterParts() if i]
389-
for location in locations:
390-
name = location.file
391-
if not keep_original_name:
392-
title = self.title.replace(' ', '.')
393-
name = '%s.%s' % (title, location.container)
394-
if kwargs is not None:
395-
url = self.getStreamURL(**kwargs)
396-
else:
397-
self._server.url('%s?download=1' % location.key)
398-
filepath = utils.download(url, self._server._token, filename=name,
399-
savepath=savepath, session=self._server._session)
400-
if filepath:
401-
filepaths.append(filepath)
402-
return filepaths
403-
404378

405379
@utils.registerPlexObject
406380
class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin,
@@ -582,18 +556,20 @@ def unwatched(self):
582556
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
583557
return self.episodes(viewCount=0)
584558

585-
def download(self, savepath=None, keep_original_name=False, **kwargs):
586-
""" Download video files to specified directory.
559+
def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs):
560+
""" Download all episodes from the show. See :func:`~plexapi.base.Playable.download` for details.
587561
588562
Parameters:
589563
savepath (str): Defaults to current working dir.
590-
keep_original_name (bool): True to keep the original file name otherwise
591-
a friendlier is generated.
564+
keep_original_name (bool): True to keep the original filename otherwise
565+
a friendlier filename is generated.
566+
subfolders (bool): True to separate episodes in to season folders.
592567
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
593568
"""
594569
filepaths = []
595570
for episode in self.episodes():
596-
filepaths += episode.download(savepath, keep_original_name, **kwargs)
571+
_savepath = os.path.join(savepath, 'Season %s' % str(episode.seasonNumber).zfill(2)) if subfolders else savepath
572+
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
597573
return filepaths
598574

599575

@@ -714,12 +690,12 @@ def unwatched(self):
714690
return self.episodes(viewCount=0)
715691

716692
def download(self, savepath=None, keep_original_name=False, **kwargs):
717-
""" Download video files to specified directory.
693+
""" Download all episodes from the season. See :func:`~plexapi.base.Playable.download` for details.
718694
719695
Parameters:
720696
savepath (str): Defaults to current working dir.
721-
keep_original_name (bool): True to keep the original file name otherwise
722-
a friendlier is generated.
697+
keep_original_name (bool): True to keep the original filename otherwise
698+
a friendlier filename is generated.
723699
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
724700
"""
725701
filepaths = []
@@ -839,8 +815,8 @@ def __repr__(self):
839815
] if p])
840816

841817
def _prettyfilename(self):
842-
""" Returns a human friendly filename. """
843-
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
818+
""" Returns a filename for use in download. """
819+
return '%s - %s - %s' % (self.grandparentTitle, self.seasonEpisode, self.title)
844820

845821
@property
846822
def actors(self):
@@ -953,6 +929,7 @@ def locations(self):
953929
return [part.file for part in self.iterParts() if part]
954930

955931
def _prettyfilename(self):
932+
""" Returns a filename for use in download. """
956933
return self.title
957934

958935

@@ -968,4 +945,5 @@ def _loadData(self, data):
968945
self.librarySectionTitle = parent.librarySectionTitle
969946

970947
def _prettyfilename(self):
948+
""" Returns a filename for use in download. """
971949
return '%s (%s)' % (self.title, self.subtype)

0 commit comments

Comments
 (0)