mirror of
https://github.com/SickGear/SickGear.git
synced 2024-12-01 00:43:37 +00:00
8d9406d5fc
Add episode watched state system that integrates with Kodi, Plex, and/or Emby, instructions at Shows/History/Layout/"Watched". Add installable SickGear Kodi repository containing addon "SickGear Watched State Updater". Change add Emby setting for watched state scheduler at Config/Notifications/Emby/"Update watched interval". Change add Plex setting for watched state scheduler at Config/Notifications/Plex/"Update watched interval". Add API cmd=sg.updatewatchedstate, instructions for use are linked to in layout "Watched" at /history. Change history page table filter input values are saved across page refreshes. Change history page table filter inputs, accept values like "dvd or web" to only display both. Change history page table filter inputs, press 'ESC' key inside a filter input to reset it. Add provider activity stats to Shows/History/Layout/ drop down. Change move provider failures table from Manage/Media Search to Shows/History/Layout/Provider fails. Change sort provider failures by most recent failure, and with paused providers at the top. Change remove table form non-testing version 20007, that was reassigned.
423 lines
17 KiB
Python
423 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# This file is part of SickGear.
|
|
#
|
|
# SickGear is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# SickGear is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with SickGear. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
from time import sleep
|
|
|
|
import datetime
|
|
import math
|
|
import os
|
|
import platform
|
|
import re
|
|
import sys
|
|
|
|
try:
|
|
from urllib import urlencode # Python2
|
|
except ImportError:
|
|
import urllib
|
|
from urllib.parse import urlencode # Python3
|
|
|
|
try:
|
|
import urllib.request as urllib2
|
|
except ImportError:
|
|
import urllib2
|
|
|
|
from sickbeard import logger
|
|
from sickbeard.helpers import getURL, tryInt
|
|
|
|
try:
|
|
from lxml import etree
|
|
except ImportError:
|
|
try:
|
|
import xml.etree.cElementTree as etree
|
|
except ImportError:
|
|
import xml.etree.ElementTree as etree
|
|
|
|
|
|
class Plex:
|
|
def __init__(self, settings=None):
|
|
|
|
settings = settings or {}
|
|
self._plex_host = settings.get('plex_host') or '127.0.0.1'
|
|
self.plex_port = settings.get('plex_port') or '32400'
|
|
|
|
self.username = settings.get('username', '')
|
|
self.password = settings.get('password', '')
|
|
self.token = settings.get('token', '')
|
|
|
|
self.device_name = settings.get('device_name', '')
|
|
self.client_id = settings.get('client_id') or '5369636B47656172'
|
|
self.machine_client_identifier = ''
|
|
|
|
self.default_home_users = settings.get('default_home_users', '')
|
|
|
|
# Progress percentage to consider video as watched
|
|
# if set to anything > 0, videos with watch progress greater than this will be considered watched
|
|
self.default_progress_as_watched = settings.get('default_progress_as_watched', 0)
|
|
|
|
# Sections to scan. If empty all sections will be looked at,
|
|
# the section id should be used which is the number found be in the url on PlexWeb after /section/[ID]
|
|
self.section_list = settings.get('section_list', [])
|
|
|
|
# Sections to skip scanning, for use when Settings['section_list'] is not specified,
|
|
# the same as section_list, the section id should be used
|
|
self.ignore_sections = settings.get('ignore_sections', [])
|
|
|
|
# Filter sections by paths that are in this array
|
|
self.section_filter_path = settings.get('section_filter_path', [])
|
|
|
|
# Results
|
|
self.show_states = {}
|
|
self.file_count = 0
|
|
|
|
# Conf
|
|
self.config_version = 2.0
|
|
self.use_logger = False
|
|
self.test = None
|
|
self.home_user_tokens = {}
|
|
|
|
if self.username and '' == self.token:
|
|
self.token = self.get_token(self.username, self.password)
|
|
|
|
@property
|
|
def plex_host(self):
|
|
|
|
if not self._plex_host.startswith('http'):
|
|
return 'http://%s' % self.plex_host
|
|
return self._plex_host
|
|
|
|
@plex_host.setter
|
|
def plex_host(self, value):
|
|
|
|
self._plex_host = value
|
|
|
|
def log(self, msg, debug=True):
|
|
|
|
try:
|
|
if self.use_logger:
|
|
msg = 'Plex:: ' + msg
|
|
if debug:
|
|
logger.log(msg, logger.DEBUG)
|
|
else:
|
|
logger.log(msg)
|
|
# else:
|
|
# print(msg.encode('ascii', 'replace').decode())
|
|
except (StandardError, Exception):
|
|
pass
|
|
|
|
def get_token(self, user, passw):
|
|
|
|
auth = ''
|
|
try:
|
|
auth = getURL('https://plex.tv/users/sign_in.json',
|
|
headers={'X-Plex-Device-Name': 'SickGear',
|
|
'X-Plex-Platform': platform.system(), 'X-Plex-Device': platform.system(),
|
|
'X-Plex-Platform-Version': platform.release(),
|
|
'X-Plex-Provides': 'Python', 'X-Plex-Product': 'Python',
|
|
'X-Plex-Client-Identifier': self.client_id,
|
|
'X-Plex-Version': str(self.config_version),
|
|
'X-Plex-Username': user
|
|
},
|
|
json=True,
|
|
data=urlencode({b'user[login]': user, b'user[password]': passw}).encode('utf-8')
|
|
)['user']['authentication_token']
|
|
except IndexError:
|
|
self.log('Error getting Plex Token')
|
|
|
|
return auth
|
|
|
|
def get_access_token(self, token):
|
|
|
|
resources = self.get_url_x('https://plex.tv/api/resources?includeHttps=1', token=token)
|
|
if None is resources:
|
|
return ''
|
|
|
|
devices = resources.findall('Device')
|
|
for device in devices:
|
|
if 1 == len(devices) \
|
|
or self.machine_client_identifier == device.get('clientIdentifier') \
|
|
or (self.device_name
|
|
and (self.device_name.lower() in device.get('name').lower()
|
|
or self.device_name.lower() in device.get('clientIdentifier').lower())
|
|
):
|
|
access_token = device.get('accessToken')
|
|
if not access_token:
|
|
return ''
|
|
return access_token
|
|
|
|
connections = device.findall('Connection')
|
|
for connection in connections:
|
|
if self.plex_host == connection.get('address'):
|
|
access_token = device.get('accessToken')
|
|
if not access_token:
|
|
return ''
|
|
uri = connection.get('uri')
|
|
match = re.compile('(http[s]?://.*?):(\d*)').match(uri)
|
|
if match:
|
|
self.plex_host = match.group(1)
|
|
self.plex_port = match.group(2)
|
|
return access_token
|
|
return ''
|
|
|
|
def get_plex_home_user_tokens(self):
|
|
|
|
user_tokens = {}
|
|
|
|
# check Plex is contactable
|
|
home_users = self.get_url_x('https://plex.tv/api/home/users')
|
|
if None is not home_users:
|
|
for user in home_users.findall('User'):
|
|
user_id = user.get('id')
|
|
# use empty byte data to force POST
|
|
switch_page = self.get_url_x('https://plex.tv/api/home/users/%s/switch' % user_id, data=b'')
|
|
if None is not switch_page:
|
|
home_token = 'user' == switch_page.tag and switch_page.get('authenticationToken')
|
|
if home_token:
|
|
username = switch_page.get('title')
|
|
user_tokens[username] = self.get_access_token(home_token)
|
|
return user_tokens
|
|
|
|
def get_url_x(self, url, token=None, **kwargs):
|
|
|
|
if not token:
|
|
token = self.token
|
|
if not url.startswith('http'):
|
|
url = 'http://' + url
|
|
|
|
for x in range(0, 3):
|
|
if 0 < x:
|
|
sleep(0.5)
|
|
try:
|
|
headers = {'X-Plex-Device-Name': 'SickGear',
|
|
'X-Plex-Platform': platform.system(), 'X-Plex-Device': platform.system(),
|
|
'X-Plex-Platform-Version': platform.release(),
|
|
'X-Plex-Provides': 'controller', 'X-Plex-Product': 'Python',
|
|
'X-Plex-Client-Identifier': self.client_id,
|
|
'X-Plex-Version': str(self.config_version),
|
|
'X-Plex-Token': token,
|
|
'Accept': 'application/xml'
|
|
}
|
|
if self.username:
|
|
headers.update({'X-Plex-Username': self.username})
|
|
page = getURL(url, headers=headers, **kwargs)
|
|
if page:
|
|
parsed = etree.fromstring(page)
|
|
if None is not parsed and len(parsed):
|
|
return parsed
|
|
return None
|
|
|
|
except Exception as e:
|
|
self.log('Error requesting page: %s' % e)
|
|
continue
|
|
return None
|
|
|
|
# uses the Plex API to delete files instead of system functions, useful for remote installations
|
|
def delete_file(self, media_id=0):
|
|
|
|
try:
|
|
endpoint = ('/library/metadata/%s' % str(media_id))
|
|
req = urllib2.Request('%s:%s%s' % (self.plex_host, self.plex_port, endpoint),
|
|
None, {'X-Plex-Token': self.token})
|
|
req.get_method = lambda: 'DELETE'
|
|
urllib2.urlopen(req)
|
|
except (StandardError, Exception):
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def get_media_info(video_node):
|
|
|
|
progress = 0
|
|
if None is not video_node.get('viewOffset') and None is not video_node.get('duration'):
|
|
progress = tryInt(video_node.get('viewOffset')) * 100 / tryInt(video_node.get('duration'))
|
|
|
|
for media in video_node.findall('Media'):
|
|
for part in media.findall('Part'):
|
|
file_name = part.get('file')
|
|
# if '3' > sys.version: # remove HTML quoted characters, only works in python < 3
|
|
# file_name = urllib2.unquote(file_name.encode('utf-8', errors='replace'))
|
|
# else:
|
|
file_name = urllib2.unquote(file_name)
|
|
|
|
return {'path_file': file_name, 'media_id': video_node.get('ratingKey'),
|
|
'played': int(video_node.get('viewCount') or 0), 'progress': progress}
|
|
|
|
def check_users_watched(self, users, media_id):
|
|
|
|
if not self.home_user_tokens:
|
|
self.home_user_tokens = self.get_plex_home_user_tokens()
|
|
|
|
result = {}
|
|
if 'all' in users:
|
|
users = self.home_user_tokens.keys()
|
|
|
|
for user in users:
|
|
user_media_page = self.get_url_pms('/library/metadata/%s' % media_id, token=self.home_user_tokens[user])
|
|
if None is not user_media_page:
|
|
video_node = user_media_page.find('Video')
|
|
|
|
progress = 0
|
|
if None is not video_node.get('viewOffset') and None is not video_node.get('duration'):
|
|
progress = tryInt(video_node.get('viewOffset')) * 100 / tryInt(video_node.get('duration'))
|
|
|
|
played = int(video_node.get('viewCount') or 0)
|
|
if not progress and not played:
|
|
continue
|
|
|
|
date_watched = 0
|
|
if (0 < tryInt(video_node.get('viewCount'))) or (0 < self.default_progress_as_watched < progress):
|
|
last_viewed_at = video_node.get('lastViewedAt')
|
|
if last_viewed_at and last_viewed_at not in ('', '0'):
|
|
date_watched = last_viewed_at
|
|
|
|
if date_watched:
|
|
result[user] = dict(played=played, progress=progress, date_watched=date_watched)
|
|
else:
|
|
self.log('Do not have the token for %s.' % user)
|
|
|
|
return result
|
|
|
|
def get_url_pms(self, endpoint=None, **kwargs):
|
|
|
|
return endpoint and self.get_url_x(
|
|
'%s:%s%s' % (self.plex_host, self.plex_port, endpoint), **kwargs)
|
|
|
|
# parse episode information from season pages
|
|
def stat_show(self, node):
|
|
|
|
episodes = []
|
|
if 'directory' == node.tag.lower() and 'show' == node.get('type'):
|
|
show = self.get_url_pms(node.get('key'))
|
|
if None is show: # Check if show page is None or empty
|
|
self.log('Failed to load show page. Skipping...')
|
|
return None
|
|
|
|
for season_node in show.findall('Directory'): # Each directory is a season
|
|
if 'season' != season_node.get('type'): # skips Specials
|
|
continue
|
|
|
|
season_key = season_node.get('key')
|
|
season = self.get_url_pms(season_key)
|
|
if None is not season:
|
|
episodes += [season]
|
|
|
|
elif 'mediacontainer' == node.tag.lower() and 'episode' == node.get('viewGroup'):
|
|
episodes = [node]
|
|
|
|
check_users = []
|
|
if self.default_home_users:
|
|
check_users = self.default_home_users.strip(' ,').lower().split(',')
|
|
for k in range(0, len(check_users)): # Remove extra spaces and commas
|
|
check_users[k] = check_users[k].strip(', ')
|
|
|
|
for episode_node in episodes:
|
|
for video_node in episode_node.findall('Video'):
|
|
|
|
media_info = self.get_media_info(video_node)
|
|
|
|
if check_users:
|
|
user_info = self.check_users_watched(check_users, media_info['media_id'])
|
|
for user_name, user_media_info in user_info.items():
|
|
self.show_states.update({len(self.show_states): dict(
|
|
path_file=media_info['path_file'],
|
|
media_id=media_info['media_id'],
|
|
played=(100 * user_media_info['played']) or user_media_info['progress'] or 0,
|
|
label=user_name,
|
|
date_watched=user_media_info['date_watched'])})
|
|
else:
|
|
self.show_states.update({len(self.show_states): dict(
|
|
path_file=media_info['path_file'],
|
|
media_id=media_info['media_id'],
|
|
played=(100 * media_info['played']) or media_info['progress'] or 0,
|
|
label=self.username,
|
|
date_watched=video_node.get('lastViewedAt'))})
|
|
|
|
self.file_count += 1
|
|
|
|
return True
|
|
|
|
def fetch_show_states(self, fetch_all=False):
|
|
|
|
error_log = []
|
|
self.show_states = {}
|
|
|
|
server_check = self.get_url_pms('/')
|
|
if None is server_check or 'MediaContainer' != server_check.tag:
|
|
error_log.append('Cannot reach server!')
|
|
|
|
else:
|
|
if not self.device_name:
|
|
self.device_name = server_check.get('friendlyName')
|
|
|
|
if not self.machine_client_identifier:
|
|
self.machine_client_identifier = server_check.get('machineIdentifier')
|
|
|
|
access_token = None
|
|
if self.token:
|
|
access_token = self.get_access_token(self.token)
|
|
if access_token:
|
|
self.token = access_token
|
|
if not self.home_user_tokens:
|
|
self.home_user_tokens = self.get_plex_home_user_tokens()
|
|
else:
|
|
error_log.append('Access Token not found')
|
|
|
|
resp_sections = None
|
|
if None is access_token or len(access_token):
|
|
resp_sections = self.get_url_pms('/library/sections/')
|
|
|
|
if None is not resp_sections:
|
|
|
|
unpather = []
|
|
for loc in self.section_filter_path:
|
|
loc = re.sub(r'[/\\]+', '/', loc.lower())
|
|
loc = re.sub(r'^(.{,2})[/\\]', '', loc)
|
|
unpather.append(loc)
|
|
self.section_filter_path = unpather
|
|
|
|
for section in resp_sections.findall('Directory'):
|
|
if 'show' != section.get('type') or not section.findall('Location'):
|
|
continue
|
|
|
|
section_path = re.sub(r'[/\\]+', '/', section.find('Location').get('path').lower())
|
|
section_path = re.sub(r'^(.{,2})[/\\]', '', section_path)
|
|
if not any([section_path in path for path in self.section_filter_path]):
|
|
continue
|
|
|
|
if section.get('key') not in self.ignore_sections \
|
|
and section.get('title') not in self.ignore_sections:
|
|
section_key = section.get('key')
|
|
|
|
for (user, token) in (self.home_user_tokens or {'': None}).iteritems():
|
|
self.username = user
|
|
|
|
resp_section = self.get_url_pms('/library/sections/%s/%s' % (
|
|
section_key, ('recentlyViewed', 'all')[fetch_all]), token=token)
|
|
if None is not resp_section:
|
|
view_group = 'MediaContainer' == resp_section.tag and \
|
|
resp_section.get('viewGroup') or ''
|
|
if 'show' == view_group and fetch_all:
|
|
for DirectoryNode in resp_section.findall('Directory'):
|
|
self.stat_show(DirectoryNode)
|
|
elif 'episode' == view_group and not fetch_all:
|
|
self.stat_show(resp_section)
|
|
|
|
if 0 < len(error_log):
|
|
self.log('Library errors...')
|
|
for item in error_log:
|
|
self.log(item)
|
|
|
|
return 0 < len(error_log)
|