From fef2f5f54b3826b984059be046553f055dd2756d Mon Sep 17 00:00:00 2001 From: p0ps Date: Wed, 12 Jan 2022 11:29:42 +0100 Subject: [PATCH] Start on changes for changeIndexer page. (#9862) * Start on changes for changeIndexer page. * Separated new-show into new-show and new-show-search.vue. * Added components: - change-indexer.vue - change-indexer-row.vue - select-indexer.vue * Add ChangeIndexer QueueItem * Added ChangeIndexer queueitem * implement the api * Conntect frontend / backend. Enabled mass updating indexers. * yarn dev * Added menu item. * Fixed merge conflicts * Allow change episode status from SKIPPED to DOWNLOADED. This is the case when we quickly remove/add a show with existing episodes. * Remove a removed show from recentShows. * Fix linting issues * Fix tests. The specific test should result in a different new status now. * yarn dev * update snapshot * Update changelog --- CHANGELOG.md | 1 + medusa/queues/show_queue.py | 247 ++++++++- medusa/server/api/v2/series.py | 2 +- medusa/server/api/v2/series_change_indexer.py | 35 ++ medusa/server/core.py | 4 + medusa/server/web/manage/handler.py | 8 + medusa/tv/episode.py | 10 +- medusa/tv/series.py | 16 +- tests/test_update_status_quality.py | 4 +- .../slim/src/components/app-header.vue | 1 + .../slim/src/components/change-indexer.vue | 135 +++++ .../slim/src/components/display-show.vue | 8 +- themes-default/slim/src/components/index.js | 3 + .../components/manage/change-indexer-row.vue | 161 ++++++ .../slim/src/components/manage/index.js | 2 + .../src/components/manage/select-indexer.vue | 73 +++ .../slim/src/components/new-show-search.vue | 506 +++++++++++++++++ .../slim/src/components/new-show.vue | 508 +----------------- .../src/components/new-shows-existing.vue | 16 +- .../slim/src/components/show-list/banner.vue | 2 +- .../slim/src/components/show-list/simple.vue | 2 +- .../src/components/show-list/smallposter.vue | 2 +- themes-default/slim/src/router/routes.js | 11 + themes-default/slim/src/store/index.js | 3 + .../slim/src/store/modules/shows.js | 9 + themes-default/slim/static/css/style.css | 4 + .../menu/16x_sprite_colored_menu_icons.png | Bin 21043 -> 22112 bytes .../__snapshots__/app-header.spec.js.snap | 12 + themes/dark/assets/css/style.css | 4 + .../menu/16x_sprite_colored_menu_icons.png | Bin 18854 -> 22112 bytes themes/dark/assets/js/medusa-runtime.js | 383 ++++++++++++- ...rc_components_manage_change-indexer_vue.js | 66 +++ themes/light/assets/css/style.css | 4 + .../menu/16x_sprite_colored_menu_icons.png | Bin 18854 -> 22112 bytes themes/light/assets/js/medusa-runtime.js | 383 ++++++++++++- ...rc_components_manage_change-indexer_vue.js | 66 +++ 36 files changed, 2147 insertions(+), 544 deletions(-) create mode 100644 medusa/server/api/v2/series_change_indexer.py create mode 100644 themes-default/slim/src/components/change-indexer.vue create mode 100644 themes-default/slim/src/components/manage/change-indexer-row.vue create mode 100644 themes-default/slim/src/components/manage/index.js create mode 100644 themes-default/slim/src/components/manage/select-indexer.vue create mode 100644 themes-default/slim/src/components/new-show-search.vue create mode 100644 themes/dark/assets/js/src_components_manage_change-indexer_vue.js create mode 100644 themes/light/assets/js/src_components_manage_change-indexer_vue.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b396b0e9..76d172ec13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ #### New Features - Add support for banner and background images to indexer tvmaze ([10234](https://github.com/pymedusa/Medusa/pull/10234)) - Add option for using ffprobe to validate postprocessed media ([10132](https://github.com/pymedusa/Medusa/pull/10132)) +- Add change indexer page to change the current indexer for shows in bulk ([9862](https://github.com/pymedusa/Medusa/pull/9862)) #### Improvements - Add column sorting for the add new show page search results ([10217](https://github.com/pymedusa/Medusa/pull/10217)) diff --git a/medusa/queues/show_queue.py b/medusa/queues/show_queue.py index c2d84a7e06..c325bcced5 100644 --- a/medusa/queues/show_queue.py +++ b/medusa/queues/show_queue.py @@ -50,7 +50,7 @@ from medusa.logger.adapters.style import BraceAdapter from medusa.name_cache import build_name_cache from medusa.queues import generic_queue -from medusa.tv.series import SaveSeriesException, Series, SeriesIdentifier +from medusa.tv.series import ChangeIndexerException, SaveSeriesException, Series, SeriesIdentifier from requests.exceptions import RequestException @@ -75,6 +75,7 @@ def __init__(self): SUBTITLE = 6 REMOVE = 7 SEASON_UPDATE = 8 + CHANGE = 9 names = { REFRESH: 'Refresh', @@ -84,6 +85,7 @@ def __init__(self): SUBTITLE: 'Subtitle', REMOVE: 'Remove Show', SEASON_UPDATE: 'Season Update', + CHANGE: 'Change Indexer' } @@ -95,7 +97,9 @@ class ShowQueue(generic_queue.GenericQueue): ShowQueueActions.SEASON_UPDATE: 'The information on this page is in the process of being updated.', ShowQueueActions.REFRESH: 'The episodes below are currently being refreshed from disk', ShowQueueActions.SUBTITLE: 'Currently downloading subtitles for this show', + ShowQueueActions.CHANGE: "This show is in the process of changing it's indexer", } + queue_mappings = { ShowQueueActions.REFRESH: 'This show is queued to be refreshed.', ShowQueueActions.UPDATE: 'This show is queued and awaiting an update.', @@ -237,6 +241,12 @@ def addShow(self, indexer, indexer_id, show_dir, **options): return queue_item_obj + def changeIndexer(self, old_slug, new_slug): + queue_item_obj = QueueItemChangeIndexer(old_slug, new_slug) + self.add_item(queue_item_obj) + + return queue_item_obj + def removeShow(self, show, full=False): if show is None: raise CantRemoveShowException('Failed removing show: Show does not exist') @@ -300,12 +310,234 @@ def _isLoading(self): isLoading = property(_isLoading) -class QueueItemAdd(ShowQueueItem): - def __init__(self, indexer, indexer_id, show_dir, **options): +class QueueItemChangeIndexer(ShowQueueItem): + """Queue Item for changing a shows indexer to another.""" + + def __init__(self, old_slug, new_slug): + """ + Initialize QueueItemChangeIndexer with an old slug and new slug. + + Old slug will be used as the currently added show. Which is used to get all show options. + New slug is the to be created show. + """ + self.old_slug = old_slug + self.new_slug = new_slug + self.show_dir = None + self.root_dir = None + + self.options = {} + self.old_show = None + self.new_show = None + + # this will initialize self.show to None + ShowQueueItem.__init__(self, ShowQueueActions.CHANGE, self.old_show) + + # Process add show in priority + self.priority = generic_queue.QueuePriorities.HIGH + + def _store_options(self): + self.options = { + 'default_status': None, + 'quality': {'preferred': self.old_show.qualities_preferred, 'allowed': self.old_show.qualities_allowed}, + 'season_folders': self.old_show.season_folders, + 'lang': self.old_show.lang, + 'subtitles': self.old_show.subtitles, + 'anime': self.old_show.anime, + 'scene': self.old_show.scene, + 'paused': self.old_show.paused, + 'blacklist': self.old_show.release_groups.blacklist if self.old_show.release_groups else None, + 'whitelist': self.old_show.release_groups.whitelist if self.old_show.release_groups else None, + 'default_status_after': self.old_show.default_ep_status, + 'root_dir': None, + 'show_lists': self.old_show.show_lists + } + + self.show_dir = self.old_show._location + + def run(self): + """Run QueueItemChangeIndexer queue item.""" + step = [] + + # Small helper, to reduce code for messaging + def message_step(new_step): + step.append(new_step) + ws.Message('QueueItemShow', dict( + step=step, **self.to_json + )).push() + + ShowQueueItem.run(self) + + def get_show_from_slug(slug): + identifier = SeriesIdentifier.from_slug(slug) + if not identifier: + raise ChangeIndexerException(f'Could not create identifier with slug {slug}') + + show = Series.find_by_identifier(identifier) + return show + + try: + # Create reference to old show, before starting the remove it. + self.old_show = get_show_from_slug(self.old_slug) + + # Store needed options. + self._store_options() + + # Start of removing the old show + log.info( + '{id}: Removing {show}', + {'id': self.old_show.series_id, 'show': self.old_show.name} + ) + message_step(f'Removing old show {self.old_show.name}') + + # Need to first remove the episodes from the Trakt collection, because we need the list of + # Episodes from the db to know which eps to remove. + if app.USE_TRAKT: + message_step('Removing episodes from trakt collection') + try: + app.trakt_checker_scheduler.action.remove_show_trakt_library(self.old_show) + except TraktException as error: + log.warning( + '{id}: Unable to delete show {show} from Trakt.' + ' Please remove manually otherwise it will be added again.' + ' Error: {error_msg}', + {'id': self.old_show.series_id, 'show': self.old_show.name, 'error_msg': error} + ) + except Exception as error: + log.exception('Exception occurred while trying to delete show {show}, error: {error', + {'show': self.old_show.name, 'error': error}) + + self.old_show.delete_show(full=False) + + # Send showRemoved to frontend, so we can remove it from localStorage. + ws.Message('showRemoved', self.old_show.to_json(detailed=False)).push() # Send ws update to client + + # Double check to see if the show really has been removed, else bail. + if get_show_from_slug(self.old_slug): + raise ChangeIndexerException(f'Could not create identifier with slug {self.old_slug}') + + # Start adding the new show + log.info( + 'Starting to add show by {0}', + ('show_dir: {0}'.format(self.show_dir) + if self.show_dir else + 'New slug: {0}'.format(self.new_slug)) + ) + + self.new_show = Series.from_identifier(SeriesIdentifier.from_slug(self.new_slug)) + + try: + # Push an update to any open Web UIs through the WebSocket + message_step('load show from {indexer}'.format(indexer=indexerApi(self.new_show.indexer).name)) + + api = self.new_show.identifier.get_indexer_api(self.options) + + if getattr(api[self.new_show.series_id], 'seriesname', None) is None: + log.error( + 'Show in {path} has no name on {indexer}, probably searched with the wrong language.', + {'path': self.show_dir, 'indexer': indexerApi(self.new_show.indexer).name} + ) + + ui.notifications.error( + 'Unable to add show', + 'Show in {path} has no name on {indexer}, probably the wrong language.' + ' Delete .nfo and manually add the correct language.'.format( + path=self.show_dir, indexer=indexerApi(self.new_show.indexer).name) + ) + self._finish_early() + raise SaveSeriesException('Indexer is missing a showname in this language: {0!r}') + + self.new_show.load_from_indexer(tvapi=api) + + message_step('load info from imdb') + self.new_show.load_imdb_info() + except IndexerException as error: + log.warning('Unable to load series from indexer: {0!r}'.format(error)) + raise SaveSeriesException('Unable to load series from indexer: {0!r}'.format(error)) + + try: + message_step('configure show options') + self.new_show.configure(self) + except KeyError as error: + log.error( + 'Unable to add show {series_name} due to an error with one of the provided options: {error}', + {'series_name': self.new_show.name, 'error': error} + ) + ui.notifications.error( + 'Unable to add show {series_name} due to an error with one of the provided options: {error}'.format( + series_name=self.new_show.name, error=error + ) + ) + raise SaveSeriesException( + 'Unable to add show {series_name} due to an error with one of the provided options: {error}'.format( + series_name=self.new_show.name, error=error + )) + + except Exception as error: + log.error('Error trying to configure show: {0}', error) + log.debug(traceback.format_exc()) + raise + + app.showList.append(self.new_show) + self.new_show.save_to_db() + + try: + message_step('load episodes from {indexer}'.format(indexer=indexerApi(self.new_show.indexer).name)) + self.new_show.load_episodes_from_indexer(tvapi=api) + # If we provide a default_status_after through the apiv2 series route options object. + # set it after we've added the episodes. + self.new_show.default_ep_status = self.options['default_status_after'] or app.STATUS_DEFAULT_AFTER + + except IndexerException as error: + log.warning('Unable to load series episodes from indexer: {0!r}'.format(error)) + raise SaveSeriesException( + 'Unable to load series episodes from indexer: {0!r}'.format(error) + ) - # show_dir, default_status, quality, season_folders, lang, subtitles, anime, - # scene, paused, blacklist, whitelist, default_status_after, root_dir, show_lists): + message_step('create metadata in show folder') + self.new_show.write_metadata() + self.new_show.update_metadata() + self.new_show.populate_cache() + build_name_cache(self.new_show) # update internal name cache + self.new_show.flush_episodes() + self.new_show.sync_trakt() + message_step('add scene numbering') + self.new_show.add_scene_numbering() + + if self.show_dir: + # If a show dir was passed, this was added as an existing show. + # For new shows we shouldn't have any files on disk. + message_step('refresh episodes from disk') + try: + app.show_queue_scheduler.action.refreshShow(self.new_show) + except CantRefreshShowException as error: + log.warning('Unable to rescan episodes from disk: {0!r}'.format(error)) + + except (ChangeIndexerException, SaveSeriesException) as error: + log.warning('Unable to add series: {0!r}'.format(error)) + self.success = False + self._finish_early() + log.debug(traceback.format_exc()) + + default_status = self.options['default_status'] or app.STATUS_DEFAULT + if statusStrings[default_status] == 'Wanted': + message_step('trigger backlog search') + app.backlog_search_scheduler.action.search_backlog([self.new_show]) + + self.success = True + + ws.Message('showAdded', self.new_show.to_json(detailed=False)).push() # Send ws update to client + message_step('finished') + self.finish() + + def _finish_early(self): + if self.new_show is not None: + app.show_queue_scheduler.action.removeShow(self.new_show) + self.finish() + + +class QueueItemAdd(ShowQueueItem): + def __init__(self, indexer, indexer_id, show_dir, **options): self.indexer = indexer self.indexer_id = indexer_id self.show_dir = ensure_text(show_dir) if show_dir else None @@ -491,7 +723,10 @@ def _finish_early(self): class QueueItemRefresh(ShowQueueItem): + """QueueItemRefresh class.""" + def __init__(self, show=None, force=False): + """Queue item refresh constructor.""" ShowQueueItem.__init__(self, ShowQueueActions.REFRESH, show) # do refreshes first because they're quick @@ -501,7 +736,7 @@ def __init__(self, show=None, force=False): self.force = force def run(self): - + """Run QueueItemRefresh queue item.""" ShowQueueItem.run(self) log.info( diff --git a/medusa/server/api/v2/series.py b/medusa/server/api/v2/series.py index a5ecefdc48..1b15c135b6 100644 --- a/medusa/server/api/v2/series.py +++ b/medusa/server/api/v2/series.py @@ -110,7 +110,7 @@ def post(self, series_slug=None, path_param=None): 'paused': data_options.get('paused'), 'blacklist': data_options['release'].get('blacklist', []) if data_options.get('release') else None, 'whitelist': data_options['release'].get('whitelist', []) if data_options.get('release') else None, - 'default_status_after': data_options.get('statusAfter'), + 'default_status_after': None, 'root_dir': data_options.get('rootDir'), 'show_lists': data_options.get('showLists') } diff --git a/medusa/server/api/v2/series_change_indexer.py b/medusa/server/api/v2/series_change_indexer.py new file mode 100644 index 0000000000..fccb9d457c --- /dev/null +++ b/medusa/server/api/v2/series_change_indexer.py @@ -0,0 +1,35 @@ +# coding=utf-8 +"""Request handler for series assets.""" +from __future__ import unicode_literals + +from medusa import app +from medusa.server.api.v2.base import BaseRequestHandler +from medusa.tv.series import Series, SeriesIdentifier + +from tornado.escape import json_decode + + +class SeriesChangeIndexer(BaseRequestHandler): + """Change shows indexer.""" + + #: resource name + name = 'changeindexer' + #: identifier + identifier = None + #: allowed HTTP methods + allowed_methods = ('POST', ) + + def post(self): + """Change an existing show's indexer to another.""" + data = json_decode(self.request.body) + old_slug = data.get('oldSlug') + new_slug = data.get('newSlug') + + identifier = SeriesIdentifier.from_slug(old_slug) + series_obj = Series.find_by_identifier(identifier) + if not series_obj: + return self._not_found(f'Could not find a show to change indexer with slug {old_slug}') + + queue_item_obj = app.show_queue_scheduler.action.changeIndexer(old_slug, new_slug) + + return self._created(data=queue_item_obj.to_json) diff --git a/medusa/server/core.py b/medusa/server/core.py index 9fafb6f5f8..5b5b517222 100644 --- a/medusa/server/core.py +++ b/medusa/server/core.py @@ -35,6 +35,7 @@ from medusa.server.api.v2.search import SearchHandler from medusa.server.api.v2.series import SeriesHandler from medusa.server.api.v2.series_asset import SeriesAssetHandler +from medusa.server.api.v2.series_change_indexer import SeriesChangeIndexer from medusa.server.api.v2.series_legacy import SeriesLegacyHandler from medusa.server.api.v2.series_mass_edit import SeriesMassEdit from medusa.server.api.v2.series_mass_operation import SeriesMassOperation @@ -117,6 +118,9 @@ def get_apiv2_handlers(base): SeriesMassEdit.create_app_handler(base), # /api/v2/massupdate SeriesMassOperation.create_app_handler(base), + + # /api/v2/series/changeindexer + SeriesChangeIndexer.create_app_handler(base), # /api/v2/series/tvdb1234/operation SeriesOperationHandler.create_app_handler(base), # /api/v2/series/tvdb1234/asset diff --git a/medusa/server/web/manage/handler.py b/medusa/server/web/manage/handler.py index b7f55a7455..6e2b9d7485 100644 --- a/medusa/server/web/manage/handler.py +++ b/medusa/server/web/manage/handler.py @@ -36,6 +36,14 @@ def episodeStatuses(self, status=None): """ return PageTemplate(rh=self, filename='index.mako').render() + def changeIndexer(self): + """ + Render manage/changeIndexer page. + + [Converted to VueRouter] + """ + return PageTemplate(rh=self, filename='index.mako').render() + def subtitleMissed(self, whichSubs=None): """ Serve manageEpisodeStatus page. diff --git a/medusa/tv/episode.py b/medusa/tv/episode.py index f145b86945..5b3197b5d1 100644 --- a/medusa/tv/episode.py +++ b/medusa/tv/episode.py @@ -2086,8 +2086,14 @@ def update_status_quality(self, filepath): new_quality = Quality.name_quality(filepath, self.series.is_anime) if old_status in (SNATCHED, SNATCHED_PROPER, SNATCHED_BEST) or ( - old_status == DOWNLOADED and old_location) or ( - old_status == WANTED and not old_location): + old_status == DOWNLOADED and old_location + ) or ( + old_status == WANTED and not old_location + ) or ( + # For example when removing an existing show (keep files) + # and re-adding it. The status is SKIPPED just after adding it. + old_status == SKIPPED and not old_location + ): new_status = DOWNLOADED else: new_status = ARCHIVED diff --git a/medusa/tv/series.py b/medusa/tv/series.py index b48ca1fd12..90e5b8b62f 100644 --- a/medusa/tv/series.py +++ b/medusa/tv/series.py @@ -131,6 +131,10 @@ class SaveSeriesException(Exception): """Generic exception used for adding a new series.""" +class ChangeIndexerException(Exception): + """Generic exception used for changing a shows indexer.""" + + class SeriesIdentifier(Identifier): """Series identifier with indexer and indexer id.""" @@ -1377,8 +1381,15 @@ def __get_images(self, metadata_provider): season_all_poster_result = metadata_provider.create_season_all_poster(self) or season_all_poster_result season_all_banner_result = metadata_provider.create_season_all_banner(self) or season_all_banner_result - return (fanart_result or poster_result or banner_result or season_posters_result or - season_banners_result or season_all_poster_result or season_all_banner_result) + return ( + fanart_result + or poster_result + or banner_result + or season_posters_result + or season_banners_result + or season_all_poster_result + or season_all_banner_result + ) def make_ep_from_file(self, filepath): """Make a TVEpisode object from a media file. @@ -2306,6 +2317,7 @@ def to_json(self, detailed=False, episodes=False): data['id']['imdb'] = self.imdb_id data['id']['slug'] = self.identifier.slug data['id']['trakt'] = self.externals.get('trakt_id') + data['externals'] = {k.split('_')[0]: v for k, v in self.externals.items()} data['title'] = self.title # Name plus (optional) year. data['name'] = self.name data['indexer'] = self.indexer_name # e.g. tvdb diff --git a/tests/test_update_status_quality.py b/tests/test_update_status_quality.py index 69b1419afd..a0b1439489 100644 --- a/tests/test_update_status_quality.py +++ b/tests/test_update_status_quality.py @@ -77,11 +77,11 @@ def create(filepath, status, size, quality): 'filepath': 'Show.S01E08.1080p.HDTV.X264-GROUP.mkv', 'expected': (SNATCHED, Quality.FULLHDTV) }, - { # p8: Previous status was Skipped + { # p8: Previous status was Skipped, but we got a new file. Set to Downloaded. 'status': SKIPPED, 'quality': Quality.NA, 'filepath': 'Show.S01E09.1080p.HDTV.X264-GROUP.mkv', - 'expected': (ARCHIVED, Quality.FULLHDTV) + 'expected': (DOWNLOADED, Quality.FULLHDTV) }, { # p9: Previous status was Unaired 'status': UNAIRED, diff --git a/themes-default/slim/src/components/app-header.vue b/themes-default/slim/src/components/app-header.vue index 63bc59e229..bbf3a4f007 100644 --- a/themes-default/slim/src/components/app-header.vue +++ b/themes-default/slim/src/components/app-header.vue @@ -43,6 +43,7 @@