diff --git a/.gitignore b/.gitignore index 4ed21c61..a8166349 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ cps/static/[0-9]* *.bak *.log.* tags + +settings.yaml +gdrive_credentials \ No newline at end of file diff --git a/cps/db.py b/cps/db.py index f6ee790e..c64f286b 100755 --- a/cps/db.py +++ b/cps/db.py @@ -12,9 +12,9 @@ import ub session = None cc_exceptions = None -cc_classes = None -cc_ids = None -books_custom_column_links = None +cc_classes = {} +cc_ids = [] +books_custom_column_links = {} engine = None @@ -274,6 +274,8 @@ def setup_db(): return False dbpath = os.path.join(config.config_calibre_dir, "metadata.db") + if not os.path.exists(dbpath): + return False engine = create_engine('sqlite:///{0}'.format(dbpath.encode('utf-8')), echo=False) try: conn = engine.connect() @@ -293,41 +295,40 @@ def setup_db(): cc = conn.execute("SELECT id, datatype FROM custom_columns") - cc_ids = [] cc_exceptions = ['datetime', 'int', 'comments', 'float', 'composite', 'series'] - books_custom_column_links = {} - cc_classes = {} for row in cc: if row.datatype not in cc_exceptions: - books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link', Base.metadata, + if row.id not in books_custom_column_links: + books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link', Base.metadata, Column('book', Integer, ForeignKey('books.id'), primary_key=True), Column('value', Integer, ForeignKey('custom_column_' + str(row.id) + '.id'), primary_key=True) ) - cc_ids.append([row.id, row.datatype]) - if row.datatype == 'bool': - ccdict = {'__tablename__': 'custom_column_' + str(row.id), - 'id': Column(Integer, primary_key=True), - 'book': Column(Integer, ForeignKey('books.id')), - 'value': Column(Boolean)} - else: - ccdict = {'__tablename__': 'custom_column_' + str(row.id), - 'id': Column(Integer, primary_key=True), - 'value': Column(String)} - cc_classes[row.id] = type('Custom_Column_' + str(row.id), (Base,), ccdict) + cc_ids.append([row.id, row.datatype]) + if row.datatype == 'bool': + ccdict = {'__tablename__': 'custom_column_' + str(row.id), + 'id': Column(Integer, primary_key=True), + 'book': Column(Integer, ForeignKey('books.id')), + 'value': Column(Boolean)} + else: + ccdict = {'__tablename__': 'custom_column_' + str(row.id), + 'id': Column(Integer, primary_key=True), + 'value': Column(String)} + cc_classes[row.id] = type('Custom_Column_' + str(row.id), (Base,), ccdict) for id in cc_ids: - if id[1] == 'bool': - setattr(Books, 'custom_column_' + str(id[0]), relationship(cc_classes[id[0]], - primaryjoin=( - Books.id == cc_classes[id[0]].book), - backref='books')) - else: - setattr(Books, 'custom_column_' + str(id[0]), relationship(cc_classes[id[0]], - secondary=books_custom_column_links[id[0]], - backref='books')) + if not hasattr(Books, 'custom_column_' + str(id[0])): + if id[1] == 'bool': + setattr(Books, 'custom_column_' + str(id[0]), relationship(cc_classes[id[0]], + primaryjoin=( + Books.id == cc_classes[id[0]].book), + backref='books')) + else: + setattr(Books, 'custom_column_' + str(id[0]), relationship(cc_classes[id[0]], + secondary=books_custom_column_links[id[0]], + backref='books')) # Base.metadata.create_all(engine) Session = sessionmaker() diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py new file mode 100644 index 00000000..66afed71 --- /dev/null +++ b/cps/gdriveutils.py @@ -0,0 +1,313 @@ +from pydrive.auth import GoogleAuth +from pydrive.drive import GoogleDrive +import os, time + +from ub import config + +from sqlalchemy import * +from sqlalchemy import exc +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import * + +from apiclient import errors + +import web + + +dbpath = os.path.join(os.path.normpath(os.path.dirname(os.path.realpath(__file__)) + os.sep + ".." + os.sep), "gdrive.db") +engine = create_engine('sqlite:///{0}'.format(dbpath), echo=False) +Base = declarative_base() + +# Open session for database connection +Session = sessionmaker() +Session.configure(bind=engine) +session = Session() + +class GdriveId(Base): + __tablename__='gdrive_ids' + + id = Column(Integer, primary_key=True) + gdrive_id = Column(Integer, unique=True) + path = Column(String) + + def __repr__(self): + return str(self.path) + +if not os.path.exists(dbpath): + try: + Base.metadata.create_all(engine) + except Exception: + raise + +def getDrive(gauth=None): + if not gauth: + gauth=GoogleAuth(settings_file='settings.yaml') + # Try to load saved client credentials + gauth.LoadCredentialsFile("gdrive_credentials") + if gauth.access_token_expired: + # Refresh them if expired + gauth.Refresh() + else: + # Initialize the saved creds + gauth.Authorize() + # Save the current credentials to a file + return GoogleDrive(gauth) + +def getEbooksFolder(drive=None): + if not drive: + drive = getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + ebooksFolder= "title = '%s' and 'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" % config.config_google_drive_folder + + fileList = drive.ListFile({'q': ebooksFolder}).GetList() + return fileList[0] + +def getEbooksFolderId(drive=None): + storedPathName=session.query(GdriveId).filter(GdriveId.path == '/').first() + if storedPathName: + return storedPathName.gdrive_id + else: + gDriveId=GdriveId() + gDriveId.gdrive_id=getEbooksFolder(drive)['id'] + gDriveId.path='/' + session.merge(gDriveId) + session.commit() + return + +def getFolderInFolder(parentId, folderName, drive=None): + if not drive: + drive = getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + folder= "title = '%s' and '%s' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" % (folderName.replace("'", "\\'"), parentId) + fileList = drive.ListFile({'q': folder}).GetList() + return fileList[0] + +def getFile(pathId, fileName, drive=None): + if not drive: + drive = getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + metaDataFile="'%s' in parents and trashed = false and title = '%s'" % (pathId, fileName.replace("'", "\\'")) + + fileList = drive.ListFile({'q': metaDataFile}).GetList() + return fileList[0] + +def getFolderId(path, drive=None): + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + currentFolderId=getEbooksFolderId(drive) + sqlCheckPath=path if path[-1] =='/' else path + '/' + storedPathName=session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first() + + if not storedPathName: + dbChange=False + s=path.split('/') + for i, x in enumerate(s): + if len(x) > 0: + currentPath="/".join(s[:i+1]) + if currentPath[-1] != '/': + currentPath = currentPath + '/' + storedPathName=session.query(GdriveId).filter(GdriveId.path == currentPath).first() + if storedPathName: + currentFolderId=storedPathName.gdrive_id + else: + currentFolderId=getFolderInFolder(currentFolderId, x, drive)['id'] + gDriveId=GdriveId() + gDriveId.gdrive_id=currentFolderId + gDriveId.path=currentPath + session.merge(gDriveId) + dbChange=True + if dbChange: + session.commit() + else: + currentFolderId=storedPathName.gdrive_id + return currentFolderId + + +def getFileFromEbooksFolder(drive, path, fileName): + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + if path: + sqlCheckPath=path if path[-1] =='/' else path + '/' + folderId=getFolderId(path, drive) + else: + folderId=getEbooksFolderId(drive) + + return getFile(folderId, fileName, drive) + +def copyDriveFileRemote(drive, origin_file_id, copy_title): + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + copied_file = {'title': copy_title} + try: + file_data = drive.auth.service.files().copy( + fileId=origin_file_id, body=copied_file).execute() + return drive.CreateFile({'id': file_data['id']}) + except errors.HttpError as error: + print ('An error occurred: %s' % error) + return None + +def downloadFile(drive, path, filename, output): + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + f=getFileFromEbooksFolder(drive, path, filename) + f.GetContentFile(output) + +def backupCalibreDbAndOptionalDownload(drive, f=None): + pass + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + metaDataFile="'%s' in parents and title = 'metadata.db' and trashed = false" % getEbooksFolderId() + + fileList = drive.ListFile({'q': metaDataFile}).GetList() + + databaseFile=fileList[0] + + if f: + databaseFile.GetContentFile(f) + +def copyToDrive(drive, uploadFile, createRoot, replaceFiles, + ignoreFiles=[], + parent=None, prevDir=''): + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + isInitial=not bool(parent) + if not parent: + parent=getEbooksFolder(drive) + if os.path.isdir(os.path.join(prevDir,uploadFile)): + existingFolder=drive.ListFile({'q' : "title = '%s' and '%s' in parents and trashed = false" % (os.path.basename(uploadFile), parent['id'])}).GetList() + if len(existingFolder) == 0 and (not isInitial or createRoot): + parent = drive.CreateFile({'title': os.path.basename(uploadFile), 'parents' : [{"kind": "drive#fileLink", 'id' : parent['id']}], + "mimeType": "application/vnd.google-apps.folder" }) + parent.Upload() + else: + if (not isInitial or createRoot) and len(existingFolder) > 0: + parent=existingFolder[0] + for f in os.listdir(os.path.join(prevDir,uploadFile)): + if f not in ignoreFiles: + copyToDrive(drive, f, True, replaceFiles, ignoreFiles, parent, os.path.join(prevDir,uploadFile)) + else: + if os.path.basename(uploadFile) not in ignoreFiles: + existingFiles=drive.ListFile({'q' : "title = '%s' and '%s' in parents and trashed = false" % (os.path.basename(uploadFile), parent['id'])}).GetList() + if len(existingFiles) > 0: + driveFile=existingFiles[0] + else: + driveFile = drive.CreateFile({'title': os.path.basename(uploadFile), 'parents' : [{"kind": "drive#fileLink", 'id' : parent['id']}], }) + driveFile.SetContentFile(os.path.join(prevDir,uploadFile)) + driveFile.Upload() + +def watchChange(drive, channel_id, channel_type, channel_address, + channel_token=None, expiration=None): + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + """Watch for all changes to a user's Drive. + Args: + service: Drive API service instance. + channel_id: Unique string that identifies this channel. + channel_type: Type of delivery mechanism used for this channel. + channel_address: Address where notifications are delivered. + channel_token: An arbitrary string delivered to the target address with + each notification delivered over this channel. Optional. + channel_address: Address where notifications are delivered. Optional. + Returns: + The created channel if successful + Raises: + apiclient.errors.HttpError: if http request to create channel fails. + """ + body = { + 'id': channel_id, + 'type': channel_type, + 'address': channel_address + } + if channel_token: + body['token'] = channel_token + if expiration: + body['expiration'] = expiration + return drive.auth.service.changes().watch(body=body).execute() + +def watchFile(drive, file_id, channel_id, channel_type, channel_address, + channel_token=None, expiration=None): + """Watch for any changes to a specific file. + Args: + service: Drive API service instance. + file_id: ID of the file to watch. + channel_id: Unique string that identifies this channel. + channel_type: Type of delivery mechanism used for this channel. + channel_address: Address where notifications are delivered. + channel_token: An arbitrary string delivered to the target address with + each notification delivered over this channel. Optional. + channel_address: Address where notifications are delivered. Optional. + Returns: + The created channel if successful + Raises: + apiclient.errors.HttpError: if http request to create channel fails. + """ + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + + body = { + 'id': channel_id, + 'type': channel_type, + 'address': channel_address + } + if channel_token: + body['token'] = channel_token + if expiration: + body['expiration'] = expiration + return drive.auth.service.files().watch(fileId=file_id, body=body).execute() + +def stopChannel(drive, channel_id, resource_id): + """Stop watching to a specific channel. + Args: + service: Drive API service instance. + channel_id: ID of the channel to stop. + resource_id: Resource ID of the channel to stop. + Raises: + apiclient.errors.HttpError: if http request to create channel fails. + """ + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + service=drive.auth.service + body = { + 'id': channel_id, + 'resourceId': resource_id + } + return drive.auth.service.channels().stop(body=body).execute() + +def getChangeById (drive, change_id): + if not drive: + drive=getDrive() + if drive.auth.access_token_expired: + drive.auth.Refresh() + """Print a single Change resource information. + + Args: + service: Drive API service instance. + change_id: ID of the Change resource to retrieve. + """ + try: + change = drive.auth.service.changes().get(changeId=change_id).execute() + return change + except errors.HttpError, error: + web.app.logger.exception(error) + return None diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 7824ddd0..9d107cb2 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -7,6 +7,45 @@ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if show_authenticate_google_drive %} +
+ Authenticate Google Drive +
+ {% else %} + {% if content.config_google_drive_watch_changes_response %} + +
+ + + Revoke + + {% else %} + Enable watch of metadata.db + {% endif %} +
+ {% endif %} +
@@ -80,3 +119,22 @@
{% endblock %} +{% block js %} + +{% endblock %} diff --git a/cps/ub.py b/cps/ub.py index 19f85880..d5063866 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -11,6 +11,7 @@ import traceback import logging from werkzeug.security import generate_password_hash from flask_babel import gettext as _ +import json dbpath = os.path.join(os.path.normpath(os.path.dirname(os.path.realpath(__file__)) + os.sep + ".." + os.sep), "app.db") engine = create_engine('sqlite:///{0}'.format(dbpath), echo=False) @@ -269,6 +270,12 @@ class Settings(Base): config_anonbrowse = Column(SmallInteger, default=0) config_public_reg = Column(SmallInteger, default=0) config_default_role = Column(SmallInteger, default=0) + config_use_google_drive = Column(Boolean) + config_google_drive_client_id = Column(String) + config_google_drive_client_secret = Column(String) + config_google_drive_folder = Column(String) + config_google_drive_calibre_url_base = Column(String) + config_google_drive_watch_changes_response = Column(String) def __repr__(self): pass @@ -295,7 +302,17 @@ class Config: self.config_anonbrowse = data.config_anonbrowse self.config_public_reg = data.config_public_reg self.config_default_role = data.config_default_role - if self.config_calibre_dir is not None: + self.config_use_google_drive = data.config_use_google_drive + self.config_google_drive_client_id = data.config_google_drive_client_id + self.config_google_drive_client_secret = data.config_google_drive_client_secret + self.config_google_drive_calibre_url_base = data.config_google_drive_calibre_url_base + self.config_google_drive_folder = data.config_google_drive_folder + if data.config_google_drive_watch_changes_response: + self.config_google_drive_watch_changes_response = json.loads(data.config_google_drive_watch_changes_response) + else: + self.config_google_drive_watch_changes_response=None + + if (self.config_calibre_dir is not None and not self.config_use_google_drive) or os.path.exists(self.config_calibre_dir + '/metadata.db'): self.db_configured = True else: self.db_configured = False @@ -379,6 +396,17 @@ def migrate_Database(): conn.execute("ALTER TABLE Settings ADD column `config_anonbrowse` SmallInteger DEFAULT 0") conn.execute("ALTER TABLE Settings ADD column `config_public_reg` SmallInteger DEFAULT 0") session.commit() + + try: + session.query(exists().where(Settings.config_use_google_drive)).scalar() + except exc.OperationalError: + conn = engine.connect() + conn.execute("ALTER TABLE Settings ADD column `config_use_google_drive` INTEGER DEFAULT 0") + conn.execute("ALTER TABLE Settings ADD column `config_google_drive_client_id` String DEFAULT ''") + conn.execute("ALTER TABLE Settings ADD column `config_google_drive_client_secret` String DEFAULT ''") + conn.execute("ALTER TABLE Settings ADD column `config_google_drive_calibre_url_base` INTEGER DEFAULT 0") + conn.execute("ALTER TABLE Settings ADD column `config_google_drive_folder` String DEFAULT ''") + conn.execute("ALTER TABLE Settings ADD column `config_google_drive_watch_changes_response` String DEFAULT ''") try: session.query(exists().where(Settings.config_default_role)).scalar() session.commit() diff --git a/cps/web.py b/cps/web.py index 4e644cc1..53a5b1a5 100755 --- a/cps/web.py +++ b/cps/web.py @@ -1,12 +1,14 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from pydrive.auth import GoogleAuth + import mimetypes import logging from logging.handlers import RotatingFileHandler from tempfile import gettempdir import textwrap from flask import Flask, render_template, session, request, Response, redirect, url_for, send_from_directory, \ - make_response, g, flash, abort + make_response, g, flash, abort, send_file import ub from ub import config import helper @@ -41,7 +43,17 @@ import re import db from shutil import move, copyfile from tornado.ioloop import IOLoop +import shutil import StringIO +from shutil import move +import gdriveutils +import io +import hashlib +import threading + +import time + +current_milli_time = lambda: int(round(time.time() * 1000)) try: from wand.image import Image @@ -52,13 +64,67 @@ except ImportError, e: from cgi import escape # Global variables +gdrive_watch_callback_token='target=calibreweb-watch_files' global_task = None +def md5(fname): + hash_md5 = hashlib.md5() + with open(fname, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + +class Singleton: + """ + A non-thread-safe helper class to ease implementing singletons. + This should be used as a decorator -- not a metaclass -- to the + class that should be a singleton. + + The decorated class can define one `__init__` function that + takes only the `self` argument. Also, the decorated class cannot be + inherited from. Other than that, there are no restrictions that apply + to the decorated class. + + To get the singleton instance, use the `Instance` method. Trying + to use `__call__` will result in a `TypeError` being raised. + + """ + + def __init__(self, decorated): + self._decorated = decorated + + def Instance(self): + """ + Returns the singleton instance. Upon its first call, it creates a + new instance of the decorated class and calls its `__init__` method. + On all subsequent calls, the already created instance is returned. + + """ + try: + return self._instance + except AttributeError: + self._instance = self._decorated() + return self._instance + + def __call__(self): + raise TypeError('Singletons must be accessed through `Instance()`.') + + def __instancecheck__(self, inst): + return isinstance(inst, self._decorated) + +@Singleton +class Gauth: + def __init__(self): + self.auth=GoogleAuth(settings_file='settings.yaml') + +@Singleton +class Gdrive: + def __init__(self): + self.drive=gdriveutils.getDrive(Gauth.Instance().auth) -# Proxy Helper class class ReverseProxied(object): """Wrap the application in this middleware and configure the - front-end server to add these headers, to let you quietly bind + front-end server to add these headers, to let you quietly bind this to a URL other than / and to an HTTP scheme that is different than what is used locally. @@ -133,6 +199,9 @@ lm.anonymous_user = ub.Anonymous app.secret_key = 'A0Zr98j/3yX R~XHH!jmN]LWX/,?RT' db.setup_db() +def is_gdrive_ready(): + return os.path.exists('settings.yaml') and os.path.exists('gdrive_credentials') + @babel.localeselector def get_locale(): # if a user is logged in, use the locale from the user settings @@ -187,6 +256,12 @@ def authenticate(): 'You have to login with proper credentials', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'}) +def updateGdriveCalibreFromLocal(): + gdriveutils.backupCalibreDbAndOptionalDownload(Gdrive.Instance().drive) + gdriveutils.copyToDrive(Gdrive.Instance().drive, config.config_calibre_dir, False, True) + for x in os.listdir(config.config_calibre_dir): + if os.path.isdir(os.path.join(config.config_calibre_dir,x)): + shutil.rmtree(os.path.join(config.config_calibre_dir,x)) def requires_basic_auth_if_no_ano(f): @wraps(f) @@ -286,6 +361,17 @@ def formatdate(val): formatdate = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S") return format_date(formatdate, format='medium',locale=get_locale()) +@app.template_filter('strftime') +def timestamptodate(date, fmt=None): + date=datetime.datetime.fromtimestamp( + int(date)/1000 + ) + native = date.replace(tzinfo=None) + if fmt: + format=fmt + else: + format='%d %m %Y - %H:%S' + return native.strftime(format) def admin_required(f): """ @@ -668,8 +754,15 @@ def get_opds_download_link(book_id, format): file_name = book.title if len(book.authors) > 0: file_name = book.authors[0].name + '-' + file_name - file_name = helper.get_valid_filename(file_name) - response = make_response(send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + format)) + + if config.config_use_google_drive: + df=gdriveutils.getFileFromEbooksFolder(Gdrive.Instance().drive, book.path, '%s.%s' % (data.name, format)) + download_url = df.metadata.get('downloadUrl') + resp, content = df.auth.Get_Http_Object().request(download_url) + response=send_file(io.BytesIO(content)) + else: + file_name = helper.get_valid_filename(file_name) + response = make_response(send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + format)) response.headers["Content-Disposition"] = "attachment; filename=\"%s.%s\"" % (data.name, format) return response @@ -802,7 +895,9 @@ def hot_books(page): hot_books = all_books.offset(off).limit(config.config_books_per_page) entries = list() for book in hot_books: - entries.append(db.session.query(db.Books).filter(filter).filter(db.Books.id == book.Downloads.book_id).first()) + entry=db.session.query(db.Books).filter(filter).filter(db.Books.id == book.Downloads.book_id).first() + if entry: + entries.append(entry) numBooks = entries.__len__() pagination = Pagination(page, config.config_books_per_page, numBooks) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, @@ -1037,6 +1132,99 @@ def stats(): categorycounter=categorys, seriecounter=series, title=_(u"Statistics")) +#@app.route("/load_gdrive") +#@login_required +#@admin_required +#def load_all_gdrive_folder_ids(): +# books=db.session.query(db.Books).all() +# for book in books: +# gdriveutils.getFolderId(book.path, Gdrive.Instance().drive) +# return + +@app.route("/gdrive/authenticate") +@login_required +@admin_required +def authenticate_google_drive(): + authUrl=Gauth.Instance().auth.GetAuthUrl() + return redirect(authUrl) + +@app.route("/gdrive/callback") +def google_drive_callback(): + auth_code = request.args.get('code') + credentials = Gauth.Instance().auth.flow.step2_exchange(auth_code) + with open('gdrive_credentials' ,'w') as f: + f.write(credentials.to_json()) + return redirect(url_for('configuration')) + +@app.route("/gdrive/watch/subscribe") +@login_required +@admin_required +def watch_gdrive(): + if not config.config_google_drive_watch_changes_response: + address = '%scalibre-web/gdrive/watch/callback' % config.config_google_drive_calibre_url_base + notification_id=str(uuid4()) + result = gdriveutils.watchChange(Gdrive.Instance().drive, notification_id, + 'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000) + print (result) + settings = ub.session.query(ub.Settings).first() + settings.config_google_drive_watch_changes_response=json.dumps(result) + ub.session.merge(settings) + ub.session.commit() + settings = ub.session.query(ub.Settings).first() + config.loadSettings() + + print (settings.config_google_drive_watch_changes_response) + + return redirect(url_for('configuration')) + +@app.route("/gdrive/watch/revoke") +@login_required +@admin_required +def revoke_watch_gdrive(): + last_watch_response=config.config_google_drive_watch_changes_response + if last_watch_response: + response=gdriveutils.stopChannel(Gdrive.Instance().drive, last_watch_response['id'], last_watch_response['resourceId']) + settings = ub.session.query(ub.Settings).first() + settings.config_google_drive_watch_changes_response=None + ub.session.merge(settings) + ub.session.commit() + config.loadSettings() + return redirect(url_for('configuration')) + +@app.route("/gdrive/watch/callback", methods=['GET', 'POST']) +def on_received_watch_confirmation(): + app.logger.info (request.headers) + if request.headers.get('X-Goog-Channel-Token') == gdrive_watch_callback_token \ + and request.headers.get('X-Goog-Resource-State') == 'change' \ + and request.data: + + data=request.data + + def updateMetaData(): + app.logger.info ('Change received from gdrive') + app.logger.info (data) + try: + j=json.loads(data) + app.logger.info ('Getting change details') + response=gdriveutils.getChangeById(Gdrive.Instance().drive, j['id']) + app.logger.info (response) + if response: + dbpath = os.path.join(config.config_calibre_dir, "metadata.db") + if not response['deleted'] and response['file']['title'] == 'metadata.db' and response['file']['md5Checksum'] != md5(dbpath): + app.logger.info ('Database file updated') + copyfile (dbpath, config.config_calibre_dir + "/metadata.db_" + str(current_milli_time())) + app.logger.info ('Backing up existing and downloading updated metadata.db') + gdriveutils.downloadFile(Gdrive.Instance().drive, None, "metadata.db", config.config_calibre_dir + "/tmp_metadata.db") + app.logger.info ('Setting up new DB') + os.rename(config.config_calibre_dir + "/tmp_metadata.db", dbpath) + db.setup_db() + except Exception, e: + app.logger.exception(e) + + updateMetaData() + return '' + + @app.route("/shutdown") @login_required @admin_required @@ -1173,8 +1361,15 @@ def advanced_search(): @app.route("/cover/") @login_required_if_no_ano def get_cover(cover_path): - return send_from_directory(os.path.join(config.config_calibre_dir, cover_path), "cover.jpg") + if config.config_use_google_drive: + df=gdriveutils.getFileFromEbooksFolder(Gdrive.Instance().drive, cover_path, 'cover.jpg') + download_url = df.metadata.get('webContentLink') + return redirect(download_url) + else: + return send_from_directory(os.path.join(config.config_calibre_dir, cover_path), "cover.jpg") + resp.headers['Content-Type']='image/jpeg' + return resp @app.route("/opds/thumb_240_240/") @app.route("/opds/cover_240_240/") @@ -1183,7 +1378,12 @@ def get_cover(cover_path): @requires_basic_auth_if_no_ano def feed_get_cover(book_id): book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - return send_from_directory(os.path.join(config.config_calibre_dir, book.path), "cover.jpg") + if config.config_use_google_drive: + df=gdriveutils.getFileFromEbooksFolder(Gdrive.Instance().drive, cover_path, 'cover.jpg') + download_url = df.metadata.get('webContentLink') + return redirect(download_url) + else: + return send_from_directory(os.path.join(config.config_calibre_dir, cover_path), "cover.jpg") def render_read_books(page, are_read, as_xml=False): readBooks=ub.session.query(ub.ReadBook).filter(ub.ReadBook.user_id == int(current_user.id)).filter(ub.ReadBook.is_read == True).all() @@ -1308,8 +1508,13 @@ def get_download_link(book_id, format): if len(book.authors) > 0: file_name = book.authors[0].name + '-' + file_name file_name = helper.get_valid_filename(file_name) - response = make_response( - send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + format)) + if config.config_use_google_drive: + df=gdriveutils.getFileFromEbooksFolder(Gdrive.Instance().drive, book.path, '%s.%s' % (data.name, format)) + download_url = df.metadata.get('downloadUrl') + resp, content = df.auth.Get_Http_Object().request(download_url) + response=send_file(io.BytesIO(content)) + else: + response = make_response(send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + format)) try: response.headers["Content-Type"] = mimetypes.types_map['.' + format] except: @@ -1682,6 +1887,35 @@ def configuration_helper(origin): if content.config_calibre_dir != to_save["config_calibre_dir"]: content.config_calibre_dir = to_save["config_calibre_dir"] db_change = True + ##Google drive setup + create_new_yaml=False + if "config_google_drive_client_id" in to_save: + if content.config_google_drive_client_id != to_save["config_google_drive_client_id"]: + content.config_google_drive_client_id = to_save["config_google_drive_client_id"] + create_new_yaml=True + if "config_google_drive_client_secret" in to_save: + if content.config_google_drive_client_secret != to_save["config_google_drive_client_secret"]: + content.config_google_drive_client_secret = to_save["config_google_drive_client_secret"] + create_new_yaml=True + if "config_google_drive_calibre_url_base" in to_save: + if content.config_google_drive_calibre_url_base != to_save["config_google_drive_calibre_url_base"]: + content.config_google_drive_calibre_url_base = to_save["config_google_drive_calibre_url_base"] + create_new_yaml=True + if ("config_use_google_drive" in to_save and not content.config_use_google_drive) or ("config_use_google_drive" not in to_save and content.config_use_google_drive): + content.config_use_google_drive = "config_use_google_drive" in to_save + db_change = True + if not content.config_use_google_drive: + create_new_yaml=False + if create_new_yaml: + with open('settings.yaml', 'w') as f: + with open('gdrive_template.yaml' ,'r') as t: + f.write(t.read() % {'client_id' : content.config_google_drive_client_id, 'client_secret' : content.config_google_drive_client_secret, + "redirect_uri" : content.config_google_drive_calibre_url_base + 'gdrive/callback'}) + if "config_google_drive_folder" in to_save: + if content.config_google_drive_folder != to_save["config_google_drive_folder"]: + content.config_google_drive_folder = to_save["config_google_drive_folder"] + db_change = True + ## if "config_port" in to_save: if content.config_port != int(to_save["config_port"]): content.config_port = int(to_save["config_port"]) @@ -1720,6 +1954,8 @@ def configuration_helper(origin): if "passwd_role" in to_save: content.config_default_role = content.config_default_role + ub.ROLE_PASSWD try: + if content.config_use_google_drive and is_gdrive_ready() and not os.path.exists(config.config_calibre_dir + "/metadata.db"): + gdriveutils.downloadFile(Gdrive.Instance().drive, None, "metadata.db", config.config_calibre_dir + "/metadata.db") if db_change: if config.db_configured: db.session.close() @@ -1751,6 +1987,7 @@ def configuration_helper(origin): if origin: success = True return render_title_template("config_edit.html", origin=origin, success=success, content=config, + show_authenticate_google_drive=not is_gdrive_ready(), title=_(u"Basic Configuration")) @@ -1999,7 +2236,7 @@ def edit_book(book_id): modify_database_object(input_authors, book.authors, db.Authors, db.session, 'author') if author0_before_edit != book.authors[0].name: edited_books_id.add(book.id) - book.author_sort=helper.get_sorted_author(input_authors[0]) + book.author_sort=helper.get_sorted_author(input_authors[0]) if to_save["cover_url"] and os.path.splitext(to_save["cover_url"])[1].lower() == ".jpg": img = requests.get(to_save["cover_url"]) @@ -2163,6 +2400,8 @@ def edit_book(book_id): author_names.append(author.name) for b in edited_books_id: helper.update_dir_stucture(b, config.config_calibre_dir) + if config.config_use_google_drive: + updateGdriveCalibreFromLocal() if "detail_view" in to_save: return redirect(url_for('show_book', id=book.id)) else: @@ -2227,7 +2466,7 @@ def upload(): if is_author: db_author = is_author else: - db_author = db.Authors(author, helper.get_sorted_author(author), "") + db_author = db.Authors(author, helper.get_sorted_author(author), "") db.session.add(db_author) # combine path and normalize path from windows systems path = os.path.join(author_dir, title_dir).replace('\\','/') @@ -2242,6 +2481,9 @@ def upload(): author_names = [] for author in db_book.authors: author_names.append(author.name) + if config.config_use_google_drive: + if not current_user.role_edit() and not current_user.role_admin(): + updateGdriveCalibreFromLocal() cc = db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all() if current_user.role_edit() or current_user.role_admin(): return render_title_template('book_edit.html', book=db_book, authors=author_names, cc=cc, diff --git a/gdrive_template.yaml b/gdrive_template.yaml new file mode 100644 index 00000000..a6d77eee --- /dev/null +++ b/gdrive_template.yaml @@ -0,0 +1,14 @@ +client_config_backend: settings +client_config: + client_id: %(client_id)s + client_secret: %(client_secret)s + redirect_uri: %(redirect_uri)s + +save_credentials: True +save_credentials_backend: file +save_credentials_file: gdrive_credentials + +get_refresh_token: True + +oauth_scope: + - https://www.googleapis.com/auth/drive