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
This commit is contained in:
JonnyWong16 2021-11-20 14:16:58 -08:00 committed by GitHub
parent 34a42185ac
commit 01131c95cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 153 additions and 164 deletions

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
import os
from urllib.parse import quote_plus
from plexapi import library, media, utils
@ -205,23 +206,20 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S
""" Alias of :func:`~plexapi.audio.Artist.track`. """
return self.track(title, album, track)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads all tracks for the artist to the specified location.
def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs):
""" Download all tracks from the artist. See :func:`~plexapi.base.Playable.download` for details.
Parameters:
savepath (str): Title of the track to return.
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Atrist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL` will
be returned and the additional arguments passed in will be sent to that
function. If kwargs is not specified, the media items will be downloaded
and saved to disk.
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original filename otherwise
a friendlier filename is generated.
subfolders (bool): True to separate tracks in to album folders.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
"""
filepaths = []
for album in self.albums():
for track in album.tracks():
filepaths += track.download(savepath, keep_original_name, **kwargs)
for track in self.tracks():
_savepath = os.path.join(savepath, track.parentTitle) if subfolders else savepath
filepaths += track.download(_savepath, keep_original_name, **kwargs)
return filepaths
@ -314,17 +312,13 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin,
return self.fetchItem(self.parentKey)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads all tracks for the artist to the specified location.
""" Download all tracks from the album. See :func:`~plexapi.base.Playable.download` for details.
Parameters:
savepath (str): Title of the track to return.
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Atrist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL` will
be returned and the additional arguments passed in will be sent to that
function. If kwargs is not specified, the media items will be downloaded
and saved to disk.
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original filename otherwise
a friendlier filename is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
"""
filepaths = []
for track in self.tracks():
@ -398,7 +392,8 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin,
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
return '%s - %s - %s - %s' % (
self.grandparentTitle, self.parentTitle, str(self.trackNumber).zfill(2), self.title)
def album(self):
""" Return the track's :class:`~plexapi.audio.Album`. """

View file

@ -681,34 +681,50 @@ class Playable(object):
client.playMedia(self)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads this items media to the specified location. Returns a list of
""" Downloads the media item to the specified location. Returns a list of
filepaths that have been saved to disk.
Parameters:
savepath (str): Title of the track to return.
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Artist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL` will
be returned and the additional arguments passed in will be sent to that
function. If kwargs is not specified, the media items will be downloaded
and saved to disk.
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original filename otherwise
a friendlier filename is generated. See filenames below.
**kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL`
to download a transcoded stream, otherwise the media item will be downloaded
as-is and saved to disk.
**Filenames**
* Movie: ``<title> (<year>)``
* Episode: ``<show title> - s00e00 - <episode title>``
* Track: ``<artist title> - <album title> - 00 - <track title>``
* Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>``
"""
filepaths = []
locations = [i for i in self.iterParts() if i]
for location in locations:
filename = location.file
if keep_original_name is False:
filename = '%s.%s' % (self._prettyfilename(), location.container)
# So this seems to be a alot slower but allows transcode.
parts = [i for i in self.iterParts() if i]
for part in parts:
if not keep_original_name:
filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container))
else:
filename = part.file
if kwargs:
# So this seems to be a alot slower but allows transcode.
download_url = self.getStreamURL(**kwargs)
else:
download_url = self._server.url('%s?download=1' % location.key)
filepath = utils.download(download_url, self._server._token, filename=filename,
savepath=savepath, session=self._server._session)
download_url = self._server.url('%s?download=1' % part.key)
filepath = utils.download(
download_url,
self._server._token,
filename=filename,
savepath=savepath,
session=self._server._session
)
if filepath:
filepaths.append(filepath)
return filepaths
def stop(self, reason=''):

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
import os
from urllib.parse import quote_plus
from plexapi import media, utils, video
@ -107,34 +108,21 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin):
""" Alias to :func:`~plexapi.photo.Photoalbum.photo`. """
return self.episode(title)
def iterParts(self):
""" Iterates over the parts of the media item. """
for album in self.albums():
for photo in album.photos():
for part in photo.iterParts():
yield part
def download(self, savepath=None, keep_original_name=False, showstatus=False):
""" Download photo files to specified directory.
def download(self, savepath=None, keep_original_name=False, subfolders=False):
""" Download all photos and clips from the photo ablum. See :func:`~plexapi.base.Playable.download` for details.
Parameters:
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
showstatus(bool): Display a progressbar.
keep_original_name (bool): True to keep the original filename otherwise
a friendlier filename is generated.
subfolders (bool): True to separate photos/clips in to photo album folders.
"""
filepaths = []
locations = [i for i in self.iterParts() if i]
for location in locations:
name = location.file
if not keep_original_name:
title = self.title.replace(' ', '.')
name = '%s.%s' % (title, location.container)
url = self._server.url('%s?download=1' % location.key)
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
savepath=savepath, session=self._server._session)
if filepath:
filepaths.append(filepath)
for album in self.albums():
_savepath = os.path.join(savepath, album.title) if subfolders else savepath
filepaths += album.download(_savepath, keep_original_name)
for photo in self.photos() + self.clips():
filepaths += photo.download(savepath, keep_original_name)
return filepaths
def _getWebURL(self, base=None):
@ -218,6 +206,12 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.year = utils.cast(int, data.attrib.get('year'))
def _prettyfilename(self):
""" Returns a filename for use in download. """
if self.parentTitle:
return '%s - %s' % (self.parentTitle, self.title)
return self.title
def photoalbum(self):
""" Return the photo's :class:`~plexapi.photo.Photoalbum`. """
return self.fetchItem(self.parentKey)
@ -241,12 +235,6 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi
"""
return [part.file for item in self.media for part in item.parts if part]
def iterParts(self):
""" Iterates over the parts of the media item. """
for item in self.media:
for part in item.parts:
yield part
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
""" Add current photo as sync item for specified device.
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
@ -283,29 +271,6 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi
return myplex.sync(sync_item, client=client, clientId=clientId)
def download(self, savepath=None, keep_original_name=False, showstatus=False):
""" Download photo files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
showstatus(bool): Display a progressbar.
"""
filepaths = []
locations = [i for i in self.iterParts() if i]
for location in locations:
name = location.file
if not keep_original_name:
title = self.title.replace(' ', '.')
name = '%s.%s' % (title, location.container)
url = self._server.url('%s?download=1' % location.key)
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
savepath=savepath, session=self._server._session)
if filepath:
filepaths.append(filepath)
return filepaths
def _getWebURL(self, base=None):
""" Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)

View file

@ -4,7 +4,9 @@ import functools
import logging
import os
import re
import string
import time
import unicodedata
import warnings
import zipfile
from datetime import datetime
@ -251,6 +253,13 @@ def toList(value, itemcast=None, delim=','):
return [itemcast(item) for item in value.split(delim) if item != '']
def cleanFilename(filename, replace='_'):
whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits)
cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
return cleaned_filename
def downloadSessionImages(server, filename=None, height=150, width=150,
opacity=100, saturation=100): # pragma: no cover
""" Helper to download a bif image or thumb.url from plex.server.sessions.

View file

@ -357,8 +357,8 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin
return any(part.hasPreviewThumbnails for media in self.media for part in media.parts)
def _prettyfilename(self):
# This is just for compat.
return self.title
""" Returns a filename for use in download. """
return '%s (%s)' % (self.title, self.year)
def reviews(self):
""" Returns a list of :class:`~plexapi.media.Review` objects. """
@ -375,32 +375,6 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin
data = self._server.query(self._details_key)
return self.findItems(data, library.Hub, rtag='Related')
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
"""
filepaths = []
locations = [i for i in self.iterParts() if i]
for location in locations:
name = location.file
if not keep_original_name:
title = self.title.replace(' ', '.')
name = '%s.%s' % (title, location.container)
if kwargs is not None:
url = self.getStreamURL(**kwargs)
else:
self._server.url('%s?download=1' % location.key)
filepath = utils.download(url, self._server._token, filename=name,
savepath=savepath, session=self._server._session)
if filepath:
filepaths.append(filepath)
return filepaths
@utils.registerPlexObject
class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin,
@ -582,18 +556,20 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Rat
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
return self.episodes(viewCount=0)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs):
""" Download all episodes from the show. See :func:`~plexapi.base.Playable.download` for details.
Parameters:
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
keep_original_name (bool): True to keep the original filename otherwise
a friendlier filename is generated.
subfolders (bool): True to separate episodes in to season folders.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
"""
filepaths = []
for episode in self.episodes():
filepaths += episode.download(savepath, keep_original_name, **kwargs)
_savepath = os.path.join(savepath, 'Season %s' % str(episode.seasonNumber).zfill(2)) if subfolders else savepath
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
return filepaths
@ -714,12 +690,12 @@ class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin):
return self.episodes(viewCount=0)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
""" Download all episodes from the season. See :func:`~plexapi.base.Playable.download` for details.
Parameters:
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
keep_original_name (bool): True to keep the original filename otherwise
a friendlier filename is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
"""
filepaths = []
@ -839,8 +815,8 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
] if p])
def _prettyfilename(self):
""" Returns a human friendly filename. """
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
""" Returns a filename for use in download. """
return '%s - %s - %s' % (self.grandparentTitle, self.seasonEpisode, self.title)
@property
def actors(self):
@ -953,6 +929,7 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin):
return [part.file for part in self.iterParts() if part]
def _prettyfilename(self):
""" Returns a filename for use in download. """
return self.title
@ -968,4 +945,5 @@ class Extra(Clip):
self.librarySectionTitle = parent.librarySectionTitle
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s (%s)' % (self.title, self.subtype)

View file

@ -117,7 +117,7 @@ def test_audio_Artist_media_tags(artist):
test_media.tag_style(artist)
def test_video_Artist_PlexWebURL(plex, artist):
def test_audio_Artist_PlexWebURL(plex, artist):
url = artist.getWebURL()
assert url.startswith('https://app.plex.tv/desktop')
assert plex.machineIdentifier in url
@ -224,7 +224,7 @@ def test_audio_Album_media_tags(album):
test_media.tag_style(album)
def test_video_Album_PlexWebURL(plex, album):
def test_audio_Album_PlexWebURL(plex, album):
url = album.getWebURL()
assert url.startswith('https://app.plex.tv/desktop')
assert plex.machineIdentifier in url
@ -375,7 +375,7 @@ def test_audio_Track_media_tags(track):
test_media.tag_mood(track)
def test_video_Track_PlexWebURL(plex, track):
def test_audio_Track_PlexWebURL(plex, track):
url = track.getWebURL()
assert url.startswith('https://app.plex.tv/desktop')
assert plex.machineIdentifier in url
@ -390,16 +390,20 @@ def test_audio_Audio_section(artist, album, track):
assert track.section().key == album.section().key == artist.section().key
def test_audio_Artist_download(monkeydownload, tmpdir, artist):
total = len(artist.tracks())
filepaths = artist.download(savepath=str(tmpdir))
assert len(filepaths) == total
subfolders = artist.download(savepath=str(tmpdir), subfolders=True)
assert len(subfolders) == total
def test_audio_Album_download(monkeydownload, tmpdir, album):
total = len(album.tracks())
filepaths = album.download(savepath=str(tmpdir))
assert len(filepaths) == total
def test_audio_Track_download(monkeydownload, tmpdir, track):
f = track.download(savepath=str(tmpdir))
assert f
def test_audio_album_download(monkeydownload, album, tmpdir):
f = album.download(savepath=str(tmpdir))
assert len(f) == 1
def test_audio_Artist_download(monkeydownload, artist, tmpdir):
f = artist.download(savepath=str(tmpdir))
assert len(f) == 1
filepaths = track.download(savepath=str(tmpdir))
assert len(filepaths) == 1

View file

@ -26,7 +26,7 @@ def test_photo_Photoalbum_mixins_rating(photoalbum):
test_mixins.edit_rating(photoalbum)
def test_video_Photoalbum_PlexWebURL(plex, photoalbum):
def test_photo_Photoalbum_PlexWebURL(plex, photoalbum):
url = photoalbum.getWebURL()
assert url.startswith('https://app.plex.tv/desktop')
assert plex.machineIdentifier in url
@ -48,10 +48,27 @@ def test_photo_Photo_media_tags(photo):
test_media.tag_tag(photo)
def test_video_Photo_PlexWebURL(plex, photo):
def test_photo_Photo_PlexWebURL(plex, photo):
url = photo.getWebURL()
assert url.startswith('https://app.plex.tv/desktop')
assert plex.machineIdentifier in url
assert 'details' in url
assert quote_plus(photo.parentKey) in url
assert 'legacy=1' in url
def test_photo_Photoalbum_download(monkeydownload, tmpdir, photoalbum):
total = 0
for album in photoalbum.albums():
total += len(album.photos()) + len(album.clips())
total += len(photoalbum.photos())
total += len(photoalbum.clips())
filepaths = photoalbum.download(savepath=str(tmpdir))
assert len(filepaths) == total
subfolders = photoalbum.download(savepath=str(tmpdir), subfolders=True)
assert len(subfolders) == total
def test_photo_Photo_download(monkeydownload, tmpdir, photo):
filepaths = photo.download(savepath=str(tmpdir))
assert len(filepaths) == 1

View file

@ -78,7 +78,8 @@ def test_utils_download(plex, episode):
url = episode.getStreamURL()
locations = episode.locations[0]
session = episode._server._session
assert utils.download(url, plex._token, filename=locations, mocked=True)
assert utils.download(
url, plex._token, filename=locations, mocked=True)
assert utils.download(
url, plex._token, filename=locations, session=session, mocked=True
)
@ -87,7 +88,6 @@ def test_utils_download(plex, episode):
)
def test_millisecondToHumanstr():
res = utils.millisecondToHumanstr(1000)
assert res == "00:00:01.0000"

View file

@ -143,10 +143,14 @@ def test_video_Movie_iterParts(movie):
def test_video_Movie_download(monkeydownload, tmpdir, movie):
filepaths1 = movie.download(savepath=str(tmpdir))
assert len(filepaths1) >= 1
filepaths2 = movie.download(savepath=str(tmpdir), videoResolution="500x300")
assert len(filepaths2) >= 1
filepaths = movie.download(savepath=str(tmpdir))
assert len(filepaths) == 1
with_resolution = movie.download(
savepath=str(tmpdir), keep_original_filename=True, videoResolution="500x300"
)
assert len(with_resolution) == 1
filename = os.path.basename(movie.media[0].parts[0].file)
assert filename in with_resolution[0]
def test_video_Movie_subtitlestreams(movie):
@ -728,24 +732,25 @@ def test_video_Show_episodes(tvshows):
def test_video_Show_download(monkeydownload, tmpdir, show):
episodes = show.episodes()
total = len(show.episodes())
filepaths = show.download(savepath=str(tmpdir))
assert len(filepaths) == len(episodes)
assert len(filepaths) == total
subfolders = show.download(savepath=str(tmpdir), subfolders=True)
assert len(subfolders) == total
def test_video_Season_download(monkeydownload, tmpdir, show):
season = show.season("Season 1")
season = show.season(1)
total = len(season.episodes())
filepaths = season.download(savepath=str(tmpdir))
assert len(filepaths) >= 4
assert len(filepaths) == total
def test_video_Episode_download(monkeydownload, tmpdir, episode):
f = episode.download(savepath=str(tmpdir))
assert len(f) == 1
with_sceen_size = episode.download(
savepath=str(tmpdir), **{"videoResolution": "500x300"}
)
assert len(with_sceen_size) == 1
filepaths = episode.download(savepath=str(tmpdir))
assert len(filepaths) == 1
with_resolution = episode.download(savepath=str(tmpdir), videoResolution="500x300")
assert len(with_resolution) == 1
# Analyze seems to fail intermittently