diff --git a/.gitignore b/.gitignore index d0e8f7d..a5276e1 100644 --- a/.gitignore +++ b/.gitignore @@ -91,4 +91,6 @@ ENV/ # Rope project settings .ropeprojectKodi.rar -.bak/* +.bak +*.sublime-project +*.sublime-workspace diff --git a/addon.xml b/addon.xml index 1c934a7..e7cf6e6 100644 --- a/addon.xml +++ b/addon.xml @@ -1,10 +1,10 @@ - + @@ -21,7 +21,7 @@ https://github.com/SIMKL/script.simkl resources/icon.png - resources/fanart.png + resources/fanart.jpg en diff --git a/changelog.txt b/changelog.txt index ca129b5..7b2ae4b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,8 +1,9 @@ v 1.2.0 []: - Using tvdb id for series scrobbling +- When you log in, it redirects you to a confirmation page -v 1.1.0 [17/02/04]: -- Fixed problem when downloading from Repositories +v 1.0.1 [18/02/04]: +-Fixed problem when downloading from Repositories v 1.0.0 [17/01/25]: - Ready for submiting to the Kodi repositories diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..69d0ff1 --- /dev/null +++ b/pylintrc @@ -0,0 +1,411 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=2 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +#disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call +disable=C,R,W,no-name-in-module + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=C0303,C0111,W0612,C0304 + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,future.builtins + + +[BASIC] + +# Naming hint for argument names +argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct argument names +argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Naming hint for attribute names +attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct attribute names +attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming hint for function names +function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct function names +function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,f,r,r1,r2,_ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for method names +method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct method names +method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming hint for variable names +variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma,dict-separator + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=xbmc.log + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=utils.*,.*\.usersettings + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant,xbmc + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/resources/data/compdate.txt b/resources/data/compdate.txt index d9fbba6..845b20b 100644 --- a/resources/data/compdate.txt +++ b/resources/data/compdate.txt @@ -1 +1 @@ -2017-04-29 23:31:30+02:00 +2017-05-17 07:02:21+02:00 diff --git a/resources/lib/engine.py b/resources/lib/engine.py index 387befe..c9e5bb2 100644 --- a/resources/lib/engine.py +++ b/resources/lib/engine.py @@ -4,62 +4,257 @@ import os import xbmc import interface +import utils +from datetime import datetime +from utils import getstr import json __addon__ = interface.__addon__ -def getstr(strid): return interface.getstr(strid) class Engine: + """ Like an offline api class """ def __init__(self, api, player): self.api = api self.player = player player.engine = self player.api = api - self.synclibrary() - def synclibrary(self): - ### UPLOAD ### - #DISABLED UNTIL WORKING FINE - pass - # kodilibrary = xbmc.executeJSONRPC(json.dumps({ - # "jsonrpc": "2.0", - # "method": "VideoLibrary.GetMovies", - # "params": { - # "limits": { - # "start": 0, - # "end": 1000 - # }, - # "properties": [ - # "playcount", - # "imdbnumber", - # "file", - # "lastplayed" - # ], - # "sort": { - # "order": "ascending", - # "method": "label", - # "ignorearticle": True - # } - # }, - # "id": "libMovies" - # })) - # xbmc.log("Simkl: Ret: {0}".format(kodilibrary)) - # kodilibrary = json.loads(kodilibrary) - - # if kodilibrary["result"]["limits"]["total"] > 0: - # for movie in kodilibrary["result"]["movies"]: - # #Dont do that, upload all at once - - # if movie["playcount"] > 0: - # imdb = movie["imdbnumber"] - # date = movie["lastplayed"] - # self.api.watched(imdb, "movie", date) + #Note 4 myself: Maybe you should handle movies and series completly different + #I mean: Two different functions, two different "databases", etc. + def synclibrary(self, mode="full"): + """ Syncs simkl with kodi """ + simkl_old_f = utils.get_old_file("simkl") + kodi_old_f = utils.get_old_file("kodi") + kodi_current = { + "movies": self.get_movies("1+"), + "movies_0": self.get_movies("0"), + #"episodes": self.get_episodes(), + "lastcheck": datetime.today().strftime(utils.SIMKL_TIME_FORMAT)} + simkl_current = self.api.get_all_items() + # with open("/home/davo/.kodi/userdata/addon_data/script.simkl/current_simkl.json", "r") as f: + # simkl_current = json.loads(f.read()) + # with open("/home/davo/.kodi/userdata/addon_data/script.simkl/old_simkl.json", "r") as f: + # simkl_old = json.loads(f.read()) + + def open_file(filename, current): + """ Reads and overwrites files """ + if not os.path.exists(filename): + #if True: + with open(filename, "w") as f: + f.write(json.dumps(current, indent=2)) + old = current.copy() + old["movies_0"] = {} + return old + else: + with open(filename, "r") as f: + old = json.loads(f.read()) + with open(filename, "w") as f: + f.write(json.dumps(current, indent=2)) + return old + + kodi_old = open_file(kodi_old_f, kodi_current) + simkl_old = open_file(simkl_old_f, simkl_current) + + simkl_lastcheck = utils.simkl_time_to_kodi(self.api.get_last_activity()["movies"]["all"]) + if utils.getSetting("synclib"): + if utils.getSetting("movie-sync"): + self.syncmovies(simkl_old["movies"], simkl_current["movies"], kodi_old["movies"], \ + kodi_current["movies"], kodi_current["movies_0"], kodi_old["movies_0"], kodi_old["lastcheck"], simkl_lastcheck) + + lstchk_anime = self.api.get_last_activity()["anime"]["all"] + lstchk_tv_shows = self.api.get_last_activity()["tv_shows"]["all"] + simkl_lastcheck = max(lstchk_anime, lstchk_tv_shows) + + def syncmovies(self, simkl_old, simkl_current, kodi_old, kodi_current, \ + kodi_current_0, kodi_old_0, kodi_lastcheck, simkl_lastcheck): + """ Syncs movies with simkl """ + simkl_lastcheck = utils.simkl_time_to_kodi(self.api.get_last_activity()["movies"]["completed"]) + xbmc.log("Simkl: Lastcheck movies %s" % simkl_lastcheck) + + simkl_added = self.diff(utils.simkl2kodi(simkl_old), utils.simkl2kodi(simkl_current)) + simkl_removed = self.diff(utils.simkl2kodi(simkl_current), utils.simkl2kodi(simkl_old)) + kodi_watched = self.diff(kodi_old, kodi_current) + kodi_unwatched = self.diff(kodi_current, kodi_old) + kodi_added = self.diff(kodi_old_0, kodi_current_0) + #We can ignore items removed from library + + xbmc.log("kodi_unwatched %s, %s" % kodi_unwatched) + xbmc.log("kodi_added %s, %s" % kodi_added) + movies_to_simkl = [] + + for movie in self.get_movies("0+"): + #xbmc.log("Movie: %s" % movie) + if movie["imdbnumber"] in simkl_added[1] and movie["imdbnumber"] not in kodi_unwatched[1]: + xbmc.log("Added %s" % movie) + movie["playcount"] = 1 + self.update_movie(movie) + elif movie["imdbnumber"] in simkl_removed[1] and movie["imdbnumber"] not in kodi_watched[1]: + xbmc.log("Removed %s" % movie) + movie["playcount"] = 0 + self.update_movie(movie) + elif movie["imdbnumber"] in kodi_watched[1] and movie["imdbnumber"] not in simkl_removed[1]: + movie["playcount"] = 1 + movies_to_simkl.append(movie) + elif movie["imdbnumber"] in kodi_unwatched[1] and movie["imdbnumber"] not in simkl_added[1]: + movie["playcount"] = 0 + movies_to_simkl.append(movie) + + elif movie["imdbnumber"] in simkl_added[1] and movie["imdbnumber"] in kodi_unwatched[1]: + xbmc.log("Conflicting %s" % movie) + if simkl_lastcheck >= kodi_lastcheck: + movie["playcount"] = 1 + else: + movie["playcount"] = 0 + movies_to_simkl.append(movie) + self.update_movie(movie) + elif movie["imdbnumber"] in kodi_watched[1] and movie["imdbnumber"] in simkl_removed[1]: + xbmc.log("Conflicting %s" % movie) + if simkl_lastcheck >= kodi_lastcheck: + movie["playcount"] = 0 + else: + movie["playcount"] = 1 + movies_to_simkl.append(movie) + self.update_movie(movie) + elif movie["imdbnumber"] in kodi_added[1]: + self.watch_if_kodi(movie) + + if len(movies_to_simkl) > 0: self.api.update_movies(movies_to_simkl) + + def watch_if_kodi(self, movie): + """ Marks a movie as watched if is watched in kodi """ + c = self.api.check_if_watched(movie) + xbmc.log("Movie, c: %s, %s" % (movie, c)) + if c: + movie["playcount"] = 1 + self.update_movie(movie) + + @staticmethod + def diff(A, B): + """ + Returns the difference between A and B. About data return: B > A + """ + A_movies = set([x["imdbnumber"] for x in A]) + B_movies = set([x["imdbnumber"] for x in B]) + diff = B_movies - A_movies + return [movie_B for movie_B in B if movie_B["imdbnumber"] in diff], diff + + ''' + @staticmethod + def sym_diff(A, B): + """ Return the symetric difference between A and B """ + A_movies = set([x["imdbnumber"] for x in A]) + B_movies = set([x["imdbnumber"] for x in B]) + diff1 = B_movies - A_movies + diff2 = A_movies - B_movies + symet = diff1 | diff2 + + [movie_B for movie_B in B if movie_B["imdbnumber"] in symet] + ''' + + @staticmethod + def intersect(A, B): + """ Returns the intersection between A and B. About data return: B > A """ + A_movies = set([x["imdbnumber"] for x in A]) + B_movies = set([x["imdbnumber"] for x in B]) + inter = A_movies & B_movies + return [movie_B for movie_B in B if movie_B["imdbnumber"] in inter], inter + + @staticmethod + def union(A, B): + A_movies = set([x["imdbnumber"] for x in A]) + B_movies = set([x["imdbnumber"] for x in B]) + union = A_movies | B_movies + return [movie_B for movie_B in B if movie_B["imdbnumber"] in union], union + + @staticmethod + def get_movies(playcount="1+"): + movies_list = [] + movies = json.loads(xbmc.executeJSONRPC(json.dumps({ + "jsonrpc": "2.0", + "method": "VideoLibrary.GetMovies", + "params": { + "limits": { + "start": 0, + "end": 0 + }, + "properties": [ + "playcount", + "imdbnumber", + "file", + "lastplayed", + "year" + ], + "sort": { + "order": "ascending", + "method": "playcount", + #"ignorearticle": True + } + }, + "id": "libMovies" + })))["result"]["movies"] + for movie in movies: + if str(movie["playcount"]) in playcount or (movie["playcount"]>playcount and "+" in playcount): + movies_list.append(movie) + return movies_list + + @staticmethod + def get_episodes(): + shows_library = json.loads(xbmc.executeJSONRPC(json.dumps({ + "jsonrpc": "2.0", + "method": "VideoLibrary.GetTVShows", + "params": { + "limits": {"start": 0, "end": 0}, + "properties": ["imdbnumber", "title", "watchedepisodes", "year"], + "sort": {"order": "ascending", "method": "season"} + }, + "id": "libMovies" + }))) + if shows_library["result"]["limits"]["total"] == 0: return None + list_tvshows = [] + for tvshow in shows_library["result"]["tvshows"]: + list_episodes = [] + episodes = json.loads(xbmc.executeJSONRPC(json.dumps({ + "jsonrpc": "2.0", + "method": "VideoLibrary.GetEpisodes", + "params": { + "limits": {"start": 0, "end": 0}, + "properties": ["playcount", "season", "episode", "lastplayed", "tvshowid"], + "sort": {"order": "ascending", "method": "playcount"}, + #"season": season["season"], + "tvshowid": tvshow["tvshowid"] + }, + "id": tvshow["tvshowid"] + }))) + + if episodes["result"]["limits"]["end"] > 0: + for episode in episodes["result"]["episodes"]: + if episode["playcount"] > 0: list_episodes.append(episode) + if len(list_episodes) > 0: + list_tvshows.append({ + "title":tvshow["title"], + "year":tvshow["year"], + "imdbnumber":tvshow["imdbnumber"], + "episodes":list_episodes}) + + return list_tvshows + + @staticmethod + def update_movie(movie): + movie1 = {"movieid":movie["movieid"], "playcount":movie["playcount"]} #Y tho? + r = xbmc.executeJSONRPC(json.dumps({ + "jsonrpc": "2.0", + "method": "VideoLibrary.SetMovieDetails", + "params": movie1 + })) + xbmc.log(str(r)) class Player(xbmc.Player): + """ Replaces the Kodi player class """ def __init__(self): xbmc.Player.__init__(self) @staticmethod def getMediaType(): + """ Returns the MediaType of the file currently playing """ if xbmc.getCondVisibility('Container.Content(tvshows)'): return "show" elif xbmc.getCondVisibility('Container.Content(seasons)'): @@ -72,15 +267,20 @@ def getMediaType(): return None def onPlayBackStarted(self): + """ Activated at start """ #self.onPlayBackStopped() pass def onPlayBackSeek(self, *args): + """ Activated on seek """ self.onPlayBackStopped() def onPlayBackResumed(self): + """ Activated on resume """ self.onPlayBackStopped() def onPlayBackEnded(self): + """ Activated at end """ xbmc.log("Simkl: ONPLAYBACKENDED") self.onPlayBackStopped() + def onPlayBackStopped(self): ''' Gets the info needed to pass to the api ''' self.api.check_connection() @@ -88,7 +288,7 @@ def onPlayBackStopped(self): item = json.loads(xbmc.executeJSONRPC(json.dumps({ "jsonrpc": "2.0", "method": "Player.GetItem", "params": { - "properties": ["showtitle", "title", "season", "episode", "file", "tvshowid", "imdbnumber", "genre" ], + "properties": ["showtitle", "title", "season", "episode", "file", "tvshowid", "imdbnumber", "year" ], "playerid": 1}, "id": "VideoGetItem"})))["result"]["item"] if item["tvshowid"] != -1: @@ -104,9 +304,6 @@ def onPlayBackStopped(self): if percentage > pctconfig: bubble = __addon__.getSetting("bubble") - xbmc.log("Simkl: Bubble == {0}".format(bubble)) - xbmc.log("Percentage: {0}, pctconfig {1}".format(percentage, pctconfig)) - r = self.api.watched(item, self.getTotalTime()) if bubble=="true" and r: @@ -120,12 +317,11 @@ def onPlayBackStopped(self): item["episode"] = lstw["episode"]["episode"] elif lstw["type"] == "movie": item["title"] = "".join([lstw["movie"]["title"], " (", str(lstw["movie"]["year"]), ")"]) - media = lstw["type"] txt = item["label"] title = "" if item["type"] == "movie": - txt = item["title"] + txt = "".join([item["title"], " (", str(item["year"]), ")"]) elif item["type"] == "episode": txt = item["showtitle"] title = "- S{:02}E{:02}".format(item["season"], item["episode"]) diff --git a/resources/lib/interface.py b/resources/lib/interface.py index a551335..eacc9b6 100644 --- a/resources/lib/interface.py +++ b/resources/lib/interface.py @@ -29,6 +29,7 @@ def login(logged): __addon__.setSetting("loginbool", str(bool(1)).lower()) class loginDialog(xbmcgui.WindowXMLDialog): + """ The login dialog popped when you click on LogIn """ def __init__(self, xmlFilename, scriptPath, pin, url, check_login, log, exp=900, inter=5, api=None): self.pin = pin @@ -44,14 +45,14 @@ def __init__(self, xmlFilename, scriptPath, pin, url, check_login, log, def threaded(self): """ A loop threaded function, so you can do another things meanwhile """ xbmc.log("Simkl: threaded: {0}".format(self)) - cnt = 0 + cnt = 1 while self.waiting: - if cnt % (self.inter+1) == 0 and cnt>1: + if cnt % (self.inter+1) == 0: xbmc.log("Simkl: Still waiting... {0}".format(cnt)) if self.check_login(self.pin, self.log): - xbmc.log(str(self.api.USERSETTINGS)) - notify(getstr(32030).format(self.api.USERSETTINGS["user"]["name"])) + xbmc.log(str(self.api.usersettings)) + notify(getstr(32030).format(self.api.usersettings["user"]["name"])) self.waiting = False #Now check that the user has done what it has to be done @@ -77,7 +78,7 @@ def onInit(self): if API.is_user_logged(): #If user is alredy logged in dialog = xbmcgui.Dialog() - username = API.USERSETTINGS["user"]["name"] + username = API.usersettings["user"]["name"] ret = dialog.yesno("Simkl LogIn Warning", getstr(32032).format(username), nolabel=getstr(32034), yeslabel=getstr(32033), autoclose=30000) #xbmc.log("Ret: {0}".format(ret)) @@ -100,4 +101,28 @@ def onAction(self, action): def onClick(self, controlID): xbmc.log("Simkl: onclick {0}, {1}".format(controlID, self)) if controlID == CANCEL_BUTTON: - self.canceled = True \ No newline at end of file + self.canceled = True + +class SyncDialog(xbmcgui.WindowXMLDialog): + """ The Dialog popped the first time you try to sync your files """ + def __init__(self, xmlFilename, scriptPath): + pass + + def onInit(self): + """ The function that is loaded on window init """ + #dialog = xbmcgui.Dialog() + pass + + +class SyncProgress(xbmcgui.DialogProgressBG): + """ The progress dialog when syncing """ + def __init__(self, media="None", mode="None"): + self.cnt = 0 + self.msg = "Simkl: Syncing {0} library, {1} mode".format(media, mode) + self.create(self.msg, "Starting") + + def push(self, i, message=None): + """ Updates the percentage and description """ + self.cnt += i + self.update(int(self.cnt), message=message) + diff --git a/resources/lib/simklapi.py b/resources/lib/simklapi.py index b5e2447..8e18bde 100644 --- a/resources/lib/simklapi.py +++ b/resources/lib/simklapi.py @@ -1,25 +1,31 @@ #!/usr/bin/python # -*- coding: UTF-8 -*- +""" +The module that connects with simkl api +http://docs.simkl.apiary.io/ +""" import sys, os, time -#import urllib -#import request import json import xbmc import interface import httplib from socket import gaierror +import utils +from utils import getstr __addon__ = interface.__addon__ -def getstr(strid): return interface.getstr(strid) -REDIRECT_URI = "http://simkl.com" +REDIRECT_URI = "https://simkl.com/apps/kodi/connected" USERFILE = os.path.join(xbmc.translatePath(__addon__.getAddonInfo("profile")).decode("utf-8"), "simkl_key") -xbmc.translatePath("special://profile/simkl_key") +#xbmc.translatePath("special://profile/simkl_key") if not os.path.exists(USERFILE): - os.mkdir(os.path.dirname(USERFILE)) + try: + os.mkdir(os.path.dirname(USERFILE)) + except OSError: + xbmc.log("Simkl: Folder alredy exists") with open(USERFILE, "w+") as f: f.write("") else: @@ -40,6 +46,7 @@ def getstr(strid): return interface.getstr(strid) "simkl-api-key": APIKEY} class API: + """ Class for handling http://api.simkl.com """ def __init__(self): self.scrobbled_dict = {} #So it doesn't scrobble 5 times the same chapter #{"Label":expiration_time} @@ -57,14 +64,14 @@ def __init__(self): self.internet = False def get_usersettings(self): + """ Retrieves user settings """ self.con = httplib.HTTPSConnection("api.simkl.com") self.con.request("GET", "/users/settings", headers=headers) - r = self.con.getresponse().read().decode("utf-8") - xbmc.log("Simkl: get_usersettings: %s" %r) - self.USERSETTINGS = json.loads(r) - xbmc.log("Simkl: Usersettings: " + str(self.USERSETTINGS)) + self.usersettings = json.loads(self.con.getresponse().read().decode("utf-8")) + xbmc.log("Simkl: Usersettings: " + str(self.usersettings)) def login(self): + """ Logins the API class to Simkl """ url = "/oauth/pin?client_id=" url += APIKEY + "&redirect=" + REDIRECT_URI @@ -89,6 +96,7 @@ def login(self): del self.logindialog def set_atoken(self, token): + """ Sets the token for the api """ global ATOKEN with open(USERFILE, "w") as f: f.write(token) @@ -97,6 +105,7 @@ def set_atoken(self, token): self.token = token def check_login(self, ucode, log): #Log is the connection + """ Cecks how the login is going """ url = "/oauth/pin/" + ucode + "?client_id=" + APIKEY log.request("GET", url, headers=headers) r = json.loads(log.getresponse().read().decode("utf-8")) @@ -105,7 +114,7 @@ def check_login(self, ucode, log): #Log is the connection self.set_atoken(r["access_token"]) log.request("GET", "/users/settings", headers=headers) r = json.loads(log.getresponse().read().decode("utf-8")) - self.USERSETTINGS = r + self.usersettings = r return True elif r["result"] == "KO": return False @@ -114,18 +123,19 @@ def is_user_logged(self): """ Checks if user is logged in """ failed = False if self.internet == False: return False - if "error" in self.USERSETTINGS.keys(): failed = self.USERSETTINGS["error"] + if "error" in self.usersettings.keys(): failed = self.usersettings["error"] if self.token == "" or failed == "user_token_failed": xbmc.log("Simkl: User not logged in") interface.login(0) return False else: - #interface.login(self.USERSETTINGS["user"]["name"]) + #interface.login(self.usersettings["user"]["name"]) interface.login(1) return True ### SCROBBLING OR CHECKIN def lock(self, fname, duration): + """ Locks a file """ xbmc.log("Duration: %s" %duration) exp = self.scrobbled_dict exp[fname] = int(time.time() + (105 - float(__addon__.getSetting("scr-pct"))) / 100 * duration) @@ -133,9 +143,11 @@ def lock(self, fname, duration): self.scrobbled_dict = {fname:exp[fname]} #So there is always only one entry on the dict def is_locked(self, fname): + """ Checks if the file is 'locked' """ exp = self.scrobbled_dict if not (fname in exp.keys()): return 0 - xbmc.log("Time: {0}, exp: {1}, Dif: {2}".format(int(time.time()), exp[fname], int(exp[fname]-time.time()))) + xbmc.log("Time: {0}, exp: {1}, Dif: {2}".format(int(time.time()), + exp[fname], int(exp[fname]-time.time()))) #When Dif reaches 0, scrobble. if time.time() < exp[fname]: xbmc.log("Simkl: Can't scrobble, file locked (alredy scrobbled)") @@ -145,7 +157,8 @@ def is_locked(self, fname): del exp[fname] return 0 - def watched(self, item, duration, date=time.strftime('%Y-%m-%d %H:%M:%S'), cnt=0): #OR IDMB, member: only works with movies + def watched(self, item, duration, date=time.strftime('%Y-%m-%d %H:%M:%S'), cnt=0): + """ Sets a 'item' as watched on simkl """ filename = item["file"].replace("\\", "/") if self.is_user_logged() and not self.is_locked(filename): try: @@ -205,20 +218,84 @@ def watched(self, item, duration, date=time.strftime('%Y-%m-%d %H:%M:%S'), cnt=0 xbmc.log("Simkl: Can't scrobble. User not logged in or file locked") return 0 + @staticmethod + def get_all_items(): + """ http://docs.simkl.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist """ + con = httplib.HTTPSConnection("api.simkl.com") + con.request("GET", "/sync/all-items/?extended=full", headers=headers) + return json.loads(con.getresponse().read()) + + @staticmethod + def watched_from_list(lst): + """ Given a list, it marks them as watched """ + con = httplib.HTTPSConnection("api.simkl.com") + con.request("GET", "/sync/history", body=json.dumps(lst), headers=headers) + r = con.getresponse().read() + return min(max(json.loads(r)["added"].values()),1) + + @staticmethod + def check_if_watched(item, movie=True): + """ Checks if an item has been watched """ + con = httplib.HTTPSConnection("api.simkl.com") + if movie: + values = json.dumps([{ + "type": "movie", + "imdb": item["imdbnumber"] + }]) + con.request("GET", "/sync/watched", body=values, headers=headers) + r1 = con.getresponse().read() + xbmc.log("Simkl: {}".format(r1)) + return json.loads(r1)[0]["result"] + else: + values = json.dumps(item) + con.request("GET", "/sync/watched", body=values, headers=headers) + return json.loads(con.getresponse().read()) + def check_connection(self, cnt=0): + """ Checks if there is a connection to the internet """ if cnt < 3: try: self.get_usersettings() self.internet = True - except Exception: + except gaierror: time.sleep(5) self.check_connection(cnt=cnt+1) else: self.internet = False interface.notify(getstr(32027)) + @staticmethod + def get_last_activity(): + """ Method http://docs.simkl.apiary.io/#reference/sync/last-activities/get-last-activity """ + con = httplib.HTTPSConnection("api.simkl.com") + con.request("GET", "/sync/activities", headers=headers) + return json.loads(con.getresponse().read()) + + @staticmethod + def update_movies(movies): + """ http://docs.simkl.apiary.io/#reference/sync/get-all-items/add-items-to-specific-list """ + con = httplib.HTTPSConnection("api.simkl.com") + movies_values = [] + for movie in movies: + xbmc.log("Updating to simkl: %s" % movie) + tmpdict = {} + tmpdict["ids"] = {"imdb": movie["imdbnumber"]} + if movie["playcount"] == 0: + tmpdict["to"] = "plantowatch" + else: + tmpdict["to"] = "completed" + tmpdict["watched_at"] = utils.kodi_time_to_simkl(movie["lastplayed"]) + movies_values.append(tmpdict) + + values = json.dumps({"movies":movies_values}) + + xbmc.log("Values %s" % values) + con.request("GET", "/sync/add-to-list/", body=values, headers=headers) + r = con.getresponse().read() + xbmc.log("Respones: %s" % r) + api = API() if __name__ == "__main__": if sys.argv[1] == "login": xbmc.log("Logging in", level=xbmc.LOGDEBUG) - api.login() \ No newline at end of file + api.login() diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 30fd062..f3eaebb 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -3,10 +3,14 @@ """ Utils module. Some basic functions that maybe I'll need more than once """ -import sys +import sys, os import xbmc, xbmcaddon +from datetime import datetime __addon__ = xbmcaddon.Addon("script.simkl") +KODI_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" +SIMKL_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.000Z" + def getstr(strid): """ Given an id, returns the localized string """ return __addon__.getLocalizedString(strid) @@ -16,4 +20,46 @@ def getSetting(settingid): ret = __addon__.getSetting(settingid) xbmc.log("Simkl: {0}: {1}".format(settingid, ret)) if ret == "false": ret = False - return ret \ No newline at end of file + elif ret == "true": ret = True + return ret + +def get_old_file(filename): + """ Gets filename, returns full path """ + fullpath = os.path.join(xbmc.translatePath(__addon__.getAddonInfo("profile")).decode("utf-8"),\ + "old_{}.json".format(filename)) + xbmc.log("Simkl: %s -- %s" % (filename, fullpath)) + return fullpath + +def simkl_time_to_kodi(string): + """ gets simkl time and returns kodi time """ + return datetime.strptime(string, SIMKL_TIME_FORMAT).strftime(KODI_TIME_FORMAT) +def kodi_time_to_simkl(string): + """ gets kodi time and returns simkl time """ + return datetime.strptime(string, KODI_TIME_FORMAT).strftime(SIMKL_TIME_FORMAT) + +def simkl2kodi(objects): + """ Simkl dictionary format to kodi library format """ + watched_movies_list = [] + for movie in objects: + if movie["status"] == "completed": + watched_movies_list.append({ + "year": movie["movie"]["year"], + "imdbnumber": movie["movie"]["ids"]["imdb"], + "label": movie["movie"]["title"], + "playcount": 1, + "lastplayed": simkl_time_to_kodi(movie["last_watched_at"]), + }) + + return watched_movies_list + +def find_match(item, database): + """ returns match from database """ + for item_database in database: + if item["imdbnumber"] == item_database["imdbnumber"]: return item_database + return None + +def compare_max(tuple1, tuple2): + """ Pretty simple, no comments """ + if tuple1[1] >= tuple2[1]: + return tuple1[0] + return tuple2[0] diff --git a/resources/settings.xml b/resources/settings.xml index fce50a8..9cb42a7 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -9,7 +9,6 @@ - @@ -17,4 +16,12 @@ percent" visible="false"/> + + + + + + + + diff --git a/script.simkl.zip b/script.simkl.zip index 34da5bf..a423972 100644 Binary files a/script.simkl.zip and b/script.simkl.zip differ diff --git a/service.py b/service.py index 92108d2..bba7b08 100644 --- a/service.py +++ b/service.py @@ -8,35 +8,44 @@ import sys, os import xbmcaddon import xbmc +#import resources from resources.lib import interface, engine from resources.lib import simklapi as API +from resources.lib.utils import getstr, getSetting __addon__ = xbmcaddon.Addon() interface.__addon__ = __addon__ autoscrobble = __addon__.getSetting("autoscrobble") -def getstr(strid): return __addon__.getLocalizedString(strid) try: - compdatefile = os.path.join(__addon__.getAddonInfo("path").decode("utf-8"), "resources", "data", "compdate.txt") + compdatefile = os.path.join(__addon__.getAddonInfo("path").decode("utf-8"), \ + "resources", "data", "compdate.txt") with open(xbmc.translatePath(compdatefile), "r") as f: __compdate__ = f.read() -except: +except IOError: __compdate__ = "ERROR: No such file or directory" +class Monitor(xbmc.Monitor): + """ http://mirrors.kodi.tv/docs/python-docs/16.x-jarvis/xbmc.html#Monitor """ + def __init__(self, engin): + self.engine = engin + + def onScanFinished(self, arg): + """ When library scan finishes """ + ### Connect with config + xbmc.log("Simkl: onScanFinished {0}".format(str(arg))) + if bool(getSetting("synclib")): self.engine.synclibrary() + if __name__ == "__main__": xbmc.log("Simkl dir: " + str(xbmc.translatePath("special://home"))) xbmc.log("Simkl | Python Version: " + str(sys.version)) xbmc.log("Simkl | "+ str(sys.argv), level=xbmc.LOGDEBUG) xbmc.log("Simkl | compdate: {0}".format(__compdate__)) - monitor = xbmc.Monitor() - player = engine.Player() + player = engine.Player() player.addon = __addon__ - eng = engine.Engine(API.api, player) - #Remember: if getTime() is more than x% scrobble file - - #Testing: - #API.api.login() + eng = engine.Engine(API.api, player) + monitor = Monitor(eng) if API.api.internet == False: interface.notify(getstr(32027)) @@ -44,11 +53,11 @@ def getstr(strid): return __addon__.getLocalizedString(strid) API.api.login() #Add "remind me tomorrow button" #interface.notify(getstr(32026)) else: - interface.notify(getstr(32025).format(API.api.USERSETTINGS["user"]["name"])) + interface.notify(getstr(32025).format(API.api.usersettings["user"]["name"])) #__addon__.openSettings() while not monitor.abortRequested(): if monitor.waitForAbort(90): break elif player.isPlaying(): - player.onPlayBackStopped() \ No newline at end of file + player.onPlayBackStopped() diff --git a/tozip.sh b/tozip.sh index e17fe3d..0300b2e 100755 --- a/tozip.sh +++ b/tozip.sh @@ -1,10 +1,33 @@ #!/bin/bash #Creates a zip file compatible with Kodi -date --rfc-3339=seconds > resources/data/compdate.txt -mkdir script.simkl -rm script.simkl.zip +if [ "$1" == "zip" ]; then + date --rfc-3339=seconds > resources/data/compdate.txt + mkdir script.simkl + rm script.simkl.zip -rsync -rv --progress ./ ./script.simkl --exclude-from .gitignore --exclude tozip.sh -zip -rx@.gitignore script.simkl.zip script.simkl/* -x script.simkl/script.simkl/ -rm -Rf script.simkl \ No newline at end of file + rsync -rv ./ ./script.simkl --exclude-from .gitignore --exclude tozip.sh + zip -rx@.gitignore script.simkl.zip script.simkl/* -x script.simkl/script.simkl/ + rm -Rf script.simkl + +elif [ "$1" == "pull" ]; then + #Remember. It should've been checkout before + REPO="/home/$USER/Documentos/repo-scripts/script.simkl" + rm -Rf $REPO/* $REPO/.* + rsync -rv --progress ./ $REPO --exclude-from .gitignore --exclude tozip.sh --exclude script.simkl.zip --exclude ".git*" + echo + ls -a --color=auto $REPO + +elif [ "$1" == "bak" ]; then + #Workaround. Use rsync or unison first so you don't have two files that are the same + BAKDATE="$(date '+%F %T')" + echo $BAKDATE + zip -r ".bak/$BAKDATE" ./* -x .bak + fdupes -irndN .bak/ + ls -l .bak + +else + echo "$1 Does nothing. Use zip / pull / bak" + +fi +exit \ No newline at end of file