Commit 0e0ec76a authored by echel0n's avatar echel0n

Added static file handlers for new Web-UI to web server

Split API endpoints into v1 and v2
Refactored allowed web methods to include PUT, DELETE, and OPTIONS
Refactored allowed web headers to include content-type and authorization
Added new series API v2 endpoints
Refactored TVShow airs_next, airs_prev, episodes_unaired, episodes_snatched, episodes_downloaded, and episodes_special methods
Added new TVShow method epsiodes_total to speed-up main show page load times
Added marshmallow to de-serialize SQLAlchemy data for sending back to UI frontend
Added config API v2 endpoint
Converted statusStrings from UserDict to built-in dict without composite splitting
Added IMDbInfo Schema to output json to frontend
Added API v2 Authorization header handling to connect frontend to backend securely
Added API v2 patch method for updating series data
Added API v2 JSON error handler
Refactored how web async calls are done
Added allowed and preferred qualities props to show object
Added poster and banner props to show object
Added to_json method to both show and episode objects
Refactored show object get_overview function into episode object overview property
Added episode manual search API v2 endpoint
Added series episodes rename API v2 endpoint
Refactored languages API endpoint for TheTVDB API client
Refactored indexerAPI indexers function to return list of indexers
Added file browser API v2 endpoint
Updated requirements.txt to work with Python 3.9.x
SR Auth certs are now grabbed and stored to avoid rate-limit issues
Added series API v2 endpoint to add new shows
Added retrieve series metadata API v2 endpoint
Fixed parsing seeders/leechers for IPTorrents
Updated URL for gktorrents
parent 7c75facc
......@@ -14,7 +14,7 @@ chardet==3.0.4
click==7.1.2
cloudscraper==1.2.46
configobj==5.0.6
cryptography==3.0
cryptography==3.2.1
decorator==4.4.2
deluge-client==1.9.0
dirsync==2.2.5
......@@ -22,7 +22,7 @@ dogpile.cache==1.0.2
ecdsa==0.14.1
enzyme==0.4.1
fake-useragent==0.1.11
feedparser==5.2.1
feedparser==6.0.2
future==0.18.2
gntp==1.0.3
guessit==3.1.1
......@@ -34,10 +34,12 @@ importlib-metadata==1.7.0
ipaddress==1.0.23
knowit==0.2.4
lockfile==0.12.2
lxml==4.5.2
lxml==4.6.1
Mako==1.1.3
markdown2==2.3.9
MarkupSafe==1.1.1
marshmallow==3.8.0
marshmallow-sqlalchemy==0.23.1
msgpack==1.0.0
MultipartPostHandler==0.1.0
mutagen==1.45.1
......
......@@ -175,9 +175,9 @@ def isVirtualEnv():
def check_requirements():
# sickrage requires python 3.5+
if sys.version_info < (3, 5, 0):
sys.exit("Sorry, SiCKRAGE requires Python 3.5+")
# sickrage requires python 3.6+
if sys.version_info < (3, 6, 0):
sys.exit("Sorry, SiCKRAGE requires Python 3.6+")
# if os.path.exists(REQS_FILE):
# with open(REQS_FILE) as f:
......
......@@ -155,8 +155,8 @@ class API(object):
client = OAuth2Session(sickrage.app.auth_server.client_id, token=self.token)
self.token = client.refresh_token(self.token_url, **extra)
def exchange_token(self, token, scope='offline_access'):
exchange = {'scope': scope, 'subject_token': token['access_token']}
def exchange_token(self, access_token, scope='offline_access'):
exchange = {'scope': scope, 'subject_token': access_token}
exchanged_token = sickrage.app.auth_server.token_exchange(**exchange)
if exchanged_token:
self.token = exchanged_token
......@@ -383,7 +383,7 @@ class API(object):
def __init__(self, api):
self.api = api
def get(self):
def get(self, *args, **kwargs):
query = 'scene-exceptions'
return self.api.request('GET', query)
......
......@@ -36,6 +36,7 @@ class AuthServer(object):
self.server_realm = 'sickrage'
self.client_id = 'sickrage-app'
self.client_secret = '5d4710b2-ca70-4d39-b5a3-0705e2c5e703'
self._certs = None
@property
def client(self):
......@@ -60,20 +61,16 @@ class AuthServer(object):
return True
def get_url(self, *args, **kwargs):
if not self.health:
return
try:
return self.client.get_url(*args, **kwargs)
except requests.exceptions.ConnectionError as e:
return
def certs(self):
if not self.health:
return
try:
return self.client.certs()
if not self._certs and self.health:
self._certs = self.client.certs()
return self._certs
except requests.exceptions.ConnectionError as e:
return
......@@ -84,9 +81,6 @@ class AuthServer(object):
return self.client.logout(*args, **kwargs)
def decode_token(self, *args, **kwargs):
if not self.health:
return
return self.client.decode_token(*args, **kwargs)
def refresh_token(self, *args, **kwargs):
......
......@@ -177,7 +177,7 @@ class ErrorViewer(object):
def clear(self):
self.errors.clear()
def get(self):
def get(self, *args, **kwargs):
return self.errors
def count(self):
......@@ -199,7 +199,7 @@ class WarningViewer(object):
def clear(self):
self.warnings.clear()
def get(self):
def get(self, *args, **kwargs):
return self.warnings
def count(self):
......
......@@ -21,7 +21,6 @@
import operator
import pathlib
import re
from collections import UserDict
from functools import reduce
from sickrage.core.helpers.metadata import get_file_metadata, get_resolution
......@@ -75,6 +74,62 @@ multiEpStrings = {NAMING_REPEAT: _("Repeat"),
NAMING_LIMITED_EXTEND_E_PREFIXED: _("Extend (Limited, E-prefixed)")}
class StatusStrings(dict):
def __setitem__(self, key, value):
super(StatusStrings, self).__setitem__(int(key), value)
def __missing__(self, key):
"""
If the key is not found, search for the missing key in qualities
Keys must be convertible to int or a ValueError will be raised. This is intentional to match old functionality until
the old StatusStrings is fully deprecated, then we will raise a KeyError instead, where appropriate.
"""
qualities = Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER + Quality.SNATCHED_BEST + Quality.ARCHIVED + Quality.FAILED
if isinstance(key, int): # if the key is already an int...
if key in list(self.keys()) + qualities:
status, quality = Quality.split_composite_status(key)
if not quality: # If a Quality is not listed...
return self[status] # ...return the status...
return self[status] + " (" + Quality.qualityStrings[quality] + ")" # ...otherwise append the quality to the status
return '' # return '' to match old functionality when the numeric key is not found
return self[int(key)]
def __contains__(self, key):
"""
Checks for existence of key
Unlike __missing__() this will NOT raise a ValueError to match expected functionality
when checking for 'key in dict'
"""
qualities = Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER + Quality.SNATCHED_BEST + Quality.ARCHIVED + Quality.FAILED
try:
# This will raise a ValueError if we can't convert the key to int
return int(key) in self or int(key) in qualities
except ValueError: # The key is not numeric and since we only want numeric keys...
# ...and we don't want this function to fail...
pass # ...suppress the ValueError and do nothing, the key does not exist
statusStrings = StatusStrings({UNKNOWN: _("Unknown"),
UNAIRED: _("Unaired"),
SNATCHED: _("Snatched"),
SNATCHED_PROPER: _("Snatched (Proper)"),
SNATCHED_BEST: _("Snatched (Best)"),
DOWNLOADED: _("Downloaded"),
SKIPPED: _("Skipped"),
WANTED: _("Wanted"),
ARCHIVED: _("Archived"),
IGNORED: _("Ignored"),
SUBTITLED: _("Subtitled"),
FAILED: _("Failed"),
MISSED: _("Missed")})
class SearchFormats(object):
STANDARD = 1
AIR_BY_DATE = 2
......@@ -559,92 +614,6 @@ qualityPresetStrings = {SD: "SD",
ANY_PLUS_UNKNOWN: "Any + Unknown"}
class StatusStrings(UserDict):
"""
Dictionary containing strings for status codes
Keys must be convertible to int or a ValueError will be raised. This is intentional to match old functionality until
the old StatusStrings is fully deprecated, then we will raise a KeyError instead, where appropriate.
Membership checks using __contains__ (i.e. 'x in y') do not raise a ValueError to match expected dict functionality
"""
# todo: Deprecate StatusStrings().status_strings and use StatusStrings() directly
# todo: Deprecate .has_key and switch to 'x in y'
# todo: Switch from raising ValueError to a saner KeyError
# todo: Raise KeyError when unable to resolve a missing key instead of returning ''
# todo: Make key of None match dict() functionality
@property
def status_strings(self): # for backwards compatibility
return self.data
def __setitem__(self, key, value):
self.data[int(key)] = value # make sure all keys being assigned values are ints
def __missing__(self, key):
"""
If the key is not found, search for the missing key in qualities
Keys must be convertible to int or a ValueError will be raised. This is intentional to match old functionality until
the old StatusStrings is fully deprecated, then we will raise a KeyError instead, where appropriate.
"""
if isinstance(key, int): # if the key is already an int...
if key in list(
self.keys()) + Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER + Quality.SNATCHED_BEST + Quality.ARCHIVED + Quality.FAILED:
status, quality = Quality.split_composite_status(key)
if quality == Quality.NONE: # If a Quality is not listed... (shouldn't this be 'if not quality:'?)
return self[status] # ...return the status...
else:
return self[status] + " (" + Quality.qualityStrings[
quality] + ")" # ...otherwise append the quality to the status
else:
return '' # return '' to match old functionality when the numeric key is not found
return self[int(key)] # Since the key was not an int, let's try int(key) instead
# Keep this until all has_key() checks are converted to 'key in dict'
# or else has_keys() won't search __missing__ for keys
def has_key(self, key):
"""
Override has_key() to test membership using an 'x in y' search
Keys must be convertible to int or a ValueError will be raised. This is intentional to match old functionality until
the old StatusStrings is fully deprecated, then we will raise a KeyError instead, where appropriate.
"""
return key in self # This will raise a ValueError if __missing__ can't convert the key to int
def __contains__(self, key):
"""
Checks for existence of key
Unlike has_key() and __missing__() this will NOT raise a ValueError to match expected functionality
when checking for 'key in dict'
"""
try:
# This will raise a ValueError if we can't convert the key to int
return ((int(key) in self.data) or
(int(
key) in Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER + Quality.SNATCHED_BEST + Quality.ARCHIVED + Quality.FAILED))
except ValueError: # The key is not numeric and since we only want numeric keys...
# ...and we don't want this function to fail...
pass # ...suppress the ValueError and do nothing, the key does not exist
statusStrings = StatusStrings({UNKNOWN: _("Unknown"),
UNAIRED: _("Unaired"),
SNATCHED: _("Snatched"),
SNATCHED_PROPER: _("Snatched (Proper)"),
SNATCHED_BEST: _("Snatched (Best)"),
DOWNLOADED: _("Downloaded"),
SKIPPED: _("Skipped"),
WANTED: _("Wanted"),
ARCHIVED: _("Archived"),
IGNORED: _("Ignored"),
SUBTITLED: _("Subtitled"),
FAILED: _("Failed"),
MISSED: _("Missed")})
class Overview(object):
UNAIRED = UNAIRED # 1
SNATCHED = SNATCHED # 2
......
This diff is collapsed.
......@@ -17,6 +17,7 @@
# along with SiCKRAGE. If not, see <http://www.gnu.org/licenses/>.
import datetime
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
from sqlalchemy import Column, Integer, Text, ForeignKeyConstraint, String, DateTime, Boolean, Index, Date, BigInteger, func, literal_column
from sqlalchemy.ext.declarative import as_declarative
from sqlalchemy.orm import relationship
......@@ -63,28 +64,42 @@ class MainDB(SRDatabase):
def remove_duplicate_episodes():
session = self.session()
# count by show ID
# count by season/episode
duplicates = session.query(
self.TVEpisode.showid,
self.TVEpisode.season,
self.TVEpisode.episode,
func.count(self.TVEpisode.showid).label('count')
func.count(self.TVEpisode.indexer_id).label('count')
).group_by(
self.TVEpisode.showid,
self.TVEpisode.season,
self.TVEpisode.episode
self.TVEpisode.episode,
).having(literal_column('count') > 1).all()
for cur_duplicate in duplicates:
sickrage.app.log.debug("Duplicate episode detected! "
"showid: {dupe_id} "
"season: {dupe_season} "
"episode {dupe_episode} count: {dupe_count}".format(dupe_id=cur_duplicate.showid,
dupe_season=cur_duplicate.season,
dupe_episode=cur_duplicate.episode,
dupe_count=cur_duplicate.count))
for result in session.query(self.TVEpisode).filter_by(showid=cur_duplicate.showid,
season=cur_duplicate.season,
episode=cur_duplicate.episode).limit(cur_duplicate.count - 1):
session.query(self.TVEpisode).filter_by(showid=result.showid, season=result.season, episode=result.episode).delete()
session.commit()
# count by indexer ID
duplicates += session.query(
duplicates = session.query(
self.TVEpisode.showid,
self.TVEpisode.season,
self.TVEpisode.episode,
func.count(self.TVEpisode.indexer_id).label('count')
).group_by(
self.TVEpisode.showid,
self.TVEpisode.season,
self.TVEpisode.episode
self.TVEpisode.indexer_id
).having(literal_column('count') > 1).all()
for cur_duplicate in duplicates:
......@@ -97,9 +112,8 @@ class MainDB(SRDatabase):
dupe_count=cur_duplicate.count))
for result in session.query(self.TVEpisode).filter_by(showid=cur_duplicate.showid,
season=cur_duplicate.season,
episode=cur_duplicate.episode).limit(cur_duplicate.count - 1):
session.query(self.TVEpisode).filter_by(indexer_id=result.indexer_id).delete()
indexer_id=cur_duplicate.indexer_id).limit(cur_duplicate.count - 1):
session.query(self.TVEpisode).filter_by(showi=result.showid, indexer_id=result.indexer_id).delete()
session.commit()
def fix_duplicate_episode_scene_numbering():
......@@ -158,7 +172,7 @@ class MainDB(SRDatabase):
dupe_count=cur_duplicate.count))
for result in session.query(self.TVEpisode).filter_by(showid=cur_duplicate.showid,
scene_absolute_number=cur_duplicate.scene_absolute_number).\
scene_absolute_number=cur_duplicate.scene_absolute_number). \
limit(cur_duplicate.count - 1):
result.scene_absolute_number = -1
session.commit()
......@@ -392,3 +406,38 @@ class MainDB(SRDatabase):
release = Column(Text, nullable=False)
size = Column(Integer, nullable=False)
provider = Column(Text, nullable=False)
class TVShowSchema(SQLAlchemyAutoSchema):
class Meta:
model = MainDB.TVShow
include_relationships = False
load_instance = True
class TVEpisodeSchema(SQLAlchemyAutoSchema):
class Meta:
model = MainDB.TVEpisode
include_relationships = False
load_instance = True
class IMDbInfoSchema(SQLAlchemyAutoSchema):
class Meta:
model = MainDB.IMDbInfo
include_relationships = False
load_instance = True
class BlacklistSchema(SQLAlchemyAutoSchema):
class Meta:
model = MainDB.Blacklist
include_relationships = False
load_instance = True
class WhitelistSchema(SQLAlchemyAutoSchema):
class Meta:
model = MainDB.Whitelist
include_relationships = False
load_instance = True
......@@ -25,7 +25,7 @@ from operator import itemgetter
import sickrage
def getWinDrives():
def get_win_drives():
""" Return list of detected drives """
assert os.name == 'nt'
from ctypes import windll
......@@ -115,7 +115,7 @@ def foldersAtPath(path, includeParent=False, includeFiles=False, fileTypes=None)
if path == '':
if os.name == 'nt':
entries = [{'currentPath': 'Root'}]
for letter in getWinDrives():
for letter in get_win_drives():
letter_path = letter + ':\\'
entries.append({'name': letter_path, 'path': letter_path})
......
......@@ -64,7 +64,7 @@ class NameParser(object):
def indexer_lookup(term):
for indexer in IndexerApi().indexers:
result = IndexerApi(indexer).search_for_show_id(term)
result = IndexerApi(indexer['id']).search_for_show_id(term)
if result:
return result
......
......@@ -144,7 +144,7 @@ class Queue(object):
self.auto_remove_tasks_timer.setName(self.name)
self.auto_remove_tasks_timer.start()
def get(self):
def get(self, *args, **kwargs):
def queue_sorter(x, y):
"""
Sorts by priority descending then time ascending
......@@ -330,6 +330,7 @@ class Worker(object):
self.task.status = TaskStatus.STARTED
self.task.result = self.task.run()
self.task.status = TaskStatus.FINISHED
self.task.finish()
except Exception as e:
if self.task is not None:
self.task.status = TaskStatus.FAILED
......@@ -367,12 +368,21 @@ class Task(object):
self.depend = depend
self.auto_remove = True
def run(self):
pass
def finish(self):
pass
def is_finished(self):
return self.status == TaskStatus.FINISHED
def is_started(self):
return self.status == TaskStatus.STARTED
def is_queued(self):
return self.status == TaskStatus.QUEUED
def is_failed(self):
return self.status == TaskStatus.FAILED
......
......@@ -29,6 +29,7 @@ from sickrage.core.queues import Queue, Task, TaskPriority
from sickrage.core.search import search_providers, snatch_episode
from sickrage.core.tv.show.helpers import find_show
from sickrage.core.tv.show.history import FailedHistory, History
from sickrage.core.websocket import WebSocketMessage
class SearchTaskActions(Enum):
......@@ -138,12 +139,19 @@ class DailySearchTask(Task):
def run(self):
self.started = True
show_obj = find_show(self.show_id)
if not show_obj:
show_object = find_show(self.show_id)
if not show_object:
return
episode_object = show_object.get_episode(self.season, self.episode)
try:
sickrage.app.log.info("Starting daily search for: [" + show_obj.name + "]")
sickrage.app.log.info("Starting daily search for: [" + show_object.name + "]")
WebSocketMessage('SEARCH_QUEUE_STATUS_UPDATED',
{'series_id': show_object.indexer_id,
'episode_id': episode_object.indexer_id,
'search_queue_status': episode_object.search_queue_status}).push()
search_result = search_providers(self.show_id, self.season, self.episode, cacheOnly=sickrage.app.config.enable_rss_cache)
if search_result:
......@@ -157,11 +165,16 @@ class DailySearchTask(Task):
sickrage.app.log.info("Downloading " + search_result.name + " from " + search_result.provider.name)
snatch_episode(search_result)
else:
sickrage.app.log.info("Unable to find search results for: [" + show_obj.name + "]")
sickrage.app.log.info("Unable to find search results for: [" + show_object.name + "]")
except Exception:
sickrage.app.log.debug(traceback.format_exc())
finally:
sickrage.app.log.info("Finished daily search for: [" + show_obj.name + "]")
WebSocketMessage('SEARCH_QUEUE_STATUS_UPDATED',
{'series_id': show_object.indexer_id,
'episode_id': episode_object.indexer_id,
'search_queue_status': episode_object.search_queue_status}).push()
sickrage.app.log.info("Finished daily search for: [" + show_object.name + "]")
class ManualSearchTask(Task):
......@@ -175,7 +188,7 @@ class ManualSearchTask(Task):
self.success = False
self.priority = TaskPriority.EXTREME
self.downCurQuality = downCurQuality
self.auto_remove = False
# self.auto_remove = False
def run(self):
self.started = True
......@@ -186,6 +199,11 @@ class ManualSearchTask(Task):
episode_object = show_object.get_episode(self.season, self.episode)
WebSocketMessage('SEARCH_QUEUE_STATUS_UPDATED',
{'series_id': show_object.indexer_id,
'episode_id': episode_object.indexer_id,
'search_queue_status': episode_object.search_queue_status}).push()
try:
sickrage.app.log.info("Starting manual search for: [" + episode_object.pretty_name() + "]")
......@@ -196,6 +214,11 @@ class ManualSearchTask(Task):
sickrage.app.log.info("Downloading " + search_result.name + " from " + search_result.provider.name)
self.success = snatch_episode(search_result)
WebSocketMessage('EPISODE_UPDATED',
{'series_id': show_object.indexer_id,
'episode_id': episode_object.indexer_id,
'episode': episode_object.to_json()}).push()
else:
sickrage.app.alerts.message(
_('No downloads were found'),
......@@ -208,6 +231,14 @@ class ManualSearchTask(Task):
finally:
sickrage.app.log.info("Finished manual search for: [" + episode_object.pretty_name() + "]")
def finish(self):
show_object = find_show(self.show_id)
episode_object = show_object.get_episode(self.season, self.episode)
WebSocketMessage('SEARCH_QUEUE_STATUS_UPDATED',
{'series_id': show_object.indexer_id,
'episode_id': episode_object.indexer_id,
'search_queue_status': episode_object.search_queue_status}).push()
class BacklogSearchTask(Task):
def __init__(self, show_id, season, episode):
......@@ -227,9 +258,16 @@ class BacklogSearchTask(Task):
if not show_object:
return
episode_object = show_object.get_episode(self.season, self.episode)
try:
sickrage.app.log.info("Starting backlog search for: [{}] S{:02d}E{:02d}".format(show_object.name, self.season, self.episode))
WebSocketMessage('SEARCH_QUEUE_STATUS_UPDATED',
{'series_id': show_object.indexer_id,
'episode_id': episode_object.indexer_id,
'search_queue_status': episode_object.search_queue_status}).push()
search_result = search_providers(self.show_id, self.season