diff --git a/medusa/classes.py b/medusa/classes.py index 68d8af8663..7b71cd575a 100644 --- a/medusa/classes.py +++ b/medusa/classes.py @@ -23,7 +23,7 @@ from dateutil import parser -from medusa import app +from medusa import app, ws from medusa.common import ( MULTI_EP_RESULT, Quality, @@ -32,7 +32,6 @@ from medusa.logger.adapters.style import BraceAdapter from medusa.search import SearchType - from six import itervalues log = BraceAdapter(logging.getLogger(__name__)) @@ -204,6 +203,34 @@ def __repr__(self): return '<{0}: {1}>'.format(type(self).__name__, result) + def to_json(self): + """Return JSON representation.""" + return { + 'identifier': self.identifier, + 'release': self.name, + 'season': self.actual_season, + 'episodes': self.actual_episodes, + 'seasonPack': len(self.actual_episodes) == 0, + 'indexer': self.series.indexer, + 'seriesId': self.series.series_id, + 'showSlug': self.series.identifier.slug, + 'url': self.url, + 'time': datetime.now().replace(microsecond=0).isoformat(), + 'quality': self.quality, + 'releaseGroup': self.release_group, + 'dateAdded': datetime.now().replace(microsecond=0).isoformat(), + 'version': self.version, + 'seeders': self.seeders, + 'size': self.size, + 'leechers': self.leechers, + 'pubdate': self.pubdate.replace(microsecond=0).isoformat() if self.pubdate else None, + 'provider': { + 'id': self.provider.get_id(), + 'name': self.provider.name, + 'imageName': self.provider.image_name() + } + } + def file_name(self): return u'{0}.{1}'.format(self.episodes[0].pretty_name(), self.result_type) @@ -213,6 +240,10 @@ def add_result_to_cache(self, cache): # FIXME: Added repr parsing, as that prevents the logger from throwing an exception. # This can happen when there are unicode decoded chars in the release name. log.debug('Adding item from search to cache: {release_name!r}', release_name=self.name) + + # Push an update to any open Web UIs through the WebSocket + ws.Message('addManualSearchResult', self.to_json()).push() + return cache.add_cache_entry(self, parsed_result=self.parsed_result) def _create_episode_objects(self): @@ -279,7 +310,7 @@ def update_from_db(self): self.leechers = int(cached_result['leechers']) self.release_group = cached_result['release_group'] self.version = int(cached_result['version']) - self.pubdate = cached_result['pubdate'] + self.pubdate = parser.parse(cached_result['pubdate']) if cached_result['pubdate'] else None self.proper_tags = cached_result['proper_tags'].split('|') \ if cached_result['proper_tags'] else [] self.date = datetime.today() diff --git a/medusa/generic_queue.py b/medusa/generic_queue.py index 0d553e3580..31b3708c63 100644 --- a/medusa/generic_queue.py +++ b/medusa/generic_queue.py @@ -2,11 +2,13 @@ from __future__ import unicode_literals -import datetime import logging import threading from builtins import object +from datetime import datetime from functools import cmp_to_key +from uuid import uuid4 + log = logging.getLogger() @@ -46,7 +48,7 @@ def add_item(self, item): :return: item """ with self.lock: - item.added = datetime.datetime.now() + item.added = datetime.utcnow() self.queue.append(item) return item @@ -111,19 +113,38 @@ def __init__(self, name, action_id=0): self.action_id = action_id self.stop = threading.Event() self.added = None - self.queue_time = datetime.datetime.now() + self.queue_time = datetime.utcnow() self.start_time = None + self._to_json = { + 'identifier': str(uuid4()), + 'name': self.name, + 'priority': self.priority, + 'actionId': self.action_id, + 'queueTime': str(self.queue_time), + 'success': None + } def run(self): """Implementing classes should call this.""" self.inProgress = True - self.start_time = datetime.datetime.now() + self.start_time = datetime.utcnow() def finish(self): """Implementing Classes should call this.""" self.inProgress = False threading.currentThread().name = self.name + @property + def to_json(self): + """Update queue item JSON representation.""" + self._to_json.update({ + 'inProgress': self.inProgress, + 'startTime': str(self.start_time) if self.start_time else None, + 'updateTime': str(datetime.utcnow()), + 'success': self.success + }) + return self._to_json + def fifo(my_list, item, max_size=100): """Append item to queue and limit it to 100 items.""" diff --git a/medusa/indexers/config.py b/medusa/indexers/config.py index ee318f271f..d43ebc68c6 100644 --- a/medusa/indexers/config.py +++ b/medusa/indexers/config.py @@ -146,6 +146,7 @@ def create_config_json(indexer): def get_indexer_config(): + """Create a per indexer and main indexer config, used by the apiv2.""" indexers = { indexerConfig[indexer]['identifier']: create_config_json(indexerConfig[indexer]) for indexer in indexerConfig } diff --git a/medusa/providers/generic_provider.py b/medusa/providers/generic_provider.py index 6d661c802d..e02c881b78 100644 --- a/medusa/providers/generic_provider.py +++ b/medusa/providers/generic_provider.py @@ -848,3 +848,49 @@ def __str__(self): def __unicode__(self): """Return provider name and provider type.""" return '{provider_name} ({provider_type})'.format(provider_name=self.name, provider_type=self.provider_type) + + def to_json(self): + """Return a json representation for a provider.""" + from medusa.providers.torrent.torrent_provider import TorrentProvider + return { + 'name': self.name, + 'id': self.get_id(), + 'config': { + 'enabled': self.enabled, + 'search': { + 'backlog': { + 'enabled': self.enable_backlog + }, + 'manual': { + 'enabled': self.enable_backlog + }, + 'daily': { + 'enabled': self.enable_backlog, + 'maxRecentItems': self.max_recent_items, + 'stopAt': self.stop_at + }, + 'fallback': self.search_fallback, + 'mode': self.search_mode, + 'separator': self.search_separator, + 'seasonTemplates': self.season_templates, + 'delay': { + 'enabled': self.enable_search_delay, + 'duration': self.search_delay + } + } + }, + 'animeOnly': self.anime_only, + 'type': self.provider_type, + 'public': self.public, + 'btCacheUrls': self.bt_cache_urls if isinstance(self, TorrentProvider) else [], + 'properStrings': self.proper_strings, + 'headers': self.headers, + 'supportsAbsoluteNumbering': self.supports_absolute_numbering, + 'supportsBacklog': self.supports_backlog, + 'url': self.url, + 'urls': self.urls, + 'cookies': { + 'enabled': self.enable_cookies, + 'required': self.cookies + } + } diff --git a/medusa/search/queue.py b/medusa/search/queue.py index 290c617659..fcc65ca3b9 100644 --- a/medusa/search/queue.py +++ b/medusa/search/queue.py @@ -9,7 +9,7 @@ import time import traceback -from medusa import app, common, failed_history, generic_queue, history, ui +from medusa import app, common, failed_history, generic_queue, history, ui, ws from medusa.helpers import pretty_file_size from medusa.logger.adapters.style import BraceAdapter from medusa.search import BACKLOG_SEARCH, DAILY_SEARCH, FAILED_SEARCH, MANUAL_SEARCH, SNATCH_RESULT, SearchType @@ -256,6 +256,11 @@ def __init__(self, scheduler_start_time, force): self.scheduler_start_time = scheduler_start_time self.force = force + self.to_json.update({ + 'success': self.success, + 'force': self.force + }) + def run(self): """Run daily search thread.""" generic_queue.QueueItem.run(self) @@ -263,6 +268,10 @@ def run(self): try: log.info('Beginning daily search for new episodes') + + # Push an update to any open Web UIs through the WebSocket + ws.Message('QueueItemUpdate', self.to_json).push() + found_results = search_for_needed_episodes(self.scheduler_start_time, force=self.force) if not found_results: @@ -315,6 +324,9 @@ def run(self): if self.success is None: self.success = False + # Push an update to any open Web UIs through the WebSocket + ws.Message('QueueItemUpdate', self.to_json).push() + self.finish() @@ -345,6 +357,13 @@ def __init__(self, show, segment, manual_search_type='episode'): self.segment = segment self.manual_search_type = manual_search_type + self.to_json.update({ + 'show': self.show.to_json(), + 'segment': [ep.to_json() for ep in self.segment], + 'success': self.success, + 'manualSearchType': self.manual_search_type + }) + def run(self): """Run manual search thread.""" generic_queue.QueueItem.run(self) @@ -359,6 +378,9 @@ def run(self): } ) + # Push an update to any open Web UIs through the WebSocket + ws.Message('QueueItemUpdate', self.to_json).push() + search_result = search_providers(self.show, self.segment, forced_search=True, down_cur_quality=True, manual_search=True, manual_search_type=self.manual_search_type) @@ -396,6 +418,10 @@ def run(self): if self.success is None: self.success = False + # Push an update to any open Web UIs through the WebSocket + msg = ws.Message('QueueItemUpdate', self.to_json) + msg.push() + self.finish() @@ -423,6 +449,13 @@ def __init__(self, show, segment, search_result): self.results = None self.search_result = search_result + self.to_json.update({ + 'show': self.show.to_json(), + 'segment': [ep.to_json() for ep in self.segment], + 'success': self.success, + 'searchResult': self.search_result.to_json() + }) + def run(self): """Run manual snatch job.""" generic_queue.QueueItem.run(self) @@ -434,6 +467,10 @@ def run(self): log.info('Beginning to snatch release: {name}', {'name': result.name}) + # Push an update to any open Web UIs through the WebSocket + msg = ws.Message('QueueItemUpdate', self.to_json) + msg.push() + if result: if result.seeders not in (-1, None) and result.leechers not in (-1, None): log.info( @@ -473,6 +510,10 @@ def run(self): if self.success is None: self.success = False + # Push an update to any open Web UIs through the WebSocket + msg = ws.Message('QueueItemUpdate', self.to_json) + msg.push() + self.finish() @@ -491,6 +532,12 @@ def __init__(self, show, segment): self.show = show self.segment = segment + self.to_json.update({ + 'show': self.show.to_json(), + 'segment': [ep.to_json() for ep in self.segment], + 'success': self.success + }) + def run(self): """Run backlog search thread.""" generic_queue.QueueItem.run(self) @@ -500,6 +547,10 @@ def run(self): try: log.info('Beginning backlog search for: {name}', {'name': self.show.name}) + + # Push an update to any open Web UIs through the WebSocket + ws.Message('QueueItemUpdate', self.to_json).push() + search_result = search_providers(self.show, self.segment) if search_result: @@ -557,6 +608,9 @@ def run(self): if self.success is None: self.success = False + # Push an update to any open Web UIs through the WebSocket + ws.Message('QueueItemUpdate', self.to_json).push() + self.finish() @@ -576,11 +630,21 @@ def __init__(self, show, segment, down_cur_quality=False): self.segment = segment self.down_cur_quality = down_cur_quality + self.to_json.update({ + 'show': self.show.to_json(), + 'segment': [ep.to_json() for ep in self.segment], + 'success': self.success, + 'downloadCurrentQuality': self.down_cur_quality + }) + def run(self): """Run failed thread.""" generic_queue.QueueItem.run(self) self.started = True + # Push an update to any open Web UIs through the WebSocket + ws.Message('QueueItemUpdate', self.to_json).push() + try: for ep_obj in self.segment: @@ -657,6 +721,9 @@ def run(self): if self.success is None: self.success = False + # Push an update to any open Web UIs through the WebSocket + ws.Message('QueueItemUpdate', self.to_json).push() + self.finish() diff --git a/medusa/server/api/v2/episode_history.py b/medusa/server/api/v2/episode_history.py index 3f3d43d2bf..82d4cf1080 100644 --- a/medusa/server/api/v2/episode_history.py +++ b/medusa/server/api/v2/episode_history.py @@ -6,8 +6,9 @@ from os.path import basename from medusa import db -from medusa.common import statusStrings +from medusa.common import DOWNLOADED, FAILED, SNATCHED, SUBTITLED, statusStrings from medusa.logger.adapters.style import BraceAdapter +from medusa.providers.generic_provider import GenericProvider from medusa.server.api.v2.base import BaseRequestHandler from medusa.server.api.v2.history import HistoryHandler from medusa.tv.episode import Episode, EpisodeNumber @@ -61,7 +62,7 @@ def get(self, series_slug, episode_slug, path_param): sql_base = """ SELECT rowid, date, action, quality, provider, version, resource, size, proper_tags, - indexer_id, showid, season, episode, manually_searched + indexer_id, showid, season, episode, manually_searched, info_hash FROM history WHERE showid = ? AND indexer_id = ? AND season = ? AND episode = ? """ @@ -74,22 +75,53 @@ def get(self, series_slug, episode_slug, path_param): def data_generator(): """Read history data and normalize key/value pairs.""" for item in results: - d = {} - d['id'] = item['rowid'] - d['series'] = SeriesIdentifier.from_id(item['indexer_id'], item['showid']).slug - d['status'] = item['action'] - d['actionDate'] = item['date'] - - d['resource'] = basename(item['resource']) - d['size'] = item['size'] - d['properTags'] = item['proper_tags'] - d['statusName'] = statusStrings.get(item['action']) - d['season'] = item['season'] - d['episode'] = item['episode'] - d['manuallySearched'] = bool(item['manually_searched']) - d['provider'] = item['provider'] - - yield d + provider = {} + release_group = None + release_name = None + file_name = None + subtitle_language = None + + if item['action'] in (SNATCHED, FAILED): + provider.update({ + 'id': GenericProvider.make_id(item['provider']), + 'name': item['provider'] + }) + release_name = item['resource'] + + if item['action'] == DOWNLOADED: + release_group = item['provider'] + file_name = item['resource'] + + if item['action'] == SUBTITLED: + subtitle_language = item['resource'] + provider.update({ + 'id': item['provider'], + 'name': item['provider'] + }) + + if item['action'] == SUBTITLED: + subtitle_language = item['resource'] + + yield { + 'id': item['rowid'], + 'series': SeriesIdentifier.from_id(item['indexer_id'], item['showid']).slug, + 'status': item['action'], + 'statusName': statusStrings.get(item['action']), + 'actionDate': item['date'], + 'quality': item['quality'], + 'resource': basename(item['resource']), + 'size': item['size'], + 'properTags': item['proper_tags'], + 'season': item['season'], + 'episode': item['episode'], + 'manuallySearched': bool(item['manually_searched']), + 'infoHash': item['info_hash'], + 'provider': provider, + 'release_name': release_name, + 'releaseGroup': release_group, + 'fileName': file_name, + 'subtitleLanguage': subtitle_language + } if not results: return self._not_found('History data not found for show {show} and episode {episode}'.format( diff --git a/medusa/server/api/v2/history.py b/medusa/server/api/v2/history.py index ffa807eec6..c04e131155 100644 --- a/medusa/server/api/v2/history.py +++ b/medusa/server/api/v2/history.py @@ -5,7 +5,8 @@ from os.path import basename from medusa import db -from medusa.common import statusStrings +from medusa.common import DOWNLOADED, FAILED, SNATCHED, SUBTITLED, statusStrings +from medusa.providers.generic_provider import GenericProvider from medusa.server.api.v2.base import BaseRequestHandler from medusa.tv.series import SeriesIdentifier @@ -30,8 +31,8 @@ def get(self, series_slug, path_param): """ sql_base = """ SELECT rowid, date, action, quality, - provider, version, proper_tags, manually_searched, - resource, size, indexer_id, showid, season, episode + provider, version, resource, size, proper_tags, + indexer_id, showid, season, episode, manually_searched, info_hash FROM history """ params = [] @@ -55,22 +56,49 @@ def data_generator(): start = arg_limit * (arg_page - 1) for item in results[start:start + arg_limit]: - d = {} - d['id'] = item['rowid'] - d['series'] = SeriesIdentifier.from_id(item['indexer_id'], item['showid']).slug - d['status'] = item['action'] - d['actionDate'] = item['date'] - - d['resource'] = basename(item['resource']) - d['size'] = item['size'] - d['properTags'] = item['proper_tags'] - d['statusName'] = statusStrings.get(item['action']) - d['season'] = item['season'] - d['episode'] = item['episode'] - d['manuallySearched'] = bool(item['manually_searched']) - d['provider'] = item['provider'] - - yield d + provider = {} + release_group = None + release_name = None + file_name = None + subtitle_language = None + + if item['action'] in (SNATCHED, FAILED): + provider.update({ + 'id': GenericProvider.make_id(item['provider']), + 'name': item['provider'] + }) + release_name = item['resource'] + + if item['action'] == DOWNLOADED: + release_group = item['provider'] + file_name = item['resource'] + + if item['action'] == SUBTITLED: + subtitle_language = item['resource'] + + if item['action'] == SUBTITLED: + subtitle_language = item['resource'] + + yield { + 'id': item['rowid'], + 'series': SeriesIdentifier.from_id(item['indexer_id'], item['showid']).slug, + 'status': item['action'], + 'statusName': statusStrings.get(item['action']), + 'actionDate': item['date'], + 'quality': item['quality'], + 'resource': basename(item['resource']), + 'size': item['size'], + 'properTags': item['proper_tags'], + 'season': item['season'], + 'episode': item['episode'], + 'manuallySearched': bool(item['manually_searched']), + 'infoHash': item['info_hash'], + 'provider': provider, + 'release_name': release_name, + 'releaseGroup': release_group, + 'fileName': file_name, + 'subtitleLanguage': subtitle_language + } if not results: return self._not_found('History data not found') diff --git a/medusa/server/api/v2/providers.py b/medusa/server/api/v2/providers.py new file mode 100644 index 0000000000..5e2b9f9ecc --- /dev/null +++ b/medusa/server/api/v2/providers.py @@ -0,0 +1,97 @@ +# coding=utf-8 +"""Request handler for series and episodes.""" +from __future__ import unicode_literals + +import logging +from datetime import datetime + +from dateutil import parser + +from medusa import providers +from medusa.logger.adapters.style import BraceAdapter +from medusa.server.api.v2.base import ( + BaseRequestHandler, +) + + +log = BraceAdapter(logging.getLogger(__name__)) +log.logger.addHandler(logging.NullHandler()) + + +class ProvidersHandler(BaseRequestHandler): + """Providers request handler.""" + + #: resource name + name = 'providers' + #: identifier + identifier = ('identifier', r'\w+') + #: path param + path_param = ('path_param', r'\w+') + #: allowed HTTP methods + allowed_methods = ('GET', 'POST', 'PATCH', 'DELETE', ) + + def get(self, identifier, path_param=None): + """ + Query provider information. + + Return a list of provider id's. + + :param identifier: provider id. E.g.: myawesomeprovider + :param path_param: + """ + show_slug = self._parse(self.get_argument('showslug', default=None), str) + season = self._parse(self.get_argument('season', default=None), str) + episode = self._parse(self.get_argument('episode', default=None), str) + + if not identifier: + # return a list of provider id's + provider_list = providers.sorted_provider_list() + return self._ok([provider.to_json() for provider in provider_list]) + + provider = providers.get_provider_class(identifier) + if not provider: + return self._not_found('Provider not found') + + if not path_param == 'results': + return self._ok(provider.to_json()) + + provider_results = provider.cache.get_results(show_slug=show_slug, season=season, episode=episode) + + arg_page = self._get_page() + arg_limit = self._get_limit(default=50) + + def data_generator(): + """Read log lines based on the specified criteria.""" + start = arg_limit * (arg_page - 1) + 1 + + for item in provider_results[start - 1:start - 1 + arg_limit]: + yield { + 'identifier': item['identifier'], + 'release': item['name'], + 'season': item['season'], + 'episodes': [int(ep) for ep in item['episodes'].strip('|').split('|') if ep != ''], + 'seasonPack': item['episodes'] == '||', + 'indexer': item['indexer'], + 'seriesId': item['indexerid'], + 'showSlug': show_slug, + 'url': item['url'], + 'time': datetime.fromtimestamp(item['time']), + 'quality': item['quality'], + 'releaseGroup': item['release_group'], + 'dateAdded': datetime.fromtimestamp(item['date_added']), + 'version': item['version'], + 'seeders': item['seeders'], + 'size': item['size'], + 'leechers': item['leechers'], + 'pubdate': parser.parse(item['pubdate']).replace(microsecond=0) if item['pubdate'] else None, + 'provider': { + 'id': provider.get_id(), + 'name': provider.name, + 'imageName': provider.image_name() + } + } + + if not len(provider_results): + return self._not_found('Provider cache results not found') + + return self._paginate(data_generator=data_generator) diff --git a/medusa/server/api/v2/series_asset.py b/medusa/server/api/v2/series_asset.py index c3da994679..35c9430630 100644 --- a/medusa/server/api/v2/series_asset.py +++ b/medusa/server/api/v2/series_asset.py @@ -38,4 +38,6 @@ def get(self, series_slug, identifier, *args, **kwargs): if not media: return self._not_found('{kind} not found'.format(kind=asset_type.capitalize())) + self.set_header('Cache-Control', 'max-age=86400') + return self._ok(stream=media, content_type=asset.media_type) diff --git a/medusa/server/core.py b/medusa/server/core.py index d1e554b563..2218805bf5 100644 --- a/medusa/server/core.py +++ b/medusa/server/core.py @@ -27,6 +27,7 @@ from medusa.server.api.v2.history import HistoryHandler from medusa.server.api.v2.internal import InternalHandler from medusa.server.api.v2.log import LogHandler +from medusa.server.api.v2.providers import ProvidersHandler 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 @@ -82,6 +83,9 @@ def get_apiv2_handlers(base): # Order: Most specific to most generic + # /api/v2/providers + ProvidersHandler.create_app_handler(base), + # /api/v2/history/tvdb1234/episode EpisodeHistoryHandler.create_app_handler(base), diff --git a/medusa/server/web/home/handler.py b/medusa/server/web/home/handler.py index 5c195cd1c1..00b793f574 100644 --- a/medusa/server/web/home/handler.py +++ b/medusa/server/web/home/handler.py @@ -5,7 +5,7 @@ import json import os import time -from datetime import date, datetime +from datetime import date from textwrap import dedent from medusa import ( @@ -39,11 +39,7 @@ cpu_presets, statusStrings, ) -from medusa.failed_history import prepare_failed_name -from medusa.helper.common import ( - enabled_providers, - pretty_file_size, -) +from medusa.helper.common import enabled_providers from medusa.helper.exceptions import ( AnidbAdbaConnectionException, CantRefreshShowException, @@ -53,9 +49,7 @@ ) from medusa.helpers.anidb import get_release_groups_for_anime from medusa.indexers.api import indexerApi -from medusa.indexers.utils import indexer_id_to_name, indexer_name_to_id -from medusa.providers.generic_provider import GenericProvider -from medusa.sbdatetime import sbdatetime +from medusa.indexers.utils import indexer_name_to_id from medusa.scene_exceptions import ( get_all_scene_exceptions, get_scene_exceptions, @@ -73,7 +67,6 @@ SEARCH_STATUS_QUEUED, SEARCH_STATUS_SEARCHING, collect_episodes_from_search_thread, - get_provider_cache_results, update_finished_search_queue_item, ) from medusa.search.queue import ( @@ -85,7 +78,6 @@ PageTemplate, WebRoot, ) -from medusa.show.history import History from medusa.show.show import Show from medusa.system.restart import Restart from medusa.system.shutdown import Shutdown @@ -723,7 +715,11 @@ def getSeasonSceneExceptions(self, indexername, seriesid): }) def displayShow(self, indexername=None, seriesid=None, ): - # @TODO: add more comprehensive show validation + """ + Render the home page. + + [Converted to VueRouter] + """ try: indexer_id = indexer_name_to_id(indexername) series_obj = Show.find_by_id(app.showList, indexer_id, seriesid) @@ -735,44 +731,24 @@ def displayShow(self, indexername=None, seriesid=None, ): t = PageTemplate(rh=self, filename='index.mako') - indexer_id = int(series_obj.indexer) - series_id = int(series_obj.series_id) - - # Delete any previous occurrances - indexer_name = indexer_id_to_name(indexer_id) - for index, recentShow in enumerate(app.SHOWS_RECENT): - if recentShow['indexerName'] == indexer_name and recentShow['showId'] == series_id: - del app.SHOWS_RECENT[index] - - # Only track 5 most recent shows - del app.SHOWS_RECENT[4:] - - # Insert most recent show - app.SHOWS_RECENT.insert(0, { - 'indexerName': indexer_name, - 'showId': series_id, - 'name': series_obj.name, - }) - return t.render( controller='home', action='displayShow', ) - def pickManualSearch(self, provider=None, rowid=None): + def pickManualSearch(self, provider=None, identifier=None): """ Tries to Perform the snatch for a manualSelected episode, episodes or season pack. @param provider: The provider id, passed as usenet_crawler and not the provider name (Usenet-Crawler) - @param rowid: The provider's cache table's rowid. (currently the implicit sqlites rowid is used, needs to be replaced in future) + @param identifier: The provider's cache table's identifier (unique). @return: A json with a {'success': true} or false. """ # Try to retrieve the cached result from the providers cache table. - # @TODO: the implicit sqlite rowid is used, should be replaced with an explicit PK column provider_obj = providers.get_provider_class(provider) try: - cached_result = Cache(provider_obj).load_from_row(rowid) + cached_result = Cache(provider_obj).load_from_row(identifier) except Exception as msg: error_message = "Couldn't read cached results. Error: {error}".format(error=msg) logger.log(error_message) @@ -900,8 +876,11 @@ def manualSearchCheckCache(self, indexername, seriesid, season=None, episode=Non def snatchSelection(self, indexername, seriesid, season=None, episode=None, manual_search_type='episode', perform_search=0, down_cur_quality=0, show_all_results=0): - """ The view with results for the manual selected show/episode """ + """ + Render the home page. + [Converted to VueRouter] + """ # @TODO: add more comprehensive show validation try: indexer_id = indexer_name_to_id(indexername) @@ -912,101 +891,9 @@ def snatchSelection(self, indexername, seriesid, season=None, episode=None, manu if series_obj is None: return self._genericMessage('Error', 'Show not in show list') - # Retrieve cache results from providers - search_show = {'series': series_obj, 'season': season, 'episode': episode, 'manual_search_type': manual_search_type} - - provider_results = get_provider_cache_results(series_obj, perform_search=perform_search, - show_all_results=show_all_results, **search_show) - - t = PageTemplate(rh=self, filename='snatchSelection.mako') - - series_obj.exceptions = get_scene_exceptions(series_obj) - - indexer_id = int(series_obj.indexer) - series_id = int(series_obj.series_id) - - # Delete any previous occurrances - indexer_name = indexer_id_to_name(indexer_id) - for index, recentShow in enumerate(app.SHOWS_RECENT): - if recentShow['indexerName'] == indexer_name and recentShow['showId'] == series_id: - del app.SHOWS_RECENT[index] - - # Only track 5 most recent shows - del app.SHOWS_RECENT[4:] - - # Insert most recent show - app.SHOWS_RECENT.insert(0, { - 'indexerName': indexer_name, - 'showId': series_id, - 'name': series_obj.name, - }) - - episode_history = [] - try: - main_db_con = db.DBConnection() - episode_status_result = main_db_con.select( - 'SELECT date, action, quality, provider, resource, size ' - 'FROM history ' - 'WHERE indexer_id = ? ' - 'AND showid = ? ' - 'AND season = ? ' - 'AND episode = ? ' - 'AND action in (?, ?, ?, ?, ?) ' - 'ORDER BY date DESC', - [indexer_id, series_id, season, episode, - DOWNLOADED, SNATCHED, SNATCHED_PROPER, SNATCHED_BEST, FAILED] - ) - episode_history = episode_status_result - for i in episode_history: - i['status'] = i['action'] - i['action_date'] = sbdatetime.sbfdatetime(datetime.strptime(text_type(i['date']), History.date_format), show_seconds=True) - i['resource_file'] = os.path.basename(i['resource']) - i['pretty_size'] = pretty_file_size(i['size']) if i['size'] > -1 else 'N/A' - i['status_name'] = statusStrings[i['status']] - provider = None - if i['status'] == DOWNLOADED: - i['status_color_style'] = 'downloaded' - elif i['status'] in (SNATCHED, SNATCHED_PROPER, SNATCHED_BEST): - i['status_color_style'] = 'snatched' - provider = providers.get_provider_class(GenericProvider.make_id(i['provider'])) - elif i['status'] == FAILED: - i['status_color_style'] = 'failed' - provider = providers.get_provider_class(GenericProvider.make_id(i['provider'])) - if provider is not None: - i['provider_name'] = provider.name - i['provider_img_link'] = 'images/providers/' + provider.image_name() - else: - i['provider_name'] = i['provider'] if i['provider'] != '-1' else 'Unknown' - i['provider_img_link'] = '' - - # Compare manual search results with history and set status - for provider_result in provider_results['found_items']: - failed_statuses = [FAILED, ] - snatched_statuses = [SNATCHED, SNATCHED_PROPER, SNATCHED_BEST] - if any([item for item in episode_history - if all([prepare_failed_name(provider_result['name']) in item['resource'], - item['provider'] in (provider_result['provider'], provider_result['release_group'],), - item['status'] in failed_statuses]) - ]): - provider_result['status_highlight'] = 'failed' - elif any([item for item in episode_history - if all([provider_result['name'] in item['resource'], - item['provider'] in provider_result['provider'], - item['status'] in snatched_statuses, - item['size'] == provider_result['size']]) - ]): - provider_result['status_highlight'] = 'snatched' - else: - provider_result['status_highlight'] = '' - - # TODO: Remove the catchall, make sure we only catch expected exceptions! - except Exception as msg: - logger.log("Couldn't read latest episode status. Error: {error}".format(error=msg)) + t = PageTemplate(rh=self, filename='index.mako') return t.render( - show=series_obj, - provider_results=provider_results, episode_history=episode_history, - season=season, episode=episode, manual_search_type=manual_search_type, controller='home', action='snatchSelection' ) diff --git a/medusa/show_queue.py b/medusa/show_queue.py index a8cc2f20fc..085ec83608 100644 --- a/medusa/show_queue.py +++ b/medusa/show_queue.py @@ -31,6 +31,7 @@ notifiers, scene_numbering, ui, + ws, ) from medusa.black_and_white_list import BlackAndWhiteList from medusa.common import WANTED, statusStrings @@ -659,6 +660,9 @@ def run(self): ) log.error(traceback.format_exc()) + # Send ws update to client + ws.Message('showAdded', self.show.to_json(detailed=False)).push() + self.finish() def _finishEarly(self): diff --git a/medusa/tv/cache.py b/medusa/tv/cache.py index 67eb545e06..c8defcdb11 100644 --- a/medusa/tv/cache.py +++ b/medusa/tv/cache.py @@ -14,7 +14,7 @@ from medusa import ( app, - db, + db ) from medusa.helper.common import episode_num from medusa.helper.exceptions import AuthException @@ -28,6 +28,7 @@ from medusa.search import FORCED_SEARCH from medusa.show import naming from medusa.show.show import Show +from medusa.tv.series import SeriesIdentifier from six import text_type, viewitems @@ -148,14 +149,14 @@ def _clear_cache(self): # trim items older than MAX_CACHE_AGE days self.trim(days=app.MAX_CACHE_AGE) - def load_from_row(self, rowid): + def load_from_row(self, identifier): """Load cached result from a single row.""" cache_db_con = self._get_db() cached_result = cache_db_con.action( 'SELECT * ' "FROM '{provider}' " - 'WHERE rowid = ?'.format(provider=self.provider_id), - [rowid], + 'WHERE identifier = ?'.format(provider=self.provider_id), + [identifier], fetchone=True ) @@ -618,3 +619,38 @@ def find_episodes(self, episodes): self.searched = time() return cache_results + + def get_results(self, show_slug=None, season=None, episode=None): + """Get cached results for this provider.""" + cache_db_con = self._get_db() + + param = [] + where = [] + + if show_slug: + show = SeriesIdentifier.from_slug(show_slug) + where += ['indexer', 'indexerid'] + param += [show.indexer.id, show.id] + + if season: + where += ['season'] + param += [season] + + if episode: + where += ['episodes'] + param += ['|{0}|'.format(episode)] + + base_sql = 'SELECT * FROM [{name}]'.format(name=self.provider_id) + base_params = [] + + if where and param: + base_sql += ' WHERE ' + base_sql += ' AND '.join([item + ' = ? ' for item in where]) + base_params += param + + results = cache_db_con.select( + base_sql, + base_params + ) + + return results diff --git a/medusa/tv/series.py b/medusa/tv/series.py index bd76acbbb2..83a907d709 100644 --- a/medusa/tv/series.py +++ b/medusa/tv/series.py @@ -2156,7 +2156,6 @@ def to_json(self, detailed=False, episodes=False): data['config']['release']['whitelist'] = bw_list.whitelist # Make sure these are at least defined - data['xemNumbering'] = [] data['sceneAbsoluteNumbering'] = [] data['xemAbsoluteNumbering'] = [] data['sceneNumbering'] = [] diff --git a/themes-default/slim/package.json b/themes-default/slim/package.json index 4365c46f85..59caee1273 100644 --- a/themes-default/slim/package.json +++ b/themes-default/slim/package.json @@ -39,9 +39,9 @@ "javascript-time-ago": "2.0.7", "jquery": "3.5.1", "lodash": "4.17.15", + "lozad": "1.15.0", "tablesorter": "2.31.3", "v-tooltip": "2.0.3", - "vanilla-lazyload": "16.1.0", "vue": "2.6.11", "vue-async-computed": "3.8.2", "vue-cookies": "1.7.0", diff --git a/themes-default/slim/src/api.js b/themes-default/slim/src/api.js index 07bb6500c7..383904d15a 100644 --- a/themes-default/slim/src/api.js +++ b/themes-default/slim/src/api.js @@ -1,6 +1,6 @@ import axios from 'axios'; -// This should be more dynamic. As now when we change the apiKey in config/general. This won't work anymore. +// This should be more dynamic. As now when we change the apiKey in config-general.vue. This won't work anymore. // Because of this, a page reload is required. export const webRoot = document.body.getAttribute('web-root'); export const apiKey = document.body.getAttribute('api-key'); diff --git a/themes-default/slim/src/app.js b/themes-default/slim/src/app.js index 8168bc6f85..da423c94dd 100644 --- a/themes-default/slim/src/app.js +++ b/themes-default/slim/src/app.js @@ -46,8 +46,8 @@ const app = new Vue({ if (isDevelopment) { console.log('App Loaded!'); } - // Legacy - send config.main to jQuery (received by index.js) - const event = new CustomEvent('medusa-config-loaded', { detail: config.main }); + // Legacy - send config.general to jQuery (received by index.js) + const event = new CustomEvent('medusa-config-loaded', { detail: { main: config.general, layout: config.layout } }); window.dispatchEvent(event); }).catch(error => { console.debug(error); diff --git a/themes-default/slim/src/components/add-show-options.vue b/themes-default/slim/src/components/add-show-options.vue index c037f66678..abefe741cc 100644 --- a/themes-default/slim/src/components/add-show-options.vue +++ b/themes-default/slim/src/components/add-show-options.vue @@ -243,10 +243,10 @@ export default { }, computed: { ...mapState({ - defaultConfig: state => state.config.showDefaults, - namingForceFolders: state => state.config.namingForceFolders, - subtitlesEnabled: state => state.config.subtitles.enabled, - episodeStatuses: state => state.consts.statuses + defaultConfig: state => state.config.general.showDefaults, + namingForceFolders: state => state.config.general.namingForceFolders, + subtitlesEnabled: state => state.config.general.subtitles.enabled, + episodeStatuses: state => state.config.consts.statuses }), ...mapGetters([ 'getStatus' diff --git a/themes-default/slim/src/components/anidb-release-group-ui.vue b/themes-default/slim/src/components/anidb-release-group-ui.vue index 994c2fa772..42018d6330 100644 --- a/themes-default/slim/src/components/anidb-release-group-ui.vue +++ b/themes-default/slim/src/components/anidb-release-group-ui.vue @@ -174,9 +174,9 @@ export default { } }, computed: { - ...mapState([ - 'layout' - ]), + ...mapState({ + layout: state => state.config.layout + }), itemsWhitelist() { return this.allReleaseGroups.filter(x => x.memberOf === 'whitelist'); }, diff --git a/themes-default/slim/src/components/app-footer.vue b/themes-default/slim/src/components/app-footer.vue index de5392e227..9b6e52cd21 100644 --- a/themes-default/slim/src/components/app-footer.vue +++ b/themes-default/slim/src/components/app-footer.vue @@ -35,11 +35,11 @@ export default { AppLink }, computed: { - ...mapState([ - 'layout', - 'stats', - 'system' - ]), + ...mapState({ + layout: state => state.config.layout, + system: state => state.config.system, + stats: state => state.stats + }), ...mapGetters([ 'getStatus', 'getScheduler' diff --git a/themes-default/slim/src/components/app-header.vue b/themes-default/slim/src/components/app-header.vue index a0d354d2ed..c9cdbd05f5 100644 --- a/themes-default/slim/src/components/app-header.vue +++ b/themes-default/slim/src/components/app-header.vue @@ -113,18 +113,16 @@ export default { AppLink }, computed: { - ...mapState([ - 'config', - 'clients', - 'notifiers', - 'postprocessing', - 'search', - 'system' - ]), ...mapState({ + config: state => state.config.general, + clients: state => state.config.clients, + notifiers: state => state.config.notifiers, + postprocessing: state => state.config.postprocessing, + search: state => state.config.search, + system: state => state.config.system, isAuthenticated: state => state.auth.isAuthenticated, username: state => state.auth.user.username, - warningLevel: state => state.config.logs.loggingLevels.warning + warningLevel: state => state.config.general.logs.loggingLevels.warning }), recentShows() { const { config } = this; diff --git a/themes-default/slim/src/components/backstretch.vue b/themes-default/slim/src/components/backstretch.vue index f5d957995c..95cdc72734 100644 --- a/themes-default/slim/src/components/backstretch.vue +++ b/themes-default/slim/src/components/backstretch.vue @@ -17,8 +17,8 @@ export default { }, computed: { ...mapState({ - enabled: state => state.layout.fanartBackground, - opacity: state => state.layout.fanartBackgroundOpacity + enabled: state => state.config.layout.fanartBackground, + opacity: state => state.config.layout.fanartBackgroundOpacity }), offset() { let offset = '90px'; diff --git a/themes-default/slim/src/components/config-general.vue b/themes-default/slim/src/components/config-general.vue index 5f7256e1a9..dfe0413382 100644 --- a/themes-default/slim/src/components/config-general.vue +++ b/themes-default/slim/src/components/config-general.vue @@ -18,12 +18,12 @@
- + open the Medusa home page on startup - when launching Medusa interface @@ -31,23 +31,23 @@

selected actions use trash (recycle bin) instead of the default permanent delete

- + - +

number of log files saved when rotating logs (default: 5) (REQUIRES RESTART)

- +

maximum size in MB of the log file (default: 1MB) (REQUIRES RESTART)

@@ -71,18 +71,18 @@
- for adding shows and metadata providers - +

with information such as next air dates, show ended, etc. Use 15 for 3pm, 4 for 4am etc.

Note: minutes are randomized each time Medusa is started

- +

seconds of inactivity when finding new shows (default:20)

@@ -94,15 +94,15 @@ - +

Plex provides a tvdb mirror, that can be utilized when Tvdb's api is unavailable.

- +

When this settings has been enabled, you may receive frequent notifications when falling back to the plex mirror.

- +

Amount of hours after we try to revert back to the thetvdb.com api url (default:3).

@@ -117,21 +117,21 @@
- +

and display notifications when updates are available. Checks are run on startup and at the frequency set below*

- +

fetch and install software updates. Updates are run on startup and in the background at the frequency set below*

- +

hours for software updates (default:1)

- +

send a message to all enabled notifiers when Medusa has been updated

@@ -213,7 +213,7 @@

Note: Use local timezone to start searching for episodes minutes after show ends (depends on your dailysearch frequency)

- + URL where the shows can be downloaded. @@ -235,55 +235,55 @@
- +

used to give 3rd party programs limited access to Medusa

you can try all the features of the legacy API (v1) here

- +

enable logs from the internal Tornado web server

- +

set blank for no login

- +

blank = no authentication

- +

web port to browse and access Medusa (default:8081)

- +

enable to be notified when a new login happens in webserver

- +

enable to be notified when a new login happens in webserver

- +

enable access to the web interface using a HTTPS address

-
- +
+

file name or path to HTTPS certificate

- +

file name or path to HTTPS key

- +

accept the following reverse proxy headers (advanced)...
(X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto)

- +

Set a base URL, for use in reverse proxies.

blank = disabled

Note: Must restart to have effect. Keep in mind that any previously configured base URLs won't work, after this change.

@@ -304,53 +304,53 @@
- Normal (default). High is lower and Low is higher CPU use - +

backlink protection via anonymizer service, must end in "?"

- +

Verify SSL Certificates (Disable this for broken SSL installs (Like QNAP))

- +

Path to an SSL CA Bundle. Will replace default bundle(certifi) with the one specified.

Note: This only apply to call made using Medusa's Requests implementation.
- +

Only shutdown when restarting Medusa. Only select this when you have external software restarting Medusa automatically when it stops (like FireDaemon)

- +

in the config.ini file. Warning: Passwords must only contain ASCII characters

- +

allow subscribing to the calendar without user and password. Some services like Google Calendar only work this way

- +

show an icon next to exported calendar events in Google Calendar.

- +

blank to disable or proxy to use when connecting to providers

- +

use proxy host for connecting to indexers (thetvdb)

- +

Skip detection of removed files. If disabled the episode will be set to the default deleted status

Note: This may mean Medusa misses renames as well @@ -358,7 +358,7 @@
- @@ -379,20 +379,20 @@
- +

Enable debug logs

- +

Enable DB debug logs

- +

enable logs from subliminal library (subtitles)

- @@ -426,29 +426,29 @@
- +
- +

You must use a personal access token if you're using "two-factor authentication" on GitHub.

-
+
- +

*** (REQUIRED FOR SUBMITTING ISSUES) ***

- +

*** (REQUIRED FOR SUBMITTING ISSUES) ***

-
+
- - + diff --git a/themes-default/slim/src/components/snatch-selection.vue b/themes-default/slim/src/components/snatch-selection.vue index 9fde46bb97..deb150f0cf 100644 --- a/themes-default/slim/src/components/snatch-selection.vue +++ b/themes-default/slim/src/components/snatch-selection.vue @@ -1,14 +1,38 @@ + + - this.$watch('show', () => { - this.$nextTick(() => this.reflowLayout()); - }); + diff --git a/themes-default/slim/src/components/status.vue b/themes-default/slim/src/components/status.vue index 2e457fa941..4c329ea36c 100644 --- a/themes-default/slim/src/components/status.vue +++ b/themes-default/slim/src/components/status.vue @@ -4,7 +4,9 @@ import { mapState } from 'vuex'; export default { name: 'status', template: '#status-template', - computed: mapState(['config']), + computed: mapState({ + config: state => state.config + }), mounted() { $('#schedulerStatusTable').tablesorter({ widgets: ['saveSort', 'zebra'], diff --git a/themes-default/slim/src/components/subtitle-search.vue b/themes-default/slim/src/components/subtitle-search.vue index 97717beca0..8e00fef106 100644 --- a/themes-default/slim/src/components/subtitle-search.vue +++ b/themes-default/slim/src/components/subtitle-search.vue @@ -1,7 +1,7 @@