jellyfin-kodi/jellyfin_kodi/database/__init__.py

434 lines
14 KiB
Python

# -*- coding: utf-8 -*-
from __future__ import division, absolute_import, print_function, unicode_literals
#################################################################################################
import datetime
import json
import os
import sqlite3
import sys
import re
from kodi_six import xbmc, xbmcvfs
from six import text_type
from . import jellyfin_db
from ..helper import translate, settings, window, dialog
from ..helper.utils import translate_path
from ..objects import obj
from ..helper import LazyLogger
#################################################################################################
LOG = LazyLogger(__name__)
ADDON_DATA = translate_path("special://profile/addon_data/plugin.video.jellyfin/")
#################################################################################################
class Database(object):
"""This should be called like a context.
i.e. with Database('jellyfin') as db:
db.cursor
db.conn.commit()
"""
timeout = 120
discovered = False
discovered_file = None
def __init__(self, db_file=None, commit_close=True):
"""file: jellyfin, texture, music, video, :memory: or path to file"""
self.db_file = db_file or "video"
self.commit_close = commit_close
def __enter__(self):
"""Open the connection and return the Database class.
This is to allow for the cursor, conn and others to be accessible.
"""
self.path = self._sql(self.db_file)
self.conn = sqlite3.connect(self.path, timeout=self.timeout)
self.cursor = self.conn.cursor()
if self.db_file in ("video", "music", "texture", "jellyfin"):
self.conn.execute(
"PRAGMA journal_mode=WAL"
) # to avoid writing conflict with kodi
LOG.debug("--->[ database: %s ] %s", self.db_file, id(self.conn))
if not window("jellyfin_db_check.bool") and self.db_file == "jellyfin":
window("jellyfin_db_check.bool", True)
jellyfin_tables(self.cursor)
self.conn.commit()
# Migration for #162
if self.db_file == "music":
query = self.conn.execute(
'SELECT * FROM path WHERE strPath LIKE "%/emby/%"'
)
contents = query.fetchall()
if contents:
for item in contents:
new_path = item[1].replace("/emby/", "/")
self.conn.execute(
'UPDATE path SET strPath = "{}" WHERE idPath = "{}"'.format(
new_path, item[0]
)
)
return self
def _get_database(self, path, silent=False):
path = translate_path(path)
if not silent:
if not xbmcvfs.exists(path):
raise Exception("Database: %s missing" % path)
conn = sqlite3.connect(path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = cursor.fetchall()
conn.close()
if not len(tables):
raise Exception("Database: %s malformed?" % path)
return path
def _discover_database(self, database):
"""Use UpdateLibrary(video) to update the date modified
on the database file used by Kodi.
"""
if database == "video":
xbmc.executebuiltin("UpdateLibrary(video)")
xbmc.sleep(200)
databases = translate_path("special://database/")
types = {"video": "MyVideos", "music": "MyMusic", "texture": "Textures"}
database = types[database]
dirs, files = xbmcvfs.listdir(databases)
target = {"db_file": "", "version": 0}
for db_file in reversed(files):
if (
db_file.startswith(database)
and not db_file.endswith("-wal")
and not db_file.endswith("-shm")
and not db_file.endswith("db-journal")
):
version_string = re.search("{}(.*).db".format(database), db_file)
version = int(version_string.group(1))
if version > target["version"]:
target["db_file"] = db_file
target["version"] = version
LOG.debug("Discovered database: %s", target)
self.discovered_file = target["db_file"]
return translate_path("special://database/%s" % target["db_file"])
def _sql(self, db_file):
"""Get the database path based on the file objects/obj_map.json
Compatible check, in the event multiple db version are supported with the same Kodi version.
Discover by file as a last resort.
"""
databases = obj.Objects().objects
if db_file not in ("video", "music", "texture") or databases.get(
"database_set%s" % db_file
):
return self._get_database(databases[db_file], True)
discovered = (
self._discover_database(db_file)
if not databases.get("database_set%s" % db_file)
else None
)
databases[db_file] = discovered
self.discovered = True
databases["database_set%s" % db_file] = True
LOG.info("Database locked in: %s", databases[db_file])
return databases[db_file]
def __exit__(self, exc_type, exc_val, exc_tb):
"""Close the connection and cursor."""
changes = self.conn.total_changes
if exc_type is not None: # errors raised
LOG.error("type: %s value: %s", exc_type, exc_val)
if self.commit_close and changes:
LOG.debug("[%s] %s rows updated.", self.db_file, changes)
self.conn.commit()
LOG.debug("---<[ database: %s ] %s", self.db_file, id(self.conn))
self.cursor.close()
self.conn.close()
def jellyfin_tables(cursor):
"""Create the tables for the jellyfin database.
jellyfin, view, version
"""
cursor.execute(
"""CREATE TABLE IF NOT EXISTS jellyfin(
jellyfin_id TEXT UNIQUE, media_folder TEXT, jellyfin_type TEXT, media_type TEXT,
kodi_id INTEGER, kodi_fileid INTEGER, kodi_pathid INTEGER, parent_id INTEGER,
checksum INTEGER, jellyfin_parent_id TEXT)"""
)
cursor.execute(
"""CREATE TABLE IF NOT EXISTS view(
view_id TEXT UNIQUE, view_name TEXT, media_type TEXT)"""
)
cursor.execute("CREATE TABLE IF NOT EXISTS version(idVersion TEXT)")
columns = cursor.execute("SELECT * FROM jellyfin")
if "jellyfin_parent_id" not in [
description[0] for description in columns.description
]:
LOG.debug("Add missing column jellyfin_parent_id")
cursor.execute("ALTER TABLE jellyfin ADD COLUMN jellyfin_parent_id 'TEXT'")
def reset():
"""Reset both the jellyfin database and the kodi database."""
from ..views import Views
views = Views()
if not dialog("yesno", "{jellyfin}", translate(33074)):
return
window("jellyfin_should_stop.bool", True)
count = 10
while window("jellyfin_sync.bool"):
LOG.info("Sync is running...")
count -= 1
if not count:
dialog("ok", "{jellyfin}", translate(33085))
return
if xbmc.Monitor().waitForAbort(1):
return
reset_kodi()
reset_jellyfin()
views.delete_playlists()
views.delete_nodes()
if dialog("yesno", "{jellyfin}", translate(33086)):
reset_artwork()
if dialog("yesno", "{jellyfin}", translate(33087)):
xbmcvfs.delete(os.path.join(ADDON_DATA, "settings.xml"))
xbmcvfs.delete(os.path.join(ADDON_DATA, "data.json"))
LOG.info("[ reset settings ]")
if xbmcvfs.exists(os.path.join(ADDON_DATA, "sync.json")):
xbmcvfs.delete(os.path.join(ADDON_DATA, "sync.json"))
settings("enableMusic.bool", False)
settings("MinimumSetup", "")
settings("MusicRescan.bool", False)
settings("SyncInstallRunDone.bool", False)
dialog("ok", "{jellyfin}", translate(33088))
xbmc.executebuiltin("RestartApp")
def reset_kodi():
with Database() as videodb:
videodb.cursor.execute("SELECT tbl_name FROM sqlite_master WHERE type='table'")
for table in videodb.cursor.fetchall():
name = table[0]
# These tables are populated by Kodi and we shouldn't wipe them
if name not in ["version", "videoversiontype"]:
videodb.cursor.execute("DELETE FROM " + name)
if settings("enableMusic.bool") or dialog("yesno", "{jellyfin}", translate(33162)):
with Database("music") as musicdb:
musicdb.cursor.execute(
"SELECT tbl_name FROM sqlite_master WHERE type='table'"
)
for table in musicdb.cursor.fetchall():
name = table[0]
if name != "version":
musicdb.cursor.execute("DELETE FROM " + name)
LOG.info("[ reset kodi ]")
def reset_jellyfin():
with Database("jellyfin") as jellyfindb:
jellyfindb.cursor.execute(
"SELECT tbl_name FROM sqlite_master WHERE type='table'"
)
for table in jellyfindb.cursor.fetchall():
name = table[0]
if name not in ("version", "view"):
jellyfindb.cursor.execute("DELETE FROM " + name)
jellyfindb.cursor.execute("DROP table IF EXISTS jellyfin")
jellyfindb.cursor.execute("DROP table IF EXISTS view")
jellyfindb.cursor.execute("DROP table IF EXISTS version")
LOG.info("[ reset jellyfin ]")
def reset_artwork():
"""Remove all existing texture."""
thumbnails = translate_path("special://thumbnails/")
if xbmcvfs.exists(thumbnails):
dirs, ignore = xbmcvfs.listdir(thumbnails)
for directory in dirs:
ignore, thumbs = xbmcvfs.listdir(os.path.join(thumbnails, directory))
for thumb in thumbs:
LOG.debug("DELETE thumbnail %s", thumb)
xbmcvfs.delete(os.path.join(thumbnails, directory, thumb))
with Database("texture") as texdb:
texdb.cursor.execute("SELECT tbl_name FROM sqlite_master WHERE type='table'")
for table in texdb.cursor.fetchall():
name = table[0]
if name != "version":
texdb.cursor.execute("DELETE FROM " + name)
LOG.info("[ reset artwork ]")
def get_sync():
if (3, 0) <= sys.version_info < (3, 6):
LOG.error("Python versions 3.0-3.5 are NOT supported.")
if not xbmcvfs.exists(ADDON_DATA):
xbmcvfs.mkdirs(ADDON_DATA)
try:
with open(os.path.join(ADDON_DATA, "sync.json"), "rb") as infile:
sync = json.load(infile)
except Exception:
sync = {}
sync["Libraries"] = sync.get("Libraries", [])
sync["RestorePoint"] = sync.get("RestorePoint", {})
sync["Whitelist"] = list(set(sync.get("Whitelist", [])))
sync["SortedViews"] = sync.get("SortedViews", [])
# Temporary cleanup from #494/#511, remove in a future version
sync["Libraries"] = [lib_id for lib_id in sync["Libraries"] if "," not in lib_id]
return sync
def save_sync(sync):
if not xbmcvfs.exists(ADDON_DATA):
xbmcvfs.mkdirs(ADDON_DATA)
sync["Date"] = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
with open(os.path.join(ADDON_DATA, "sync.json"), "wb") as outfile:
data = json.dumps(sync, sort_keys=True, indent=4, ensure_ascii=False)
if isinstance(data, text_type):
data = data.encode("utf-8")
outfile.write(data)
def get_credentials():
if (3, 0) <= sys.version_info < (3, 6):
LOG.error("Python versions 3.0-3.5 are NOT supported.")
if not xbmcvfs.exists(ADDON_DATA):
xbmcvfs.mkdirs(ADDON_DATA)
try:
with open(os.path.join(ADDON_DATA, "data.json"), "rb") as infile:
credentials = json.load(infile)
except IOError:
credentials = {}
credentials["Servers"] = credentials.get("Servers", [])
# Migration for #145
# TODO: CLEANUP for 1.0.0 release
for server in credentials["Servers"]:
# Functionality removed in #60
if "RemoteAddress" in server:
del server["RemoteAddress"]
if "ManualAddress" in server:
server["address"] = server["ManualAddress"]
del server["ManualAddress"]
# If manual is present, local should always be here, but better to be safe
if "LocalAddress" in server:
del server["LocalAddress"]
elif "LocalAddress" in server:
server["address"] = server["LocalAddress"]
del server["LocalAddress"]
if "LastConnectionMode" in server:
del server["LastConnectionMode"]
return credentials
def save_credentials(credentials):
credentials = credentials or {}
if not xbmcvfs.exists(ADDON_DATA):
xbmcvfs.mkdirs(ADDON_DATA)
try:
with open(os.path.join(ADDON_DATA, "data.json"), "wb") as outfile:
data = json.dumps(credentials, sort_keys=True, indent=4, ensure_ascii=False)
if isinstance(data, text_type):
data = data.encode("utf-8")
outfile.write(data)
except Exception:
LOG.exception("Failed to save credentials:")
def get_item(kodi_id, media):
"""Get jellyfin item based on kodi id and media."""
with Database("jellyfin") as jellyfindb:
item = jellyfin_db.JellyfinDatabase(jellyfindb.cursor).get_full_item_by_kodi_id(
kodi_id, media
)
if not item:
LOG.debug("Not an jellyfin item")
return
return item