From e0229c917cf627a1f3bacb35e7f8bc4d89c49833 Mon Sep 17 00:00:00 2001 From: otapi <31888571+otapi@users.noreply.github.com> Date: Wed, 10 Oct 2018 23:01:05 +0200 Subject: [PATCH 01/96] Download only shelf Show only titles and download button for specific shelf. Currently only direct link works, e.g: calibre-web/shelfdown/6 --- cps/templates/shelfdown.html | 82 ++++++++++++++++++++++++++++++++++++ cps/web.py | 29 +++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 cps/templates/shelfdown.html diff --git a/cps/templates/shelfdown.html b/cps/templates/shelfdown.html new file mode 100644 index 00000000..32fa78b2 --- /dev/null +++ b/cps/templates/shelfdown.html @@ -0,0 +1,82 @@ + + + + {{instance}} | {{title}} + + + + + + + + + + + {% if g.user.get_theme == 1 %} + + {% endif %} + + + + + {% block header %}{% endblock %} + + +{% block body %} +
+

{{title}}

+
+ + {% for entry in entries %} +
+ +
+

{{entry.title|shortentitle}}

+

+ {% for author in entry.authors %} + {{author.name.replace('|',',')}} + {% if not loop.last %} + & + {% endif %} + {% endfor %} +

+ +
+ +
+ {% if g.user.role_download() %} + {% if entry.data|length %} +
+ {% if entry.data|length < 2 %} + + {% for format in entry.data %} + + {{format.format}} ({{ format.uncompressed_size|filesizeformat }}) + + {% endfor %} + {% else %} + + + {% endif %} +
+ {% endif %} + {% endif %} +
+
+ {% endfor %} +
+
+ +{% endblock %} + + \ No newline at end of file diff --git a/cps/web.py b/cps/web.py index 5d6e766c..b18a2ecc 100644 --- a/cps/web.py +++ b/cps/web.py @@ -2676,6 +2676,35 @@ def show_shelf(shelf_id): return redirect(url_for("index")) +@app.route("/shelfdown/") +@login_required_if_no_ano +def show_shelf_down(shelf_id): + if current_user.is_anonymous: + shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first() + else: + shelf = ub.session.query(ub.Shelf).filter(ub.or_(ub.and_(ub.Shelf.user_id == int(current_user.id), + ub.Shelf.id == shelf_id), + ub.and_(ub.Shelf.is_public == 1, + ub.Shelf.id == shelf_id))).first() + result = list() + # user is allowed to access shelf + if shelf: + books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by( + ub.BookShelf.order.asc()).all() + for book in books_in_shelf: + cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() + if cur_book: + result.append(cur_book) + else: + app.logger.info('Not existing book %s in shelf %s deleted' % (book.book_id, shelf.id)) + ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete() + ub.session.commit() + return render_title_template('shelfdown.html', entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name), + shelf=shelf, page="shelf") + else: + flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error") + return redirect(url_for("index")) + @app.route("/shelf/order/", methods=["GET", "POST"]) @login_required def order_shelf(shelf_id): From c6d3613e576aa8d0cfa943df5ad51f53c749219d Mon Sep 17 00:00:00 2001 From: otapi <31888571+otapi@users.noreply.github.com> Date: Thu, 11 Oct 2018 18:20:38 +0200 Subject: [PATCH 02/96] Add UI link button to shelves --- cps/templates/shelf.html | 6 +++++- cps/web.py | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index 28cd20bb..645dad74 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -2,9 +2,13 @@ {% block body %}

{{title}}

+ {% if g.user.role_download() %} + {{ _('Download') }} + {% endif %} {% if g.user.is_authenticated %} {% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %} -
{{ _('Delete this Shelf') }}
+ +
{{ _('Delete this Shelf') }}
{{ _('Edit Shelf') }} {{ _('Change order') }} {% endif %} diff --git a/cps/web.py b/cps/web.py index b18a2ecc..d8d4bab1 100644 --- a/cps/web.py +++ b/cps/web.py @@ -2677,7 +2677,6 @@ def show_shelf(shelf_id): @app.route("/shelfdown/") -@login_required_if_no_ano def show_shelf_down(shelf_id): if current_user.is_anonymous: shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first() From 9b4ca22254d0ce0b3f6c0d8c7e36a68d55997e44 Mon Sep 17 00:00:00 2001 From: otapi <31888571+otapi@users.noreply.github.com> Date: Thu, 11 Oct 2018 18:39:31 +0200 Subject: [PATCH 03/96] Update shelf.html --- cps/templates/shelf.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index 645dad74..e7d528b3 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -7,8 +7,7 @@ {% endif %} {% if g.user.is_authenticated %} {% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %} - -
{{ _('Delete this Shelf') }}
+
{{ _('Delete this Shelf') }}
{{ _('Edit Shelf') }} {{ _('Change order') }} {% endif %} From 1abbcfa3c6e1f5c3f4db4d4fe513d25d4b49f350 Mon Sep 17 00:00:00 2001 From: Jim Ma Date: Thu, 11 Oct 2018 19:52:30 +0800 Subject: [PATCH 04/96] Add OAuth support: GitHub & Google --- cps/oauth.py | 134 ++++++++++++++++ cps/templates/config_edit.html | 30 ++++ cps/templates/login.html | 17 +- cps/templates/register.html | 15 ++ cps/ub.py | 35 +++++ cps/web.py | 279 ++++++++++++++++++++++++++++++++- requirements.txt | 2 + 7 files changed, 507 insertions(+), 5 deletions(-) create mode 100644 cps/oauth.py diff --git a/cps/oauth.py b/cps/oauth.py new file mode 100644 index 00000000..679e7f31 --- /dev/null +++ b/cps/oauth.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from flask import session +from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user +from sqlalchemy.orm.exc import NoResultFound + + +class OAuthBackend(SQLAlchemyBackend): + """ + Stores and retrieves OAuth tokens using a relational database through + the `SQLAlchemy`_ ORM. + + .. _SQLAlchemy: http://www.sqlalchemy.org/ + """ + def __init__(self, model, session, + user=None, user_id=None, user_required=None, anon_user=None, + cache=None): + super(OAuthBackend, self).__init__(model, session, user, user_id, user_required, anon_user, cache) + + def get(self, blueprint, user=None, user_id=None): + if blueprint.name + '_oauth_token' in session and session[blueprint.name + '_oauth_token'] != '': + return session[blueprint.name + '_oauth_token'] + # check cache + cache_key = self.make_cache_key(blueprint=blueprint, user=user, user_id=user_id) + token = self.cache.get(cache_key) + if token: + return token + + # if not cached, make database queries + query = ( + self.session.query(self.model) + .filter_by(provider=blueprint.name) + ) + uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) + u = first(_get_real_user(ref, self.anon_user) + for ref in (user, self.user, blueprint.config.get("user"))) + + use_provider_user_id = False + if blueprint.name + '_oauth_user_id' in session and session[blueprint.name + '_oauth_user_id'] != '': + query = query.filter_by(provider_user_id=session[blueprint.name + '_oauth_user_id']) + use_provider_user_id = True + + if self.user_required and not u and not uid and not use_provider_user_id: + #raise ValueError("Cannot get OAuth token without an associated user") + return None + # check for user ID + if hasattr(self.model, "user_id") and uid: + query = query.filter_by(user_id=uid) + # check for user (relationship property) + elif hasattr(self.model, "user") and u: + query = query.filter_by(user=u) + # if we have the property, but not value, filter by None + elif hasattr(self.model, "user_id"): + query = query.filter_by(user_id=None) + # run query + try: + token = query.one().token + except NoResultFound: + token = None + + # cache the result + self.cache.set(cache_key, token) + + return token + + def set(self, blueprint, token, user=None, user_id=None): + uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) + u = first(_get_real_user(ref, self.anon_user) + for ref in (user, self.user, blueprint.config.get("user"))) + + if self.user_required and not u and not uid: + raise ValueError("Cannot set OAuth token without an associated user") + + # if there was an existing model, delete it + existing_query = ( + self.session.query(self.model) + .filter_by(provider=blueprint.name) + ) + # check for user ID + has_user_id = hasattr(self.model, "user_id") + if has_user_id and uid: + existing_query = existing_query.filter_by(user_id=uid) + # check for user (relationship property) + has_user = hasattr(self.model, "user") + if has_user and u: + existing_query = existing_query.filter_by(user=u) + # queue up delete query -- won't be run until commit() + existing_query.delete() + # create a new model for this token + kwargs = { + "provider": blueprint.name, + "token": token, + } + if has_user_id and uid: + kwargs["user_id"] = uid + if has_user and u: + kwargs["user"] = u + self.session.add(self.model(**kwargs)) + # commit to delete and add simultaneously + self.session.commit() + # invalidate cache + self.cache.delete(self.make_cache_key( + blueprint=blueprint, user=user, user_id=user_id + )) + + def delete(self, blueprint, user=None, user_id=None): + query = ( + self.session.query(self.model) + .filter_by(provider=blueprint.name) + ) + uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) + u = first(_get_real_user(ref, self.anon_user) + for ref in (user, self.user, blueprint.config.get("user"))) + + if self.user_required and not u and not uid: + raise ValueError("Cannot delete OAuth token without an associated user") + + # check for user ID + if hasattr(self.model, "user_id") and uid: + query = query.filter_by(user_id=uid) + # check for user (relationship property) + elif hasattr(self.model, "user") and u: + query = query.filter_by(user=u) + # if we have the property, but not value, filter by None + elif hasattr(self.model, "user_id"): + query = query.filter_by(user_id=None) + # run query + query.delete() + self.session.commit() + # invalidate cache + self.cache.delete(self.make_cache_key( + blueprint=blueprint, user=user, user_id=user_id, + )) diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index b2826b39..0979e465 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -162,6 +162,36 @@
{% endif %} +
+ + + {{_('Obtain GitHub OAuth Credentail')}} +
+
+
+ + +
+
+ + +
+
+
+ + + {{_('Obtain Google OAuth Credentail')}} +
+
+
+ + +
+
+ + +
+
diff --git a/cps/templates/login.html b/cps/templates/login.html index 3e8ebe1e..8e622079 100644 --- a/cps/templates/login.html +++ b/cps/templates/login.html @@ -18,9 +18,24 @@ - {% if remote_login %} + {% if config.config_remote_login %} {{_('Log in with magic link')}} {% endif %} + {% if config.config_use_github_oauth %} + + + + {% endif %} + {% if config.config_use_google_oauth %} + + + + {% endif %} {% if error %} diff --git a/cps/templates/register.html b/cps/templates/register.html index 70bd10c7..24edc3a2 100644 --- a/cps/templates/register.html +++ b/cps/templates/register.html @@ -12,6 +12,21 @@ + {% if config.config_use_github_oauth %} + + + + {% endif %} + {% if config.config_use_google_oauth %} + + + + {% endif %} {% if error %} diff --git a/cps/ub.py b/cps/ub.py index f1b19d02..8811b972 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -6,6 +6,7 @@ from sqlalchemy import exc from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import * from flask_login import AnonymousUserMixin +from flask_dance.consumer.backend.sqla import OAuthConsumerMixin import sys import os import logging @@ -169,6 +170,12 @@ class User(UserBase, Base): theme = Column(Integer, default=0) +class OAuth(OAuthConsumerMixin, Base): + provider_user_id = Column(String(256)) + user_id = Column(Integer, ForeignKey(User.id)) + user = relationship(User) + + # Class for anonymous user is derived from User base and completly overrides methods and properties for the # anonymous user class Anonymous(AnonymousUserMixin, UserBase): @@ -306,6 +313,12 @@ class Settings(Base): config_use_goodreads = Column(Boolean) config_goodreads_api_key = Column(String) config_goodreads_api_secret = Column(String) + config_use_github_oauth = Column(Boolean) + config_github_oauth_client_id = Column(String) + config_github_oauth_client_secret = Column(String) + config_use_google_oauth = Column(Boolean) + config_google_oauth_client_id = Column(String) + config_google_oauth_client_secret = Column(String) config_mature_content_tags = Column(String) config_logfile = Column(String) config_ebookconverter = Column(Integer, default=0) @@ -378,6 +391,12 @@ class Config: self.config_use_goodreads = data.config_use_goodreads self.config_goodreads_api_key = data.config_goodreads_api_key self.config_goodreads_api_secret = data.config_goodreads_api_secret + self.config_use_github_oauth = data.config_use_github_oauth + self.config_github_oauth_client_id = data.config_github_oauth_client_id + self.config_github_oauth_client_secret = data.config_github_oauth_client_secret + self.config_use_google_oauth = data.config_use_google_oauth + self.config_google_oauth_client_id = data.config_google_oauth_client_id + self.config_google_oauth_client_secret = data.config_google_oauth_client_secret if data.config_mature_content_tags: self.config_mature_content_tags = data.config_mature_content_tags else: @@ -661,6 +680,22 @@ def migrate_Database(): conn.execute("ALTER TABLE Settings ADD column `config_calibre` String DEFAULT ''") session.commit() + try: + session.query(exists().where(Settings.config_use_github_oauth)).scalar() + except exc.OperationalError: + conn = engine.connect() + conn.execute("ALTER TABLE Settings ADD column `config_use_github_oauth` INTEGER DEFAULT 0") + conn.execute("ALTER TABLE Settings ADD column `config_github_oauth_client_id` String DEFAULT ''") + conn.execute("ALTER TABLE Settings ADD column `config_github_oauth_client_secret` String DEFAULT ''") + session.commit() + try: + session.query(exists().where(Settings.config_use_google_oauth)).scalar() + except exc.OperationalError: + conn = engine.connect() + conn.execute("ALTER TABLE Settings ADD column `config_use_google_oauth` INTEGER DEFAULT 0") + conn.execute("ALTER TABLE Settings ADD column `config_google_oauth_client_id` String DEFAULT ''") + conn.execute("ALTER TABLE Settings ADD column `config_google_oauth_client_secret` String DEFAULT ''") + session.commit() # Remove login capability of user Guest conn = engine.connect() conn.execute("UPDATE user SET password='' where nickname = 'Guest' and password !=''") diff --git a/cps/web.py b/cps/web.py index 5d6e766c..0937fdb7 100644 --- a/cps/web.py +++ b/cps/web.py @@ -4,7 +4,7 @@ import mimetypes import logging from logging.handlers import RotatingFileHandler -from flask import (Flask, render_template, request, Response, redirect, +from flask import (Flask, session, render_template, request, Response, redirect, url_for, send_from_directory, make_response, g, flash, abort, Markup) from flask import __version__ as flaskVersion @@ -55,6 +55,11 @@ from redirect import redirect_back import time import server from reverseproxy import ReverseProxied +from flask_dance.contrib.github import make_github_blueprint, github +from flask_dance.contrib.google import make_google_blueprint, google +from flask_dance.consumer import oauth_authorized, oauth_error +from sqlalchemy.orm.exc import NoResultFound +from oauth import OAuthBackend try: from googleapiclient.errors import HttpError except ImportError: @@ -114,6 +119,7 @@ EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit' # EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] + (['rar','cbr'] if rar_support else [])) +oauth_check = [] '''class ReverseProxied(object): """Wrap the application in this middleware and configure the @@ -348,6 +354,35 @@ def remote_login_required(f): return inner +def github_oauth_required(f): + @wraps(f) + def inner(*args, **kwargs): + if config.config_use_github_oauth: + return f(*args, **kwargs) + if request.is_xhr: + data = {'status': 'error', 'message': 'Not Found'} + response = make_response(json.dumps(data, ensure_ascii=False)) + response.headers["Content-Type"] = "application/json; charset=utf-8" + return response, 404 + abort(404) + + return inner + + +def google_oauth_required(f): + @wraps(f) + def inner(*args, **kwargs): + if config.config_use_google_oauth: + return f(*args, **kwargs) + if request.is_xhr: + data = {'status': 'error', 'message': 'Not Found'} + response = make_response(json.dumps(data, ensure_ascii=False)) + response.headers["Content-Type"] = "application/json; charset=utf-8" + return response, 404 + abort(404) + + return inner + # custom jinja filters # pagination links in jinja @@ -2264,6 +2299,7 @@ def register(): try: ub.session.add(content) ub.session.commit() + register_user_with_oauth(content) helper.send_registration_mail(to_save["email"],to_save["nickname"], password) except Exception: ub.session.rollback() @@ -2279,7 +2315,8 @@ def register(): flash(_(u"This username or e-mail address is already in use."), category="error") return render_title_template('register.html', title=_(u"register"), page="register") - return render_title_template('register.html', title=_(u"register"), page="register") + register_user_with_oauth() + return render_title_template('register.html', config=config, title=_(u"register"), page="register") @app.route('/login', methods=['GET', 'POST']) @@ -2304,8 +2341,7 @@ def login(): # if next_url is None or not is_safe_url(next_url): next_url = url_for('index') - return render_title_template('login.html', title=_(u"login"), next_url=next_url, - remote_login=config.config_remote_login, page="login") + return render_title_template('login.html', title=_(u"login"), next_url=next_url, config=config, page="login") @app.route('/logout') @@ -2313,6 +2349,7 @@ def login(): def logout(): if current_user is not None and current_user.is_authenticated: logout_user() + logout_oauth_user() return redirect(url_for('login')) @@ -3019,6 +3056,29 @@ def configuration_helper(origin): content.config_goodreads_api_key = to_save["config_goodreads_api_key"] if "config_goodreads_api_secret" in to_save: content.config_goodreads_api_secret = to_save["config_goodreads_api_secret"] + + # GitHub OAuth configuration + content.config_use_github_oauth = ("config_use_github_oauth" in to_save and to_save["config_use_github_oauth"] == "on") + if "config_github_oauth_client_id" in to_save: + content.config_github_oauth_client_id = to_save["config_github_oauth_client_id"] + if "config_github_oauth_client_secret" in to_save: + content.config_github_oauth_client_secret = to_save["config_github_oauth_client_secret"] + + if content.config_github_oauth_client_id != config.config_github_oauth_client_id or \ + content.config_github_oauth_client_secret != config.config_github_oauth_client_secret: + reboot_required = True + + # Google OAuth configuration + content.config_use_google_oauth = ("config_use_google_oauth" in to_save and to_save["config_use_google_oauth"] == "on") + if "config_google_oauth_client_id" in to_save: + content.config_google_oauth_client_id = to_save["config_google_oauth_client_id"] + if "config_google_oauth_client_secret" in to_save: + content.config_google_oauth_client_secret = to_save["config_google_oauth_client_secret"] + + if content.config_google_oauth_client_id != config.config_google_oauth_client_id or \ + content.config_google_oauth_client_secret != config.config_google_oauth_client_secret: + reboot_required = True + if "config_log_level" in to_save: content.config_log_level = int(to_save["config_log_level"]) if content.config_logfile != to_save["config_logfile"]: @@ -3883,3 +3943,214 @@ def convert_bookformat(book_id): else: flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error") return redirect(request.environ["HTTP_REFERER"]) + + +def register_oauth_blueprint(blueprint): + if blueprint.name != "": + oauth_check.append(blueprint.name) + + +def register_user_with_oauth(user=None): + all_oauth = [] + for oauth in oauth_check: + if oauth + '_oauth_user_id' in session and session[oauth + '_oauth_user_id'] != '': + all_oauth.append(oauth) + if len(all_oauth) == 0: + return + if user is None: + flash(_(u"Register with %s" % ", ".join(all_oauth)), category="success") + else: + for oauth in all_oauth: + # Find this OAuth token in the database, or create it + query = ub.session.query(ub.OAuth).filter_by( + provider=oauth, + provider_user_id=session[oauth + "_oauth_user_id"], + ) + try: + oauth = query.one() + oauth.user_id = user.id + except NoResultFound: + # no found, return error + return + try: + ub.session.commit() + except Exception as e: + app.logger.exception(e) + ub.session.rollback() + + +def logout_oauth_user(): + for oauth in oauth_check: + if oauth + '_oauth_user_id' in session: + session.pop(oauth + '_oauth_user_id') + + +github_blueprint = make_github_blueprint( + client_id=config.config_github_oauth_client_id, + client_secret=config.config_github_oauth_client_secret, + redirect_to="github_login",) + +google_blueprint = make_google_blueprint( + client_id=config.config_google_oauth_client_id, + client_secret=config.config_google_oauth_client_secret, + redirect_to="google_login", + scope=[ + "https://www.googleapis.com/auth/plus.me", + "https://www.googleapis.com/auth/userinfo.email", + ] +) + +app.register_blueprint(google_blueprint, url_prefix="/login") +app.register_blueprint(github_blueprint, url_prefix='/login') + +github_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) +google_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) + +register_oauth_blueprint(github_blueprint) +register_oauth_blueprint(google_blueprint) + + +@oauth_authorized.connect_via(github_blueprint) +def github_logged_in(blueprint, token): + if not token: + flash("Failed to log in with GitHub.", category="error") + return False + + resp = blueprint.session.get("/user") + if not resp.ok: + msg = "Failed to fetch user info from GitHub." + flash(msg, category="error") + return False + + github_info = resp.json() + github_user_id = str(github_info["id"]) + return oauth_update_token(blueprint, token, github_user_id) + + +@oauth_authorized.connect_via(google_blueprint) +def google_logged_in(blueprint, token): + if not token: + flash("Failed to log in with Google.", category="error") + return False + + resp = blueprint.session.get("/oauth2/v2/userinfo") + if not resp.ok: + msg = "Failed to fetch user info from Google." + flash(msg, category="error") + return False + + google_info = resp.json() + google_user_id = str(google_info["id"]) + + return oauth_update_token(blueprint, token, google_user_id) + + +def oauth_update_token(blueprint, token, provider_user_id): + session[blueprint.name + "_oauth_user_id"] = provider_user_id + session[blueprint.name + "_oauth_token"] = token + + # Find this OAuth token in the database, or create it + query = ub.session.query(ub.OAuth).filter_by( + provider=blueprint.name, + provider_user_id=provider_user_id, + ) + try: + oauth = query.one() + # update token + oauth.token = token + except NoResultFound: + oauth = ub.OAuth( + provider=blueprint.name, + provider_user_id=provider_user_id, + token=token, + ) + try: + ub.session.add(oauth) + ub.session.commit() + except Exception as e: + app.logger.exception(e) + ub.session.rollback() + + # Disable Flask-Dance's default behavior for saving the OAuth token + return False + + +def bind_oauth_or_register(provider, provider_user_id, redirect_url): + query = ub.session.query(ub.OAuth).filter_by( + provider=provider, + provider_user_id=provider_user_id, + ) + try: + oauth = query.one() + # already bind with user, just login + if oauth.user: + login_user(oauth.user) + return redirect(url_for('index')) + else: + # bind to current user + if current_user and not current_user.is_anonymous: + oauth.user = current_user + try: + ub.session.add(oauth) + ub.session.commit() + except Exception as e: + app.logger.exception(e) + ub.session.rollback() + return redirect(url_for('register')) + except NoResultFound: + return redirect(url_for(redirect_url)) + + +# notify on OAuth provider error +@oauth_error.connect_via(github_blueprint) +def github_error(blueprint, error, error_description=None, error_uri=None): + msg = ( + "OAuth error from {name}! " + "error={error} description={description} uri={uri}" + ).format( + name=blueprint.name, + error=error, + description=error_description, + uri=error_uri, + ) + flash(msg, category="error") + + +@app.route('/github') +@github_oauth_required +def github_login(): + if not github.authorized: + return redirect(url_for('github.login')) + account_info = github.get('/user') + if account_info.ok: + account_info_json = account_info.json() + return bind_oauth_or_register(github_blueprint.name, account_info_json['id'], 'github.login') + flash(_(u"GitHub Oauth error, please retry later."), category="error") + return redirect(url_for('login')) + + +@app.route('/google') +@google_oauth_required +def google_login(): + if not google.authorized: + return redirect(url_for("google.login")) + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + account_info_json = resp.json() + return bind_oauth_or_register(google_blueprint.name, account_info_json['id'], 'google.login') + flash(_(u"Google Oauth error, please retry later."), category="error") + return redirect(url_for('login')) + + +@oauth_error.connect_via(google_blueprint) +def google_error(blueprint, error, error_description=None, error_uri=None): + msg = ( + "OAuth error from {name}! " + "error={error} description={description} uri={uri}" + ).format( + name=blueprint.name, + error=error, + description=error_description, + uri=error_uri, + ) + flash(msg, category="error") diff --git a/requirements.txt b/requirements.txt index 3fb23ea3..2b13eb54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,5 @@ SQLAlchemy>=1.1.0 tornado>=4.1 Wand>=0.4.4 unidecode>=0.04.19 +flask-dance>=0.13.0 +sqlalchemy_utils>=0.33.5 From 4b76b8400da49f1d04e3f1eb67d0553346e7834f Mon Sep 17 00:00:00 2001 From: Jim Ma Date: Sat, 13 Oct 2018 14:40:08 +0800 Subject: [PATCH 05/96] Add OAuth link&unlink in user profile --- cps/templates/user_edit.html | 15 ++++++ cps/web.py | 96 ++++++++++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html index ecf8042e..e5dcb59d 100644 --- a/cps/templates/user_edit.html +++ b/cps/templates/user_edit.html @@ -52,6 +52,21 @@ {% endfor %} + {% if registered_oauth.keys()| length > 0 %} +
+ +
+ {% for oauth, name in registered_oauth.iteritems() %} + + {% if oauth not in oauth_status %} + Link + {% else %} + Unlink + {% endif %} +
+ {% endfor %} +
+ {% endif %}
diff --git a/cps/web.py b/cps/web.py index 0937fdb7..a85bcf31 100644 --- a/cps/web.py +++ b/cps/web.py @@ -119,7 +119,7 @@ EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit' # EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] + (['rar','cbr'] if rar_support else [])) -oauth_check = [] +oauth_check = {} '''class ReverseProxied(object): """Wrap the application in this middleware and configure the @@ -2751,6 +2751,7 @@ def profile(): downloads = list() languages = speaking_language() translations = babel.list_translations() + [LC('en')] + oauth_status = get_oauth_status() for book in content.downloads: downloadBook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() if downloadBook: @@ -2812,11 +2813,11 @@ def profile(): ub.session.rollback() flash(_(u"Found an existing account for this e-mail address."), category="error") return render_title_template("user_edit.html", content=content, downloads=downloads, - title=_(u"%(name)s's profile", name=current_user.nickname)) + title=_(u"%(name)s's profile", name=current_user.nickname, registered_oauth=oauth_check, oauth_status=oauth_status)) flash(_(u"Profile updated"), category="success") return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages, content=content, downloads=downloads, title=_(u"%(name)s's profile", - name=current_user.nickname), page="me") + name=current_user.nickname), page="me", registered_oauth=oauth_check, oauth_status=oauth_status) @app.route("/admin/view") @@ -3945,22 +3946,22 @@ def convert_bookformat(book_id): return redirect(request.environ["HTTP_REFERER"]) -def register_oauth_blueprint(blueprint): +def register_oauth_blueprint(blueprint, show_name): if blueprint.name != "": - oauth_check.append(blueprint.name) + oauth_check[blueprint.name] = show_name def register_user_with_oauth(user=None): - all_oauth = [] - for oauth in oauth_check: + all_oauth = {} + for oauth in oauth_check.keys(): if oauth + '_oauth_user_id' in session and session[oauth + '_oauth_user_id'] != '': - all_oauth.append(oauth) - if len(all_oauth) == 0: + all_oauth[oauth] = oauth_check[oauth] + if len(all_oauth.keys()) == 0: return if user is None: - flash(_(u"Register with %s" % ", ".join(all_oauth)), category="success") + flash(_(u"Register with %s" % ", ".join(list(all_oauth.values()))), category="success") else: - for oauth in all_oauth: + for oauth in all_oauth.keys(): # Find this OAuth token in the database, or create it query = ub.session.query(ub.OAuth).filter_by( provider=oauth, @@ -3980,7 +3981,7 @@ def register_user_with_oauth(user=None): def logout_oauth_user(): - for oauth in oauth_check: + for oauth in oauth_check.keys(): if oauth + '_oauth_user_id' in session: session.pop(oauth + '_oauth_user_id') @@ -4006,20 +4007,22 @@ app.register_blueprint(github_blueprint, url_prefix='/login') github_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) google_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) -register_oauth_blueprint(github_blueprint) -register_oauth_blueprint(google_blueprint) + +if config.config_use_github_oauth: + register_oauth_blueprint(github_blueprint, 'GitHub') +if config.config_use_google_oauth: + register_oauth_blueprint(google_blueprint, 'Google') @oauth_authorized.connect_via(github_blueprint) def github_logged_in(blueprint, token): if not token: - flash("Failed to log in with GitHub.", category="error") + flash(_("Failed to log in with GitHub."), category="error") return False resp = blueprint.session.get("/user") if not resp.ok: - msg = "Failed to fetch user info from GitHub." - flash(msg, category="error") + flash(_("Failed to fetch user info from GitHub."), category="error") return False github_info = resp.json() @@ -4030,13 +4033,12 @@ def github_logged_in(blueprint, token): @oauth_authorized.connect_via(google_blueprint) def google_logged_in(blueprint, token): if not token: - flash("Failed to log in with Google.", category="error") + flash(_("Failed to log in with Google."), category="error") return False resp = blueprint.session.get("/oauth2/v2/userinfo") if not resp.ok: - msg = "Failed to fetch user info from Google." - flash(msg, category="error") + flash(_("Failed to fetch user info from Google."), category="error") return False google_info = resp.json() @@ -4088,7 +4090,7 @@ def bind_oauth_or_register(provider, provider_user_id, redirect_url): return redirect(url_for('index')) else: # bind to current user - if current_user and not current_user.is_anonymous: + if current_user and current_user.is_authenticated: oauth.user = current_user try: ub.session.add(oauth) @@ -4101,6 +4103,46 @@ def bind_oauth_or_register(provider, provider_user_id, redirect_url): return redirect(url_for(redirect_url)) +def get_oauth_status(): + status = [] + query = ub.session.query(ub.OAuth).filter_by( + user_id=current_user.id, + ) + try: + oauths = query.all() + for oauth in oauths: + status.append(oauth.provider) + return status + except NoResultFound: + return None + + +def unlink_oauth(provider): + if request.host_url + 'me' != request.referrer: + pass + query = ub.session.query(ub.OAuth).filter_by( + provider=provider, + user_id=current_user.id, + ) + try: + oauth = query.one() + if current_user and current_user.is_authenticated: + oauth.user = current_user + try: + ub.session.delete(oauth) + ub.session.commit() + logout_oauth_user() + flash(_("Unlink to %(oauth)s success.", oauth=oauth_check[provider]), category="success") + except Exception as e: + app.logger.exception(e) + ub.session.rollback() + flash(_("Unlink to %(oauth)s failed.", oauth=oauth_check[provider]), category="error") + except NoResultFound: + app.logger.warning("oauth %s for user %d not fount" % (provider, current_user.id)) + flash(_("Not linked to %(oauth)s.", oauth=oauth_check[provider]), category="error") + return redirect(url_for('profile')) + + # notify on OAuth provider error @oauth_error.connect_via(github_blueprint) def github_error(blueprint, error, error_description=None, error_uri=None): @@ -4129,6 +4171,12 @@ def github_login(): return redirect(url_for('login')) +@app.route('/unlink/github', methods=["GET"]) +@login_required +def github_login_unlink(): + return unlink_oauth(github_blueprint.name) + + @app.route('/google') @google_oauth_required def google_login(): @@ -4154,3 +4202,9 @@ def google_error(blueprint, error, error_description=None, error_uri=None): uri=error_uri, ) flash(msg, category="error") + + +@app.route('/unlink/google', methods=["GET"]) +@login_required +def google_login_unlink(): + return unlink_oauth(google_blueprint.name) From 30954cc27f3206fa62142c4881effafe77e7236c Mon Sep 17 00:00:00 2001 From: Krakinou Date: Thu, 10 Jan 2019 23:51:01 +0100 Subject: [PATCH 06/96] Initial LDAP support --- cps/ub.py | 15 +++++++++++++++ cps/web.py | 12 +++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/cps/ub.py b/cps/ub.py index 57dbde6e..14cf0b24 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -14,6 +14,7 @@ import json import datetime from binascii import hexlify import cli +import ldap engine = create_engine('sqlite:///{0}'.format(cli.settingspath), echo=False) Base = declarative_base() @@ -46,6 +47,8 @@ SIDEBAR_PUBLISHER = 4096 DEFAULT_PASS = "admin123" DEFAULT_PORT = int(os.environ.get("CALIBRE_PORT", 8083)) +LDAP_PROVIDER_URL = 'ldap://localhost:389/' +LDAP_PROTOCOL_VERSION = 3 class UserBase: @@ -152,6 +155,13 @@ class UserBase: def __repr__(self): return '' % self.nickname + @staticmethod + def try_login(username, password): + conn = get_ldap_connection() + conn.simple_bind_s( + 'uid={},ou=users,dc=yunohost,dc=org'.format(username), + password + ) # Baseclass for Users in Calibre-Web, settings which are depending on certain users are stored here. It is derived from # User Base (all access methods are declared there) @@ -778,6 +788,11 @@ else: migrate_Database() clean_database() +#get LDAP connection +def get_ldap_connection(): + conn = ldap.initialize(LDAP_PROVIDER_URL) + return conn + # Generate global Settings Object accessible from every file config = Config() searched_ids = {} diff --git a/cps/web.py b/cps/web.py index da240211..c78ae132 100644 --- a/cps/web.py +++ b/cps/web.py @@ -57,6 +57,7 @@ from redirect import redirect_back import time import server from reverseproxy import ReverseProxied +import ldap try: from googleapiclient.errors import HttpError @@ -2342,7 +2343,16 @@ def login(): if request.method == "POST": form = request.form.to_dict() user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower()).first() - if user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest": + try: + app.logger.info("Tryong LDAP connexion") + ub.User.try_login(form['username'], form['password']) + login_user(user, remember=True) + flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") + return redirect_back(url_for("index")) + except ldap.INVALID_CREDENTIALS: + ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) + app.logger.info('LDAP Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) + if user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest" and not user.is_authenticated: login_user(user, remember=True) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") return redirect_back(url_for("index")) From 8d284b151d79daa892656a20c668953409659f85 Mon Sep 17 00:00:00 2001 From: Krakinou Date: Sat, 12 Jan 2019 12:52:27 +0100 Subject: [PATCH 07/96] Edit html config --- cps/templates/config_edit.html | 15 +++++++++++++++ cps/ub.py | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index b2826b39..c12d3a9f 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -162,6 +162,21 @@
{% endif %} +
+ + +
+
+
+ + +
+
+ + +
+
+ diff --git a/cps/ub.py b/cps/ub.py index 14cf0b24..325b5132 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -320,6 +320,9 @@ class Settings(Base): config_use_goodreads = Column(Boolean) config_goodreads_api_key = Column(String) config_goodreads_api_secret = Column(String) + config_use_ldap = Column(Boolean) + config_ldap_provider_url = Column(String) + config_ldap_dn = Column(String) config_mature_content_tags = Column(String) config_logfile = Column(String) config_ebookconverter = Column(Integer, default=0) @@ -392,6 +395,9 @@ class Config: self.config_use_goodreads = data.config_use_goodreads self.config_goodreads_api_key = data.config_goodreads_api_key self.config_goodreads_api_secret = data.config_goodreads_api_secret + self.config_use_ldap = data.config_use_ldap + self.config_ldap_provider_url = data.config_ldap_provider_url + self.config_ldap_dn = data.config_ldap_dn if data.config_mature_content_tags: self.config_mature_content_tags = data.config_mature_content_tags else: @@ -678,7 +684,14 @@ def migrate_Database(): conn.execute("ALTER TABLE Settings ADD column `config_converterpath` String DEFAULT ''") conn.execute("ALTER TABLE Settings ADD column `config_calibre` String DEFAULT ''") session.commit() - + try: + session.query(exists().where(Settings.config_use_ldap)).scalar() + except exc.OperationalError: + conn = engine.connect() + conn.execute("ALTER TABLE Settings ADD column `config_use_ldap` INTEGER DEFAULT 0") + conn.execute("ALTER TABLE Settings ADD column `config_ldap_provider_url` String DEFAULT ''") + conn.execute("ALTER TABLE Settings ADD column `config_ldap_dn` String DEFAULT ''") + session.commit() # Remove login capability of user Guest conn = engine.connect() conn.execute("UPDATE user SET password='' where nickname = 'Guest' and password !=''") From 91f0908059d56f80a2cce4d326fcd3ce34c9ca78 Mon Sep 17 00:00:00 2001 From: Krakinou Date: Sat, 12 Jan 2019 18:07:03 +0100 Subject: [PATCH 08/96] insert into db connect via LDAP config --- cps/ub.py | 8 +++++--- cps/web.py | 34 ++++++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/cps/ub.py b/cps/ub.py index 325b5132..6de9059c 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -47,8 +47,6 @@ SIDEBAR_PUBLISHER = 4096 DEFAULT_PASS = "admin123" DEFAULT_PORT = int(os.environ.get("CALIBRE_PORT", 8083)) -LDAP_PROVIDER_URL = 'ldap://localhost:389/' -LDAP_PROTOCOL_VERSION = 3 class UserBase: @@ -155,9 +153,12 @@ class UserBase: def __repr__(self): return '' % self.nickname + #Login via LDAP method @staticmethod def try_login(username, password): conn = get_ldap_connection() + print "bind : {}".format(config.config_ldap_dn) + print "replace :{}".format(config.config_ldap_dn.replace("%s", username)) conn.simple_bind_s( 'uid={},ou=users,dc=yunohost,dc=org'.format(username), password @@ -803,7 +804,8 @@ else: #get LDAP connection def get_ldap_connection(): - conn = ldap.initialize(LDAP_PROVIDER_URL) + print "login to LDAP server ldap://{}".format(config.config_ldap_provider_url) + conn = ldap.initialize('ldap://{}'.format(config.config_ldap_provider_url)) return conn # Generate global Settings Object accessible from every file diff --git a/cps/web.py b/cps/web.py index c78ae132..aaf3e02b 100644 --- a/cps/web.py +++ b/cps/web.py @@ -2343,16 +2343,15 @@ def login(): if request.method == "POST": form = request.form.to_dict() user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower()).first() - try: - app.logger.info("Tryong LDAP connexion") - ub.User.try_login(form['username'], form['password']) - login_user(user, remember=True) - flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") - return redirect_back(url_for("index")) - except ldap.INVALID_CREDENTIALS: - ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) - app.logger.info('LDAP Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) - if user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest" and not user.is_authenticated: + if config.config_use_ldap and ub.User.try_login(form['username'], form['password']): + try: + login_user(user, remember=True) + flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") + return redirect_back(url_for("index")) + except ldap.INVALID_CREDENTIALS: + ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) + app.logger.info('LDAP Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) + elif user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest" and not user.is_authenticated: login_user(user, remember=True) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") return redirect_back(url_for("index")) @@ -3075,6 +3074,21 @@ def configuration_helper(origin): if "config_ebookconverter" in to_save: content.config_ebookconverter = int(to_save["config_ebookconverter"]) + #LDAP configuratop, + if "config_use_ldap" in to_save and to_save["config_use_ldap"] == "on": + if not "config_ldap_provider_url" in to_save or not "content.config_ldap_dn" in to_save: + ub.session.commit() + flash(_(u'Please enter a LDAP provider and a DN'), category="error") + return render_title_template("config_edit.html", content=config, origin=origin, + gdrive=gdriveutils.gdrive_support, gdriveError=gdriveError, + goodreads=goodreads_support, title=_(u"Basic Configuration"), + page="config") + else: + content.config_use_ldap = 1 + content.config_ldap_provider_url = to_save["config_ldap_provider_url"] + content.config_ldap_dn = to_save["config_ldap_dn"] + db_change = True + # Remote login configuration content.config_remote_login = ("config_remote_login" in to_save and to_save["config_remote_login"] == "on") if not content.config_remote_login: From 2e37c14d94e254c03f9733676cd144ce33c8af96 Mon Sep 17 00:00:00 2001 From: Krakinou Date: Sat, 12 Jan 2019 18:40:32 +0100 Subject: [PATCH 09/96] Clean some comment --- cps/ub.py | 5 +---- cps/web.py | 5 +++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cps/ub.py b/cps/ub.py index 6de9059c..e541763e 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -157,10 +157,8 @@ class UserBase: @staticmethod def try_login(username, password): conn = get_ldap_connection() - print "bind : {}".format(config.config_ldap_dn) - print "replace :{}".format(config.config_ldap_dn.replace("%s", username)) conn.simple_bind_s( - 'uid={},ou=users,dc=yunohost,dc=org'.format(username), + config.config_ldap_dn.replace("%s", username), password ) @@ -804,7 +802,6 @@ else: #get LDAP connection def get_ldap_connection(): - print "login to LDAP server ldap://{}".format(config.config_ldap_provider_url) conn = ldap.initialize('ldap://{}'.format(config.config_ldap_provider_url)) return conn diff --git a/cps/web.py b/cps/web.py index aaf3e02b..e7c687fd 100644 --- a/cps/web.py +++ b/cps/web.py @@ -2343,8 +2343,9 @@ def login(): if request.method == "POST": form = request.form.to_dict() user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower()).first() - if config.config_use_ldap and ub.User.try_login(form['username'], form['password']): + if config.config_use_ldap and user: try: + ub.User.try_login(form['username'], form['password']) login_user(user, remember=True) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") return redirect_back(url_for("index")) @@ -3076,7 +3077,7 @@ def configuration_helper(origin): #LDAP configuratop, if "config_use_ldap" in to_save and to_save["config_use_ldap"] == "on": - if not "config_ldap_provider_url" in to_save or not "content.config_ldap_dn" in to_save: + if not "config_ldap_provider_url" in to_save or not "config_ldap_dn" in to_save: ub.session.commit() flash(_(u'Please enter a LDAP provider and a DN'), category="error") return render_title_template("config_edit.html", content=config, origin=origin, From 82e4f11334444f8650fbf5e0b16a4e8eb70253da Mon Sep 17 00:00:00 2001 From: Krakinou Date: Sat, 12 Jan 2019 19:24:21 +0100 Subject: [PATCH 10/96] Forgot requirements :/ --- optionnal-requirements-ldap.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 optionnal-requirements-ldap.txt diff --git a/optionnal-requirements-ldap.txt b/optionnal-requirements-ldap.txt new file mode 100644 index 00000000..98519145 --- /dev/null +++ b/optionnal-requirements-ldap.txt @@ -0,0 +1 @@ +python_ldap>=3.0.0 \ No newline at end of file From 7ccc40cf5b2260b4fe282dab92b6119eda7a22d9 Mon Sep 17 00:00:00 2001 From: Krakinou Date: Sun, 13 Jan 2019 11:02:03 +0100 Subject: [PATCH 11/96] Moving import LDAP Correct optional-requirements-ldap.txt spelling --- cps/ub.py | 2 +- cps/web.py | 3 ++- ...nal-requirements-ldap.txt => optional-requirements-ldap.txt | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename optionnal-requirements-ldap.txt => optional-requirements-ldap.txt (100%) diff --git a/cps/ub.py b/cps/ub.py index e541763e..605c8ba0 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -14,7 +14,6 @@ import json import datetime from binascii import hexlify import cli -import ldap engine = create_engine('sqlite:///{0}'.format(cli.settingspath), echo=False) Base = declarative_base() @@ -802,6 +801,7 @@ else: #get LDAP connection def get_ldap_connection(): + import ldap conn = ldap.initialize('ldap://{}'.format(config.config_ldap_provider_url)) return conn diff --git a/cps/web.py b/cps/web.py index e7c687fd..fca0e16f 100644 --- a/cps/web.py +++ b/cps/web.py @@ -57,7 +57,6 @@ from redirect import redirect_back import time import server from reverseproxy import ReverseProxied -import ldap try: from googleapiclient.errors import HttpError @@ -2344,6 +2343,7 @@ def login(): form = request.form.to_dict() user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower()).first() if config.config_use_ldap and user: + import ldap try: ub.User.try_login(form['username'], form['password']) login_user(user, remember=True) @@ -2352,6 +2352,7 @@ def login(): except ldap.INVALID_CREDENTIALS: ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) app.logger.info('LDAP Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) + flash(_(u"Wrong Username or Password"), category="error") elif user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest" and not user.is_authenticated: login_user(user, remember=True) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") diff --git a/optionnal-requirements-ldap.txt b/optional-requirements-ldap.txt similarity index 100% rename from optionnal-requirements-ldap.txt rename to optional-requirements-ldap.txt From d48cdcc789387f2d0711efa07d68e369506119be Mon Sep 17 00:00:00 2001 From: Krakinou Date: Sun, 13 Jan 2019 11:21:11 +0100 Subject: [PATCH 12/96] Correct authentication in case LDAP not activated --- cps/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cps/web.py b/cps/web.py index d304ffbd..dbc4c268 100644 --- a/cps/web.py +++ b/cps/web.py @@ -2374,7 +2374,7 @@ def login(): ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) app.logger.info('LDAP Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) flash(_(u"Wrong Username or Password"), category="error") - elif user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest" and not user.is_authenticated: + elif user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest": login_user(user, remember=True) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") return redirect_back(url_for("index")) From 59711946786e9114c3eef8c9b5b047ff13c7c587 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sun, 27 Jan 2019 11:14:38 +0100 Subject: [PATCH 13/96] Merge remote-tracking branch 'audiobook/Branch_c27805b' # Conflicts: # cps/templates/detail.html # cps/web.py # readme.md --- cps/static/css/images/black-10.png | Bin 0 -> 88 bytes cps/static/css/images/black-25.png | Bin 0 -> 88 bytes cps/static/css/images/black-33.png | Bin 0 -> 88 bytes cps/static/css/images/icomoon/credits.txt | 6 + .../icomoon/entypo-25px-000000/PNG/arrow.png | Bin 0 -> 160 bytes .../icomoon/entypo-25px-000000/PNG/cart.png | Bin 0 -> 230 bytes .../icomoon/entypo-25px-000000/PNG/first.png | Bin 0 -> 172 bytes .../icomoon/entypo-25px-000000/PNG/last.png | Bin 0 -> 167 bytes .../icomoon/entypo-25px-000000/PNG/list.png | Bin 0 -> 117 bytes .../icomoon/entypo-25px-000000/PNG/list2.png | Bin 0 -> 98 bytes .../icomoon/entypo-25px-000000/PNG/loop.png | Bin 0 -> 190 bytes .../icomoon/entypo-25px-000000/PNG/music.png | Bin 0 -> 191 bytes .../icomoon/entypo-25px-000000/PNG/pause.png | Bin 0 -> 152 bytes .../icomoon/entypo-25px-000000/PNG/play.png | Bin 0 -> 162 bytes .../entypo-25px-000000/PNG/shuffle.png | Bin 0 -> 280 bytes .../icomoon/entypo-25px-000000/PNG/volume.png | Bin 0 -> 169 bytes .../icomoon/entypo-25px-000000/SVG/arrow.svg | 8 + .../icomoon/entypo-25px-000000/SVG/cart.svg | 8 + .../icomoon/entypo-25px-000000/SVG/first.svg | 8 + .../icomoon/entypo-25px-000000/SVG/last.svg | 8 + .../icomoon/entypo-25px-000000/SVG/list.svg | 8 + .../icomoon/entypo-25px-000000/SVG/list2.svg | 8 + .../icomoon/entypo-25px-000000/SVG/loop.svg | 8 + .../icomoon/entypo-25px-000000/SVG/music.svg | 8 + .../icomoon/entypo-25px-000000/SVG/pause.svg | 8 + .../icomoon/entypo-25px-000000/SVG/play.svg | 8 + .../entypo-25px-000000/SVG/shuffle.svg | 8 + .../icomoon/entypo-25px-000000/SVG/volume.svg | 8 + .../icomoon/entypo-25px-ffffff/PNG/arrow.png | Bin 0 -> 165 bytes .../icomoon/entypo-25px-ffffff/PNG/cart.png | Bin 0 -> 236 bytes .../icomoon/entypo-25px-ffffff/PNG/first.png | Bin 0 -> 179 bytes .../icomoon/entypo-25px-ffffff/PNG/last.png | Bin 0 -> 170 bytes .../icomoon/entypo-25px-ffffff/PNG/list.png | Bin 0 -> 117 bytes .../icomoon/entypo-25px-ffffff/PNG/list2.png | Bin 0 -> 99 bytes .../icomoon/entypo-25px-ffffff/PNG/loop.png | Bin 0 -> 201 bytes .../icomoon/entypo-25px-ffffff/PNG/music.png | Bin 0 -> 195 bytes .../icomoon/entypo-25px-ffffff/PNG/pause.png | Bin 0 -> 154 bytes .../icomoon/entypo-25px-ffffff/PNG/play.png | Bin 0 -> 166 bytes .../entypo-25px-ffffff/PNG/shuffle.png | Bin 0 -> 291 bytes .../icomoon/entypo-25px-ffffff/PNG/volume.png | Bin 0 -> 165 bytes .../icomoon/entypo-25px-ffffff/SVG/arrow.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/cart.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/first.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/last.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/list.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/list2.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/loop.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/music.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/pause.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/play.svg | 8 + .../entypo-25px-ffffff/SVG/shuffle.svg | 8 + .../icomoon/entypo-25px-ffffff/SVG/volume.svg | 8 + .../icomoon/free-25px-000000/PNG/spinner.png | Bin 0 -> 293 bytes .../icomoon/free-25px-000000/SVG/spinner.svg | 8 + .../icomoon/free-25px-ffffff/PNG/spinner.png | Bin 0 -> 299 bytes .../icomoon/free-25px-ffffff/SVG/spinner.svg | 8 + cps/static/css/images/patterns/credits.txt | 2 + .../patterns/pinstriped_suit_vertical.png | Bin 0 -> 10828 bytes cps/static/css/images/patterns/pool_table.png | Bin 0 -> 40692 bytes .../css/images/patterns/rubber_grip.png | Bin 0 -> 123 bytes .../css/images/patterns/tasky_pattern.png | Bin 0 -> 104 bytes .../css/images/patterns/textured_paper.png | Bin 0 -> 130482 bytes cps/static/css/images/patterns/tweed.png | Bin 0 -> 21309 bytes .../css/images/patterns/wood_pattern.png | Bin 0 -> 103832 bytes .../css/images/patterns/wood_pattern_dark.png | Bin 0 -> 33072 bytes cps/static/css/images/patterns/woven.png | Bin 0 -> 1165 bytes cps/static/css/libs/bar-ui.css | 1001 +++ cps/static/css/listen.css | 114 + cps/static/js/libs/bar-ui.js | 1745 +++++ cps/static/js/libs/soundmanager2.js | 6294 +++++++++++++++++ cps/templates/detail.html | 17 +- cps/templates/index.html | 5 + cps/templates/listenmp3.html | 140 + cps/templates/read.html | 4 +- cps/web.py | 45 +- readme.md | 18 +- 76 files changed, 9582 insertions(+), 17 deletions(-) create mode 100644 cps/static/css/images/black-10.png create mode 100644 cps/static/css/images/black-25.png create mode 100644 cps/static/css/images/black-33.png create mode 100644 cps/static/css/images/icomoon/credits.txt create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/arrow.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/cart.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/first.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/last.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/list.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/list2.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/loop.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/music.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/pause.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/play.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/shuffle.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/PNG/volume.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/arrow.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/cart.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/first.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/last.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/list.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/list2.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/loop.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/music.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/pause.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/play.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/shuffle.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-000000/SVG/volume.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/arrow.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/cart.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/first.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/last.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/list.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/list2.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/loop.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/music.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/pause.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/play.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/shuffle.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/volume.png create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/arrow.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/cart.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/first.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/last.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/list.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/list2.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/loop.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/music.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/pause.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/play.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/shuffle.svg create mode 100644 cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/volume.svg create mode 100644 cps/static/css/images/icomoon/free-25px-000000/PNG/spinner.png create mode 100644 cps/static/css/images/icomoon/free-25px-000000/SVG/spinner.svg create mode 100644 cps/static/css/images/icomoon/free-25px-ffffff/PNG/spinner.png create mode 100644 cps/static/css/images/icomoon/free-25px-ffffff/SVG/spinner.svg create mode 100644 cps/static/css/images/patterns/credits.txt create mode 100644 cps/static/css/images/patterns/pinstriped_suit_vertical.png create mode 100644 cps/static/css/images/patterns/pool_table.png create mode 100644 cps/static/css/images/patterns/rubber_grip.png create mode 100644 cps/static/css/images/patterns/tasky_pattern.png create mode 100644 cps/static/css/images/patterns/textured_paper.png create mode 100644 cps/static/css/images/patterns/tweed.png create mode 100644 cps/static/css/images/patterns/wood_pattern.png create mode 100644 cps/static/css/images/patterns/wood_pattern_dark.png create mode 100644 cps/static/css/images/patterns/woven.png create mode 100644 cps/static/css/libs/bar-ui.css create mode 100644 cps/static/css/listen.css create mode 100644 cps/static/js/libs/bar-ui.js create mode 100644 cps/static/js/libs/soundmanager2.js create mode 100644 cps/templates/listenmp3.html diff --git a/cps/static/css/images/black-10.png b/cps/static/css/images/black-10.png new file mode 100644 index 0000000000000000000000000000000000000000..fe545ed85188384926cd9b5d0b6a751c52b12d77 GIT binary patch literal 88 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4U@}4e^As)w*6DBMX=#w(YIPc)I$ztaD0e0sx6Y7?c12 literal 0 HcmV?d00001 diff --git a/cps/static/css/images/black-25.png b/cps/static/css/images/black-25.png new file mode 100644 index 0000000000000000000000000000000000000000..a498b981b42e14fb2377a9bd88a6f06445121143 GIT binary patch literal 88 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4U@}4e^As)w*6E-Xt=#w(YIk4U@}4e^As)w*6F3$M^hp_H9azD* gn5_rGIDD3YVFOQqfJ?nCJ5VWur>mdKI;Vst090od6#xJL literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/credits.txt b/cps/static/css/images/icomoon/credits.txt new file mode 100644 index 00000000..34c4b3ab --- /dev/null +++ b/cps/static/css/images/icomoon/credits.txt @@ -0,0 +1,6 @@ +SVG icons via Icomoon +https://icomoon.io/app + +Icons used from the following sets: +* Entypo - Creative Commons BY-SA 3.0 http://creativecommons.org/licenses/by-sa/3.0/us/ +* IcoMoon - Free (GPL) http://www.gnu.org/licenses/gpl.html \ No newline at end of file diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/arrow.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..e77449bcc9d54970735c8d516faff515ee6f6cdb GIT binary patch literal 160 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#A>7Fi*Ar*|t4UK^b2Lc>swK24P z=iyzF;gRrzzvZ*!n?fhn5MJ(vQ=Er?YM2RdGGEA5mdK II;Vst01VhRCjbBd literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/cart.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/cart.png new file mode 100644 index 0000000000000000000000000000000000000000..70e74a1444fd242bf43e7f64f55792694fd70b23 GIT binary patch literal 230 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#AYdu{YLn;{OPLf>EmLR}zTyy!H zYd~DL{JiG!n7DWMcI}x{q}l%Nci!xWd#C5SELkk4ANcBG!}KE>HzkrBR>ZGdqw!WT zi@)=Y;zHXao7)}o_&rzTpY;?l+cEnN-@}#%Cixx5d5`Z|FK4p*bhXN|WeR%@)7(>H zKK)><%DT5|Z6B|bqi-Way7+Hqi~axXqwB6Let5O(^l8q!H}^&HM}O2j9%9EX)AhJx e!WORhOoq3g#4cCMoYw|AoWax8&t;ucLK6VY(P5$h literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/first.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/first.png new file mode 100644 index 0000000000000000000000000000000000000000..0947734aed5b50faa56d261a104b1d95bfdec5c1 GIT binary patch literal 172 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#A#hxyXAr*{o&jj)|8wfN!^mkHK zSQw<0DpbJWH-q^V!yF#==y#=&)9zW<%ir4lMEk(pwCyW}_C9{GPg~5r&1C(J{z)h0 zb{85Q|NnT2Rg#qIQK9ubGasLum~>WX#W|hEc5Uw&UvwIuof7K0`ZC@3d&u9{@0h($ WH$JQ=eN+#$p25@A&t;ucLK6UxphUj_ literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/last.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/last.png new file mode 100644 index 0000000000000000000000000000000000000000..3621d331019ffff3d979d210c53cb65e9820df69 GIT binary patch literal 167 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#9o-U3d6^zLT_}v;A4_CGQF5w4%+YN?@N4Cxko>2=5?3%TWkD z&2lMcr^BpxI|GpkVoA~--P8G(9R0`I#<}CNM5bmXbAa@jcYzEH?@b#I3QFv82HMQv M>FVdQ&MBb@0Fy*Cl>h($ literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/list.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/list.png new file mode 100644 index 0000000000000000000000000000000000000000..2684aaf3ecbf9b6ad12a2df9219d48bd94f7527a GIT binary patch literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#AHl8kyAr*|t3er7ItUSLZuNiAJ z++=kyvzpVt?qK6XdkM3K%Ksak*nOh;400N77cnsFNqn_?nQ-OzVg`oiMj4ymX3XLT Pn#17f>gTe~DWM4f3$!G9 literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/list2.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/list2.png new file mode 100644 index 0000000000000000000000000000000000000000..601413b7ea2e36e7e6bd58e3e71378e14b8570c6 GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#Anw~C>Ar*|t3er7ItUSLz9{Tx0 vN=D+Ja0}mzZ88VA#e1seml(YI8_2*A=}|9tsY&4|P!ofvtDnm{r-UW|tsorM literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/loop.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/loop.png new file mode 100644 index 0000000000000000000000000000000000000000..6f9aba040320806ae6c60a5b2e71c1b89881123d GIT binary patch literal 190 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#At)4E9Ar*{o`yF`?8}PWww>U_& z7~N3lJ;8tr#~-FqATK0!(RkjbgpirnAbTz^hr*zt4M z0~NC=FVZebi8);8yQG$OOk?hXNlrnxmV7FB`(pnMw_eYKj)HZX&pQv~9J)Vg?)8<` pK5g4hT1~VuR488H%x@$fuT-|%EOXaUcA$e8JYD@<);T3K0RWc^N~iz; literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/music.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/music.png new file mode 100644 index 0000000000000000000000000000000000000000..6e1ae083acd70babd3c1e036b1f9e8c8757dd5de GIT binary patch literal 191 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#AZJsWUAr*{!FFOhzG7xBe7=Osi z!AaGHE9jczbw;s*3ljnPoO})&7cw%unmB#;`|S%=fmShiy85}Sb4q9e08uA0 AyZ`_I literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/PNG/play.png b/cps/static/css/images/icomoon/entypo-25px-000000/PNG/play.png new file mode 100644 index 0000000000000000000000000000000000000000..6fcf777042ce56ef31e68605572e22239a53ab0c GIT binary patch literal 162 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#AnVv3=Ar*|t4y=nE8Xwv^+cz_4 zM#)J0V}4lM#xQA%!^1i@)}XC_*sV6stI*}BkkfFA;AWe1Ucq6HSc~vu&g_FNTuU;4 z9+==TOPAZ@^J&&gPH&iP000>X1ONa4Zs1Mm0002rNklxf&dS0ELXGE+VsdBT5ImSLa6Hh)L=)ZY_w~exz%3g{7W%62 zL*$y(Y^Wy&3!N3d5lcKRWy!#9uR&qN@q*0?ZxTykLwE~sqldnNmQ~a1Ey7ZLn+at( zJF&!?wQFIs$8v5vGoQ?8_6q>CGJrj!bw-YrCleSW&+|K$iV;tXCmjkKL(gAKNeJ{* eB3(GH{3*Y;sJb)%Wx&$_0000aX6u;7v>@)$1C|9JRhk1C7+y!ue0Q(C R{3_6H22WQ%mvv4FO#rHwHr@aL literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/arrow.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/arrow.svg new file mode 100644 index 00000000..e6f2a0bd --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/arrow.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/cart.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/cart.svg new file mode 100644 index 00000000..590ffa8c --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/cart.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/first.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/first.svg new file mode 100644 index 00000000..e69482d1 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/first.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/last.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/last.svg new file mode 100644 index 00000000..9a958b23 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/last.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/list.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/list.svg new file mode 100644 index 00000000..88c39810 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/list2.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/list2.svg new file mode 100644 index 00000000..0c9ea62f --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/list2.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/loop.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/loop.svg new file mode 100644 index 00000000..7b0c90ce --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/loop.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/music.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/music.svg new file mode 100644 index 00000000..135ccded --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/music.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/pause.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/pause.svg new file mode 100644 index 00000000..d08ab5ad --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/pause.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/play.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/play.svg new file mode 100644 index 00000000..352ccad2 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/play.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/shuffle.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/shuffle.svg new file mode 100644 index 00000000..a6fc25f4 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/shuffle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-000000/SVG/volume.svg b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/volume.svg new file mode 100644 index 00000000..dcc4a3c2 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-000000/SVG/volume.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/arrow.png b/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..2452d288026c194dec15e6e11bd02c19029cf90a GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#AIi4<#Ar_~T6C_v#KHPs$&zLF2 z^l$(F`wm@gE&mk^{_nT-ThXv8@nC(@ftnKO4o1c$w?4@qT^}%8>`(atM#HTQ&Tp9C z2^?OM6$_s3KlHzfNx-?A`DCNd7BS{aTWo&JH<)$1k%56@WyuV`s2#U~ P_A+?7`njxgN@xNAzotOZ literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/cart.png b/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/cart.png new file mode 100644 index 0000000000000000000000000000000000000000..611a39661f86e1f4d21c349dfffb1fd61d812094 GIT binary patch literal 236 zcmVP000>X1ONa4Zs1Mm00029Nkl=6ob#LAIDjwm_ONRUN1IbtZwVd&0?&f7-6e~v|;w5y7fSF~Fg(79|k z9#HU1g>8&ij;zSb1lZ)}aNn!GE&vbsCGEIlkM%k3{`2zw&MK8FamZ$<<9hvcsS53~ m`~qzpnvN1&voz6sN%R8wTvwp^FLagw0000jRhi^+K2fN%EoIWD-|e0%Ov<+}tE#8H dp0xi4XR?<>C&Ub@{yz^#u9GO568R zjTe<&XKdAEiudhuSnelyPxDfGv{%{bM^kHaPSivieebqAb0cvQ`(?*T?^nogXOXZB TaCI&KTF&6<>gTe~DWM4f#FVdQ&MBb@056X!4FCWD literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/list2.png b/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/list2.png new file mode 100644 index 0000000000000000000000000000000000000000..a8892c26705ef10d5e0f905121e1578b192bb37a GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#ATAnVBAr_~T6C_wun*MYDdw-n! w`Qq$?AN~>+3Ac(E^kWQO{SAEI&vc2AK}}n>Z&UmF5TGswPgg&ebxsLQ01$f~l>h($ literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/loop.png b/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/loop.png new file mode 100644 index 0000000000000000000000000000000000000000..60b071a65f36b297a6963db693c2cdc91bfdc0b8 GIT binary patch literal 201 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#A6FglULn;{GUUC#XWFX-7(4&Jz z_<3XFDMM)F8wfL%VEDZ-`z1g4EFrrHuwDnzr*&zGdJx2C;84!(!zYT zZO7}$ljGP`UDT?4wWaRsPfhuDN`KMPUXH(97X>msA1+$mx$ktD_B0Mh_vgl^4Gj;d zKg>>fR<`4&(EhfbntP?njYpd9J8i1pv|nD?-)i6DtYmeOqd>PYc)I$ztaD0e0svsq BP)q;- literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/music.png b/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/music.png new file mode 100644 index 0000000000000000000000000000000000000000..c1a64a6c83814d60bfc48b00e0ac57be62726ee2 GIT binary patch literal 195 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#AU7jwEAr*{UFFOhzG7xBeICX>2 zgaFQoO;buQSR7#DlVT5Xl65(BzVU#fapRuEU;jRr-`;B9ws{k8bm5z+AM>97mb3Nz zb8pe)5+=?bXLV0?w--k&O^S}6lxp-@u_PcQ$Wiso#EfujU#9HXG|yiXUhC|9$Q$xh u$NbgL_1%p`#+OM z4Wq(+x%q9}{~aIvt!LGUXFS2y_<4s3$Dj5_mK7chF)obL)g&?+tcBTR+_v^)NoMG> zN<{H|cs`xq~2%4pAMoKkhq3?U7N?{U$$Q(lL>Sb2A-+yyPMo7_48t`FnHEP000>X1ONa4Zs1Mm0002$Nkl2z;7>w!n0ZJPwr3be0BST@|NZ zQ1t$6D1bha7>0qtTMKRbu6CI^Zgc?LHzZWM2Ogb%hw_od+p!Bve8-G^bZokmZ>zLL ppQT(eFkjBfAv*OU*T4PSya0j~tWU#whb#a9002ovPDHLkV1hUFa-skL literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/volume.png b/cps/static/css/images/icomoon/entypo-25px-ffffff/PNG/volume.png new file mode 100644 index 0000000000000000000000000000000000000000..7c251ddd90aacbd87669785d31fb8e619cb58aa6 GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%0wnVu_`U#AIi4<#Ar*{oPdkb>1xU06D*q9T zn?`jRDg-adJf)jXv{-^0_`+3S?b-HHjbP2Vi!`;=PmcaOs*&W*?9>?@$X N44$rjF6*2UngCC}I!6Ei literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/arrow.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/arrow.svg new file mode 100644 index 00000000..ea2a59ae --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/arrow.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/cart.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/cart.svg new file mode 100644 index 00000000..4b08a94b --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/cart.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/first.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/first.svg new file mode 100644 index 00000000..ac3cf397 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/first.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/last.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/last.svg new file mode 100644 index 00000000..4e3b833d --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/last.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/list.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/list.svg new file mode 100644 index 00000000..fa2f7174 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/list2.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/list2.svg new file mode 100644 index 00000000..7cec36cb --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/list2.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/loop.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/loop.svg new file mode 100644 index 00000000..79c89573 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/loop.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/music.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/music.svg new file mode 100644 index 00000000..9a6fd461 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/music.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/pause.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/pause.svg new file mode 100644 index 00000000..77ca91bb --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/pause.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/play.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/play.svg new file mode 100644 index 00000000..b98385f7 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/play.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/shuffle.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/shuffle.svg new file mode 100644 index 00000000..13a4007d --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/shuffle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/volume.svg b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/volume.svg new file mode 100644 index 00000000..9d708435 --- /dev/null +++ b/cps/static/css/images/icomoon/entypo-25px-ffffff/SVG/volume.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/icomoon/free-25px-000000/PNG/spinner.png b/cps/static/css/images/icomoon/free-25px-000000/PNG/spinner.png new file mode 100644 index 0000000000000000000000000000000000000000..bd6d1a4c981f1e10b67ab47c593c4857d442500f GIT binary patch literal 293 zcmV+=0owkFP)P000>X1ONa4Zs1Mm0002&NklczO)0h6G?`V?@wM0LVl}c#R|wGBX3pMJ(vTGs2Ly zba7+MtNerxHp-#CNsKWV)I`QILh4J3^qCUb`v^UrMr6Ts0+tquCzdl4!hyiXl~0y{ raKe#TxO1uAn!rqla3hrpc>cpL+I4O;PO-yk00000NkvXXu0mjf> + + + + + + + diff --git a/cps/static/css/images/icomoon/free-25px-ffffff/PNG/spinner.png b/cps/static/css/images/icomoon/free-25px-ffffff/PNG/spinner.png new file mode 100644 index 0000000000000000000000000000000000000000..7db552586aa2d4bb4cea9f0d19b8b6c0c0525b33 GIT binary patch literal 299 zcmV+`0o4A9P)P000>X1ONa4Zs1Mm0002;Nklj8tJSYpf9C84&#pplQ>7Qip*Sg1fJ#ThCC=#LZLU>;YHEx~te z(V>kS$Ocet*I*rY&>RG(MSWLE)*wfOwILor%a{PVhm;zWkkLG(RA3b<)DI~M2nmaL z2+J`EH1QzYzWh`k6q2;v@Jt?3@?n#@N7mRX+ja8&zQiFqE9tgy7QCXro{{;*D6|H% xi`PecHEfk)<;(Qou4(UfO}0?R(W>JU{03gt8K_0A002ovPDHLkV1m)?d?Ek< literal 0 HcmV?d00001 diff --git a/cps/static/css/images/icomoon/free-25px-ffffff/SVG/spinner.svg b/cps/static/css/images/icomoon/free-25px-ffffff/SVG/spinner.svg new file mode 100644 index 00000000..eac5df26 --- /dev/null +++ b/cps/static/css/images/icomoon/free-25px-ffffff/SVG/spinner.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cps/static/css/images/patterns/credits.txt b/cps/static/css/images/patterns/credits.txt new file mode 100644 index 00000000..fb3d6673 --- /dev/null +++ b/cps/static/css/images/patterns/credits.txt @@ -0,0 +1,2 @@ +Patterns from subtlepatterns.com. +"If you need more, that's where to get 'em." \ No newline at end of file diff --git a/cps/static/css/images/patterns/pinstriped_suit_vertical.png b/cps/static/css/images/patterns/pinstriped_suit_vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..26d547a62f7ef619981c51fd92f115760ddc7cfa GIT binary patch literal 10828 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 F{XcKz)Ib0L literal 0 HcmV?d00001 diff --git a/cps/static/css/images/patterns/pool_table.png b/cps/static/css/images/patterns/pool_table.png new file mode 100644 index 0000000000000000000000000000000000000000..4183efa53b765f23288e7f93bd22f5b1fc29f9c5 GIT binary patch literal 40692 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

>p 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 literal 0 HcmV?d00001 diff --git a/cps/static/css/images/patterns/rubber_grip.png b/cps/static/css/images/patterns/rubber_grip.png new file mode 100644 index 0000000000000000000000000000000000000000..076b9606f513440b964bb4b5d5d3d78a175eabe3 GIT binary patch literal 123 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 Eg4qg2P5=M^ literal 0 HcmV?d00001 diff --git a/cps/static/css/images/patterns/wood_pattern.png b/cps/static/css/images/patterns/wood_pattern.png new file mode 100644 index 0000000000000000000000000000000000000000..47903e43b1e84e5826fd2026473d6dc798a826f0 GIT binary patch literal 103832 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 z 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

pdANuD<9Sl`$~z@ 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 literal 0 HcmV?d00001 diff --git a/cps/static/css/images/patterns/wood_pattern_dark.png b/cps/static/css/images/patterns/wood_pattern_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1f5fc7fef391f38156d453489d8ea81e1f47a59f GIT binary patch literal 33072 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∾kbY6 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"); + /* IE 8 inverse filter, may only match pure black/white */ + /* filter: xray; */ + /* pending W3 standard */ + filter: invert(1); + /* not you, IE < 10. */ + filter: none\9; +} + +.sm2-bar-ui .bd a { + text-decoration: none; +} + +.sm2-bar-ui .bd .sm2-button-element:hover { + background-color: rgba(0,0,0,0.1); + background-image: url(../images/black-10.png); + background-image: none, none; +} + +.sm2-bar-ui .bd .sm2-button-element:active { + background-color: rgba(0,0,0,0.25); + background-image: url(../images/black-25.png); + background-image: none, none; +} + +.sm2-bar-ui .bd .sm2-extra-controls .sm2-button-element:active .sm2-inline-button, +.sm2-bar-ui .bd .active .sm2-inline-button/*, +.sm2-bar-ui.playlist-open .sm2-menu a */{ + -ms-transform: scale(0.9); + -webkit-transform: scale(0.9); + -webkit-transform-origin: 50% 50%; + /* firefox doesn't scale quite right. */ + transform: scale(0.9); + transform-origin: 50% 50%; + /* firefox doesn't scale quite right. */ + -moz-transform: none; +} + +.sm2-bar-ui .bd .sm2-extra-controls .sm2-button-element:hover, +.sm2-bar-ui .bd .sm2-extra-controls .sm2-button-element:active, +.sm2-bar-ui .bd .active { + background-color: rgba(0,0,0,0.1); + background-image: url(../images/black-10.png); + background-image: none, none; +} + +.sm2-bar-ui .bd .sm2-extra-controls .sm2-button-element:active { + /* box shadow is excessive on smaller elements. */ + box-shadow: none; +} + +.sm2-bar-ui { + /* base font size */ + font-size: 15px; + text-shadow: none; +} + +.sm2-bar-ui .sm2-inline-element { + position: relative; + display: inline-block; + vertical-align: middle; + padding: 0px; + overflow: hidden; +} + +.sm2-bar-ui .sm2-inline-element, +.sm2-bar-ui .sm2-button-element .sm2-button-bd { + position: relative; + /** + * .sm2-button-bd exists because of a Firefox bug from 2000 + * re: nested relative / absolute elements inside table cells. + * https://bugzilla.mozilla.org/show_bug.cgi?id=63895 + */ +} + +.sm2-bar-ui .sm2-inline-element, +.sm2-bar-ui .sm2-button-element .sm2-button-bd { + /** + * if you play with UI width/height, these are the important ones. + * NOTE: match these values if you want square UI buttons. + */ + min-width: 2.8em; + min-height: 2.8em; +} + +.sm2-bar-ui .sm2-inline-button { + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; +} + +.sm2-bar-ui .sm2-extra-controls .bd { + /* don't double-layer. */ + background-image: none; + background-color: rgba(0,0,0,0.15); +} + +.sm2-bar-ui .sm2-extra-controls .sm2-inline-element { + width: 25px; /* bare minimum */ + min-height: 1.75em; + min-width: 2.5em; +} + +.sm2-bar-ui .sm2-inline-status { + line-height: 100%; + /* how much to allow before truncating song artist / title with ellipsis */ + display: inline-block; + min-width: 200px; + max-width: 20em; + /* a little more spacing */ + padding-left: 0.75em; + padding-right: 0.75em; +} + +.sm2-bar-ui .sm2-inline-element { + /* extra-small em scales up nicely, vs. 1px which gets fat */ + border-right: 0.075em dotted #666; /* legacy */ + border-right: 0.075em solid rgba(0,0,0,0.1); +} + +.sm2-bar-ui .sm2-inline-element.noborder { + border-right: none; +} + +.sm2-bar-ui .sm2-inline-element.compact { + min-width: 2em; + padding: 0px 0.25em; +} + +.sm2-bar-ui .sm2-inline-element:first-of-type { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; + overflow: hidden; +} + +.sm2-bar-ui .sm2-inline-element:last-of-type { + border-right: none; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.sm2-bar-ui .sm2-inline-status a:hover { + background-color: transparent; + text-decoration: underline; +} + +.sm2-inline-time, +.sm2-inline-duration { + display: table-cell; + width: 1%; + font-size: 75%; + line-height: 0.9em; + min-width: 2em; /* if you have sounds > 10:00 in length, make this bigger. */ + vertical-align: middle; +} + +.sm2-bar-ui .sm2-playlist { + position: relative; + height: 1.45em; +} + +.sm2-bar-ui .sm2-playlist-target { + /* initial render / empty case */ + position: relative; + min-height: 1em; +} + +.sm2-bar-ui .sm2-playlist ul { + position: absolute; + left: 0px; + top: 0px; + width: 100%; + list-style-type: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.sm2-bar-ui p, +.sm2-bar-ui .sm2-playlist ul, +.sm2-bar-ui .sm2-playlist ul li { + margin: 0px; + padding: 0px; +} + +.sm2-bar-ui .sm2-playlist ul li { + position: relative; +} + +.sm2-bar-ui .sm2-playlist ul li, +.sm2-bar-ui .sm2-playlist ul li a { + position: relative; + display: block; + /* prevent clipping of characters like "g" */ + height: 1.5em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.sm2-row { + position: relative; + display: table-row; +} + +.sm2-progress-bd { + /* spacing between progress track/ball and time (position) */ + padding: 0px 0.8em; +} + +.sm2-progress .sm2-progress-track, +.sm2-progress .sm2-progress-ball, +.sm2-progress .sm2-progress-bar { + position: relative; + width: 100%; + height: 0.65em; + border-radius: 0.65em; +} + +.sm2-progress .sm2-progress-bar { + /* element which follows the progres "ball" as it moves */ + position: absolute; + left: 0px; + top: 0px; + width: 0px; + background-color: rgba(0,0,0,0.33); + background-image: url(../images/black-33.png); + background-image: none, none; +} + +.volume-shade, +.playing .sm2-progress .sm2-progress-track, +.paused .sm2-progress .sm2-progress-track { + cursor: pointer; +} + +.playing .sm2-progress .sm2-progress-ball { + cursor: -moz-grab; + cursor: -webkit-grab; + cursor: grab; +} + +.sm2-progress .sm2-progress-ball { + position: absolute; + top: 0px; + left: 0px; + width: 1em; + height: 1em; + margin: -0.2em 0px 0px -0.5em; + width: 14px; + height: 14px; + margin: -2px 0px 0px -7px; + width: 0.9333em; + height: 0.9333em; + margin: -0.175em 0px 0px -0.466em; + background-color: #fff; + padding: 0px; +/* + z-index: 1; +*/ + transition: transform 0.15s ease-in-out; +} + +/* +.sm2-bar-ui.dark-text .sm2-progress .sm2-progress-ball { + background-color: #000; +} +*/ + +.sm2-progress .sm2-progress-track { + background-color: rgba(0,0,0,0.4); + background-image: url(../images/black-33.png); /* legacy */ + background-image: none, none; /* modern browsers */ +} + +/* scrollbar rules have to be separate, browsers not supporting this syntax will skip them when combined. */ +.sm2-playlist-wrapper ul::-webkit-scrollbar-track { + background-color: rgba(0,0,0,0.4); +} + +.playing.grabbing .sm2-progress .sm2-progress-track, +.playing.grabbing .sm2-progress .sm2-progress-ball { + cursor: -moz-grabbing; + cursor: -webkit-grabbing; + cursor: grabbing; +} + +.sm2-bar-ui.grabbing .sm2-progress .sm2-progress-ball { + -webkit-transform: scale(1.15); + transform: scale(1.15); +} + +.sm2-inline-button { + background-position: 50% 50%; + background-repeat: no-repeat; + /* hide inner text */ + line-height: 10em; + /** + * image-rendering seems to apply mostly to Firefox in this case. Use with caution. + * https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering#Browser_compatibility + */ + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + -ms-interpolation-mode: nearest-neighbor; + -ms-interpolation-mode: bicubic; +} + +.sm2-icon-play-pause, +.sm2-icon-play-pause:hover, +.paused .sm2-icon-play-pause:hover { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/play.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/play.svg); + background-size: 67.5%; + background-position: 40% 53%; +} + +.playing .sm2-icon-play-pause { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/pause.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/pause.svg); + background-size: 57.6%; + background-position: 50% 53%; +} + +.sm2-volume-control { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/volume.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/volume.svg); +} + +.sm2-volume-control, +.sm2-volume-shade { + background-position: 42% 50%; + background-size: 56%; +} + +.volume-shade { + filter: alpha(opacity=33); /* <= IE 8 */ + opacity: 0.33; +/* -webkit-filter: invert(1);*/ + background-image: url(../images/icomoon/entypo-25px-000000/PNG/volume.png); + background-image: none, url(../images/icomoon/entypo-25px-000000/SVG/volume.svg); +} + +.sm2-icon-menu { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/list2.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/list2.svg); + background-size: 58%; + background-position: 54% 51%; +} + +.sm2-icon-previous { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/first.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/first.svg); +} + +.sm2-icon-next { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/last.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/last.svg); +} + +.sm2-icon-previous, +.sm2-icon-next { + background-size: 49.5%; + background-position: 50% 50%; +} + + +.sm2-extra-controls .sm2-icon-previous, +.sm2-extra-controls .sm2-icon-next { + backgound-size: 53%; +} + +.sm2-icon-shuffle { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/shuffle.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/shuffle.svg); + background-size: 45%; + background-position: 50% 50%; +} + +.sm2-icon-repeat { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/loop.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/loop.svg); + background-position: 50% 43%; + background-size: 54%; +} + +.sm2-extra-controls .sm2-icon-repeat { + background-position: 50% 45%; +} + +.sm2-playlist-wrapper ul li .sm2-row { + display: table; + width: 100%; +} + +.sm2-playlist-wrapper ul li .sm2-col { + display: table-cell; + vertical-align: top; + /* by default, collapse. */ + width: 0%; +} + +.sm2-playlist-wrapper ul li .sm2-col.sm2-wide { + /* take 100% width. */ + width: 100%; +} + +.sm2-playlist-wrapper ul li .sm2-icon { + display: inline-block; + overflow: hidden; + width: 2em; + color: transparent !important; /* hide text */ + white-space: nowrap; /* don't let text affect height */ + padding-left: 0px; + padding-right: 0px; + text-indent: 2em; /* IE 8, mostly */ +} + +.sm2-playlist-wrapper ul li .sm2-icon, +.sm2-playlist-wrapper ul li:hover .sm2-icon, +.sm2-playlist-wrapper ul li.selected .sm2-icon { + background-size: 55%; + background-position: 50% 50%; + background-repeat: no-repeat; +} + +.sm2-playlist-wrapper ul li .sm2-col { + /* sibling table cells get borders. */ + border-right: 1px solid rgba(0,0,0,0.075); +} + +.sm2-playlist-wrapper ul li.selected .sm2-col { + border-color: rgba(255,255,255,0.075); +} + +.sm2-playlist-wrapper ul li .sm2-col:last-of-type { + border-right: none; +} + +.sm2-playlist-wrapper ul li .sm2-cart, +.sm2-playlist-wrapper ul li:hover .sm2-cart, +.sm2-playlist-wrapper ul li.selected .sm2-cart { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/cart.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/cart.svg); + /* slight alignment tweak */ + background-position: 48% 50%; +} + +.sm2-playlist-wrapper ul li .sm2-music, +.sm2-playlist-wrapper ul li:hover .sm2-music, +.sm2-playlist-wrapper ul li.selected .sm2-music { + background-image: url(../images/icomoon/entypo-25px-ffffff/PNG/music.png); + background-image: none, url(../images/icomoon/entypo-25px-ffffff/SVG/music.svg); +} + +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li .sm2-cart, +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li:hover .sm2-cart, +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li.selected .sm2-cart { + background-image: url(../images/icomoon/entypo-25px-000000/PNG/cart.png); + background-image: none, url(../images/icomoon/entypo-25px-000000/SVG/cart.svg); +} + +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li .sm2-music, +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li:hover .sm2-music, +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li.selected .sm2-music { + background-image: url(../images/icomoon/entypo-25px-000000/PNG/music.png); + background-image: none, url(../images/icomoon/entypo-25px-000000/SVG/music.svg); +} + + +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li .sm2-col { + border-left-color: rgba(0,0,0,0.15); +} + +.sm2-playlist-wrapper ul li .sm2-icon:hover { + background-color: rgba(0,0,0,0.33); +} + +.sm2-bar-ui .sm2-playlist-wrapper ul li .sm2-icon:hover { + background-color: rgba(0,0,0,0.45); +} + +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li.selected .sm2-icon:hover { + background-color: rgba(255,255,255,0.25); + border-color: rgba(0,0,0,0.125); +} + +.sm2-progress-ball .icon-overlay { + position: absolute; + width: 100%; + height: 100%; + top: 0px; + left: 0px; + background: none, url(../images/icomoon/free-25px-000000/SVG/spinner.svg); + background-size: 72%; + background-position: 50%; + background-repeat: no-repeat; + display: none; +} + +.playing.buffering .sm2-progress-ball .icon-overlay { + display: block; + -webkit-animation: spin 0.6s linear infinite; + animation: spin 0.6s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@-moz-keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.sm2-element ul { + font-size: 95%; + list-style-type: none; +} + +.sm2-element ul, +.sm2-element ul li { + margin: 0px; + padding: 0px; +} + +.bd.sm2-playlist-drawer { + /* optional: absolute positioning */ + /* position: absolute; */ + z-index: 3; + border-radius: 0px; + width: 100%; + height: 0px; + border: none; + background-image: none; + display: block; + overflow: hidden; + transition: height 0.2s ease-in-out; +} + +.sm2-bar-ui.fixed .bd.sm2-playlist-drawer, +.sm2-bar-ui.bottom .bd.sm2-playlist-drawer { + position: absolute; +} + +.sm2-bar-ui.fixed .sm2-playlist-wrapper, +.sm2-bar-ui.bottom .sm2-playlist-wrapper { + padding-bottom: 0px; +} + +.sm2-bar-ui.fixed .bd.sm2-playlist-drawer, +.sm2-bar-ui.bottom .bd.sm2-playlist-drawer { + /* show playlist on top */ + bottom: 2.8em; +} + +.sm2-bar-ui .bd.sm2-playlist-drawer { + opacity: 0.5; + /* redraw fix for Chrome, background color doesn't always draw when playlist drawer open. */ + transform: translateZ(0); +} + +/* experimental, may not perform well. */ +/* +.sm2-bar-ui .bd.sm2-playlist-drawer a { + -webkit-filter: blur(5px); +} +*/ + +.sm2-bar-ui.playlist-open .bd.sm2-playlist-drawer { + height: auto; + opacity: 1; +} + +.sm2-bar-ui.playlist-open .bd.sm2-playlist-drawer a { + -webkit-filter: none; /* blur(0px) was still blurred on retina displays, as of 07/2014 */ +} + +.sm2-bar-ui.fixed.playlist-open .bd.sm2-playlist-drawer .sm2-playlist-wrapper, +.sm2-bar-ui.bottom.playlist-open .bd.sm2-playlist-drawer .sm2-playlist-wrapper { + /* extra padding when open */ + padding-bottom: 0.5em; + box-shadow: none; +} + +.sm2-bar-ui .bd.sm2-playlist-drawer { + transition: all 0.2s ease-in-out; + transition-property: transform, height, opacity, background-color, -webkit-filter; +} + +.sm2-bar-ui .bd.sm2-playlist-drawer a { + transition: -webkit-filter 0.2s ease-in-out; +} + +.sm2-bar-ui .bd.sm2-playlist-drawer .sm2-inline-texture { + /* negative offset for height of top bar, so background is seamless. */ + background-position: 0px -2.8em; +} + +.sm2-box-shadow { + position: absolute; + left: 0px; + top: 0px; + width: 100%; + height: 100%; + box-shadow: inset 0px 1px 6px rgba(0,0,0,0.15); +} + +.sm2-playlist-wrapper { + position: relative; + padding: 0.5em 0.5em 0.5em 0.25em; + background-image: none, none; +} + +.sm2-playlist-wrapper ul { + max-height: 9.25em; + overflow: auto; +} + +.sm2-playlist-wrapper ul li { + border-bottom: 1px solid rgba(0,0,0,0.05); +} + +.sm2-playlist-wrapper ul li:nth-child(odd) { + background-color: rgba(255,255,255,0.03); +} + +.sm2-playlist-wrapper ul li a { + display: block; + padding: 0.5em 0.25em 0.5em 0.75em; + margin-right: 0px; + font-size: 90%; + vertical-align: middle; +} + +.sm2-playlist-wrapper ul li a.sm2-exclude { + display: inline-block; +} + +.sm2-playlist-wrapper ul li a.sm2-exclude .label { + font-size: 95%; + line-height: 1em; + margin-left: 0px; + padding: 2px 4px; +} + +.sm2-playlist-wrapper ul li:hover a { + background-color: rgba(0,0,0,0.20); + background-image: url(../images/black-20.png); + background-image: none, none; +} + +.sm2-bar-ui.dark-text .sm2-playlist-wrapper ul li:hover a { + background-color: rgba(255,255,255,0.1); + background-image: url(../images/black-10.png); + background-image: none, none; +} + +.sm2-playlist-wrapper ul li.selected a { + background-color: rgba(0,0,0,0.25); + background-image: url(../images/black-20.png); + background-image: none, none; +} + +.sm2-bar-ui.dark-text ul li.selected a { + background-color: rgba(255,255,255,0.1); + background-image: url(../images/black-10.png); + background-image: none, none; +} + +.sm2-bar-ui .disabled { + filter: alpha(opacity=33); /* <= IE 8 */ + opacity: 0.33; +} + +.sm2-bar-ui .bd .sm2-button-element.disabled:hover { + background-color: transparent; +} + +.sm2-bar-ui .active, +/*.sm2-bar-ui.playlist-open .sm2-menu,*/ +.sm2-bar-ui.playlist-open .sm2-menu:hover { + /* depressed / "on" state */ + box-shadow: inset 0px 0px 2px rgba(0,0,0,0.1); + background-image: none; +} + +.firefox-fix { + /** + * This exists because of a Firefox bug from 2000 + * re: nested relative / absolute elements inside table cells. + * https://bugzilla.mozilla.org/show_bug.cgi?id=63895 + */ + position: relative; + display: inline-block; + width: 100%; + height: 100%; +} + +/* some custom scrollbar trickery, where supported */ + +.sm2-playlist-wrapper ul::-webkit-scrollbar { + width: 10px; +} + +.sm2-playlist-wrapper ul::-webkit-scrollbar-track { + background: rgba(0,0,0,0.33); + border-radius: 10px; +} + +.sm2-playlist-wrapper ul::-webkit-scrollbar-thumb { + border-radius: 10px; + background: #fff; +} + +.sm2-extra-controls { + font-size: 0px; + text-align: center; +} + +.sm2-bar-ui .label { + position: relative; + display: inline-block; + font-size: 0.7em; + margin-left: 0.25em; + vertical-align: top; + background-color: rgba(0,0,0,0.25); + border-radius: 3px; + padding: 0px 3px; + box-sizing: padding-box; +} + +.sm2-bar-ui.dark-text .label { + background-color: rgba(0,0,0,0.1); + background-image: url(../images/black-10.png); + background-image: none, none; +} + +.sm2-bar-ui .sm2-playlist-drawer .label { + font-size: 0.8em; + padding: 0px 3px; +} + +/* --- full width stuff --- */ + +.sm2-bar-ui .sm2-inline-element { + display: table-cell; +} + +.sm2-bar-ui .sm2-inline-element { + /* collapse */ + width: 1%; +} + +.sm2-bar-ui .sm2-inline-status { + /* full width */ + width: 100%; + min-width: 100%; + max-width: 100%; +} + +.sm2-bar-ui > .bd { + width: 100%; +} + +.sm2-bar-ui .sm2-playlist-drawer { + /* re-hide playlist */ + display: block; + overflow: hidden; +} diff --git a/cps/static/css/listen.css b/cps/static/css/listen.css new file mode 100644 index 00000000..b08cc33c --- /dev/null +++ b/cps/static/css/listen.css @@ -0,0 +1,114 @@ +.sm2-bar-ui { + font-size: 20px; + } + + .sm2-bar-ui.compact { + max-width: 90%; + } + + .sm2-progress .sm2-progress-ball { + width: .5333em; + height: 1.9333em; + border-radius: 0em; + } + + .sm2-progress .sm2-progress-track { + height: 0.15em; + background: white; + } + + .sm2-bar-ui .sm2-main-controls, + .sm2-bar-ui .sm2-playlist-drawer { + background-color: transparent; + } + + .sm2-bar-ui .sm2-inline-texture { + background: transparent; + } + + .rating .glyphicon-star { + color: gray; + } + + .rating .glyphicon-star.good { + color: white; + } + + body { + overflow: hidden; + background: #272B30; + color: #aaa; + } + + #main { + position: absolute; + width: 100%; + height: 100%; + } + + #area { + width: 80%; + height: 80%; + margin: 5% auto; + max-width: 1250px; + } + + #area iframe { + border: none; + } + + #prev { + left: 40px; + } + + #next { + right: 40px; + } + + .arrow { + position: absolute; + top: 50%; + margin-top: -32px; + font-size: 64px; + color: #E2E2E2; + font-family: arial, sans-serif; + font-weight: bold; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + } + + .arrow:hover { + color: #777; + } + + .arrow:active { + color: #000; + } + + xmp, + pre, + plaintext { + display: block; + font-family: -moz-fixed; + white-space: pre; + margin: 1em 0; + } + + #area { + overflow: hidden; + } + + pre { + white-space: pre-wrap; + word-wrap: break-word; + font-family: -moz-fixed; + column-count: 2; + -webkit-columns: 2; + -moz-columns: 2; + column-gap: 20px; + -moz-column-gap: 20px; + -webkit-column-gap: 20px; + position: relative; + } \ No newline at end of file diff --git a/cps/static/js/libs/bar-ui.js b/cps/static/js/libs/bar-ui.js new file mode 100644 index 00000000..e0d4b85c --- /dev/null +++ b/cps/static/js/libs/bar-ui.js @@ -0,0 +1,1745 @@ +(function (window) { + + /** + * SoundManager 2: "Bar UI" player + * Copyright (c) 2014, Scott Schiller. All rights reserved. + * http://www.schillmania.com/projects/soundmanager2/ + * Code provided under BSD license. + * http://schillmania.com/projects/soundmanager2/license.txt + */ + + /* global console, document, navigator, soundManager, window */ + + 'use strict'; + + var Player, + players = [], + // CSS selector that will get us the top-level DOM node for the player UI. + playerSelector = '.sm2-bar-ui', + playerOptions, + utils; + + /** + * The following are player object event callback examples. + * Override globally by setting window.sm2BarPlayers.on = {}, or individually by window.sm2BarPlayers[0].on = {} etc. + * soundObject is provided for whileplaying() etc., but playback control should be done via the player object. + */ + players.on = { + /* + play: function(player, soundObject) { + console.log('playing', player); + }, + whileplaying: function(player, soundObject) { + console.log('whileplaying', player, soundObject); + }, + finish: function(player, soundObject) { + // each sound + console.log('finish', player); + }, + pause: function(player, soundObject) { + console.log('pause', player); + }, + error: function(player, soundObject) { + console.log('error', player); + }, + end: function(player, soundObject) { + // end of playlist + console.log('end', player); + } + */ + }; + + playerOptions = { + // useful when multiple players are in use, or other SM2 sounds are active etc. + stopOtherSounds: true, + // CSS class to let the browser load the URL directly e.g., download foo.mp3 + excludeClass: 'sm2-exclude' + }; + + soundManager.setup({ + // trade-off: higher UI responsiveness (play/progress bar), but may use more CPU. + html5PollingInterval: 50, + flashVersion: 9 + }); + + soundManager.onready(function () { + + var nodes, i, j; + + nodes = utils.dom.getAll(playerSelector); + + if (nodes && nodes.length) { + for (i = 0, j = nodes.length; i < j; i++) { + players.push(new Player(nodes[i])); + } + } + + }); + + /** + * player bits + */ + + Player = function (playerNode) { + + var css, dom, extras, playlistController, soundObject, actions, actionData, defaultItem, defaultVolume, firstOpen, exports; + + css = { + disabled: 'disabled', + selected: 'selected', + active: 'active', + legacy: 'legacy', + noVolume: 'no-volume', + playlistOpen: 'playlist-open' + }; + + dom = { + o: null, + playlist: null, + playlistTarget: null, + playlistContainer: null, + time: null, + player: null, + progress: null, + progressTrack: null, + progressBar: null, + duration: null, + volume: null + }; + + // prepended to tracks when a sound fails to load/play + extras = { + loadFailedCharacter: '' + }; + + function stopOtherSounds() { + + if (playerOptions.stopOtherSounds) { + soundManager.stopAll(); + } + + } + + function callback(method, oSound) { + if (method) { + // fire callback, passing current player and sound objects + if (exports.on && exports.on[method]) { + exports.on[method](exports, oSound); + } else if (players.on[method]) { + players.on[method](exports, oSound); + } + } + } + + function getTime(msec, useString) { + + // convert milliseconds to hh:mm:ss, return as object literal or string + + var nSec = Math.floor(msec / 1000), + hh = Math.floor(nSec / 3600), + min = Math.floor(nSec / 60) - Math.floor(hh * 60), + sec = Math.floor(nSec - (hh * 3600) - (min * 60)); + + // if (min === 0 && sec === 0) return null; // return 0:00 as null + + return (useString ? ((hh ? hh + ':' : '') + (hh && min < 10 ? '0' + min : min) + ':' + (sec < 10 ? '0' + sec : sec)) : { min: min, sec: sec }); + + } + + function setTitle(item) { + + // given a link, update the "now playing" UI. + + // if this is an

  • with an inner link, grab and use the text from that. + var links = item.getElementsByTagName('a'); + + if (links.length) { + item = links[0]; + } + + // remove any failed character sequence, also + dom.playlistTarget.innerHTML = '
    • ' + item.innerHTML.replace(extras.loadFailedCharacter, '') + '
    '; + + if (dom.playlistTarget.getElementsByTagName('li')[0].scrollWidth > dom.playlistTarget.offsetWidth) { + // this item can use , in fact. + dom.playlistTarget.innerHTML = '
    • ' + item.innerHTML + '
    '; + } + + } + + function makeSound(url) { + + var sound = soundManager.createSound({ + + url: url, + + volume: defaultVolume, + + whileplaying: function () { + + + //This sends a bookmark update to calibreweb every 30 seconds. + if (this.progressBuffer == undefined) { + this.progressBuffer = 0; + } + + if (this.progressBuffer <= this.position) { + + $.ajax(calibre.bookmarkUrl, { + method: "post", + data: { bookmark: this.position } + }).fail(function (xhr, status, error) { + console.error(error); + }); + + this.progressBuffer = this.progressBuffer + 30000; + } + + var progressMaxLeft = 100, + left, + width; + + left = Math.min(progressMaxLeft, Math.max(0, (progressMaxLeft * (this.position / this.durationEstimate)))) + '%'; + width = Math.min(100, Math.max(0, (100 * (this.position / this.durationEstimate)))) + '%'; + + if (this.duration) { + + dom.progress.style.left = left; + dom.progressBar.style.width = width; + + // TODO: only write changes + dom.time.innerHTML = getTime(this.position, true); + + } + + callback('whileplaying', this); + + }, + + onbufferchange: function (isBuffering) { + + if (isBuffering) { + utils.css.add(dom.o, 'buffering'); + } else { + utils.css.remove(dom.o, 'buffering'); + } + + }, + + onplay: function () { + utils.css.swap(dom.o, 'paused', 'playing'); + callback('play', this); + }, + + onpause: function () { + + $.ajax(calibre.bookmarkUrl, { + method: "post", + data: { bookmark: this.position } + }).fail(function (xhr, status, error) { + console.error(error); + }); + + utils.css.swap(dom.o, 'playing', 'paused'); + callback('pause', this); + }, + + onresume: function () { + utils.css.swap(dom.o, 'paused', 'playing'); + }, + + whileloading: function () { + + if (!this.isHTML5) { + dom.duration.innerHTML = getTime(this.durationEstimate, true); + } + + }, + + onload: function (ok) { + + sound.setPosition(calibre.bookmark); + + if (ok) { + dom.duration.innerHTML = getTime(this.duration, true); + + } else if (this._iO && this._iO.onerror) { + + this._iO.onerror(); + + } + + }, + + onerror: function () { + + // sound failed to load. + var item, element, html; + + item = playlistController.getItem(); + + if (item) { + + // note error, delay 2 seconds and advance? + // playlistTarget.innerHTML = '
    • ' + item.innerHTML + '
    '; + + if (extras.loadFailedCharacter) { + dom.playlistTarget.innerHTML = dom.playlistTarget.innerHTML.replace('
  • ', '
  • ' + extras.loadFailedCharacter + ' '); + if (playlistController.data.playlist && playlistController.data.playlist[playlistController.data.selectedIndex]) { + element = playlistController.data.playlist[playlistController.data.selectedIndex].getElementsByTagName('a')[0]; + html = element.innerHTML; + if (html.indexOf(extras.loadFailedCharacter) === -1) { + element.innerHTML = extras.loadFailedCharacter + ' ' + html; + } + } + } + + } + + callback('error', this); + + // load next, possibly with delay. + + if (navigator.userAgent.match(/mobile/i)) { + // mobile will likely block the next play() call if there is a setTimeout() - so don't use one here. + actions.next(); + } else { + if (playlistController.data.timer) { + window.clearTimeout(playlistController.data.timer); + } + playlistController.data.timer = window.setTimeout(actions.next, 2000); + } + + }, + + onstop: function () { + + $.ajax(calibre.bookmarkUrl, { + method: "post", + data: { bookmark: this.position } + }).fail(function (xhr, status, error) { + console.error(error); + }); + + utils.css.remove(dom.o, 'playing'); + + }, + + onfinish: function () { + + $.ajax(calibre.bookmarkUrl, { + method: "post", + data: { bookmark: this.position } + }).fail(function (xhr, status, error) { + console.error(error); + }); + + var lastIndex, item; + + utils.css.remove(dom.o, 'playing'); + + dom.progress.style.left = '0%'; + + lastIndex = playlistController.data.selectedIndex; + + callback('finish', this); + + // next track? + item = playlistController.getNext(); + + // don't play the same item over and over again, if at end of playlist (excluding single item case.) + if (item && (playlistController.data.selectedIndex !== lastIndex || (playlistController.data.playlist.length === 1 && playlistController.data.loopMode))) { + + playlistController.select(item); + + setTitle(item); + + stopOtherSounds(); + + // play next + this.play({ + url: playlistController.getURL() + }); + + } else { + + // end of playlist case + + // explicitly stop? + // this.stop(); + + callback('end', this); + + } + + } + + }); + + return sound; + + } + + function playLink(link) { + + // if a link is OK, play it. + + if (soundManager.canPlayURL(link.href)) { + + // if there's a timer due to failure to play one track, cancel it. + // catches case when user may use previous/next after an error. + if (playlistController.data.timer) { + window.clearTimeout(playlistController.data.timer); + playlistController.data.timer = null; + } + + if (!soundObject) { + soundObject = makeSound(link.href); + } + + // required to reset pause/play state on iOS so whileplaying() works? odd. + soundObject.stop(); + + playlistController.select(link.parentNode); + + setTitle(link.parentNode); + + // reset the UI + // TODO: function that also resets/hides timing info. + dom.progress.style.left = '0px'; + dom.progressBar.style.width = '0px'; + + stopOtherSounds(); + + soundObject.play({ + url: link.href, + position: 0 + }); + + } + + } + + function PlaylistController() { + + var data; + + data = { + + // list of nodes? + playlist: [], + + // NOTE: not implemented yet. + // shuffledIndex: [], + // shuffleMode: false, + + // selection + selectedIndex: 0, + + loopMode: false, + + timer: null + + }; + + function getPlaylist() { + + return data.playlist; + + } + + function getItem(offset) { + + var list, + item; + + // given the current selection (or an offset), return the current item. + + // if currently null, may be end of list case. bail. + if (data.selectedIndex === null) { + return offset; + } + + list = getPlaylist(); + + // use offset if provided, otherwise take default selected. + offset = (offset !== undefined ? offset : data.selectedIndex); + + // safety check - limit to between 0 and list length + offset = Math.max(0, Math.min(offset, list.length)); + + item = list[offset]; + + return item; + + } + + function findOffsetFromItem(item) { + + // given an
  • item, find it in the playlist array and return the index. + var list, + i, + j, + offset; + + offset = -1; + + list = getPlaylist(); + + if (list) { + + for (i = 0, j = list.length; i < j; i++) { + if (list[i] === item) { + offset = i; + break; + } + } + + } + + return offset; + + } + + function getNext() { + + // don't increment if null. + if (data.selectedIndex !== null) { + data.selectedIndex++; + } + + if (data.playlist.length > 1) { + + if (data.selectedIndex >= data.playlist.length) { + + if (data.loopMode) { + + // loop to beginning + data.selectedIndex = 0; + + } else { + + // no change + data.selectedIndex--; + + // end playback + // data.selectedIndex = null; + + } + + } + + } else { + + data.selectedIndex = null; + + } + + return getItem(); + + } + + function getPrevious() { + + data.selectedIndex--; + + if (data.selectedIndex < 0) { + // wrapping around beginning of list? loop or exit. + if (data.loopMode) { + data.selectedIndex = data.playlist.length - 1; + } else { + // undo + data.selectedIndex++; + } + } + + return getItem(); + + } + + function resetLastSelected() { + + // remove UI highlight(s) on selected items. + var items, + i, j; + + items = utils.dom.getAll(dom.playlist, '.' + css.selected); + + for (i = 0, j = items.length; i < j; i++) { + utils.css.remove(items[i], css.selected); + } + + } + + function select(item) { + + var offset, + itemTop, + itemBottom, + containerHeight, + scrollTop, + itemPadding, + liElement; + + // remove last selected, if any + resetLastSelected(); + + if (item) { + + liElement = utils.dom.ancestor('li', item); + + utils.css.add(liElement, css.selected); + + itemTop = item.offsetTop; + itemBottom = itemTop + item.offsetHeight; + containerHeight = dom.playlistContainer.offsetHeight; + scrollTop = dom.playlist.scrollTop; + itemPadding = 8; + + if (itemBottom > containerHeight + scrollTop) { + // bottom-align + dom.playlist.scrollTop = (itemBottom - containerHeight) + itemPadding; + } else if (itemTop < scrollTop) { + // top-align + dom.playlist.scrollTop = item.offsetTop - itemPadding; + } + + } + + // update selected offset, too. + offset = findOffsetFromItem(liElement); + + data.selectedIndex = offset; + + } + + function playItemByOffset(offset) { + + var item; + + offset = (offset || 0); + + item = getItem(offset); + + if (item) { + playLink(item.getElementsByTagName('a')[0]); + } + + } + + function getURL() { + + // return URL of currently-selected item + var item, url; + + item = getItem(); + + if (item) { + url = item.getElementsByTagName('a')[0].href; + } + + return url; + + } + + function refreshDOM() { + + // get / update playlist from DOM + + if (!dom.playlist) { + if (window.console && console.warn) { + console.warn('refreshDOM(): playlist node not found?'); + } + return; + } + + data.playlist = dom.playlist.getElementsByTagName('li'); + + } + + function initDOM() { + + dom.playlistTarget = utils.dom.get(dom.o, '.sm2-playlist-target'); + dom.playlistContainer = utils.dom.get(dom.o, '.sm2-playlist-drawer'); + dom.playlist = utils.dom.get(dom.o, '.sm2-playlist-bd'); + + } + + function initPlaylistController() { + + // inherit the default SM2 volume + defaultVolume = soundManager.defaultOptions.volume; + + initDOM(); + refreshDOM(); + + // animate playlist open, if HTML classname indicates so. + if (utils.css.has(dom.o, css.playlistOpen)) { + // hackish: run this after API has returned + window.setTimeout(function () { + actions.menu(true); + }, 1); + } + + } + + initPlaylistController(); + + return { + data: data, + refresh: refreshDOM, + getNext: getNext, + getPrevious: getPrevious, + getItem: getItem, + getURL: getURL, + playItemByOffset: playItemByOffset, + select: select + }; + + } + + function isRightClick(e) { + + // only pay attention to left clicks. old IE differs where there's no e.which, but e.button is 1 on left click. + if (e && ((e.which && e.which === 2) || (e.which === undefined && e.button !== 1))) { + // http://www.quirksmode.org/js/events_properties.html#button + return true; + } + + return false; + + } + + function getActionData(target) { + + // DOM measurements for volume slider + + if (!target) { + return; + } + + actionData.volume.x = utils.position.getOffX(target); + actionData.volume.y = utils.position.getOffY(target); + + actionData.volume.width = target.offsetWidth; + actionData.volume.height = target.offsetHeight; + + // potentially dangerous: this should, but may not be a percentage-based value. + actionData.volume.backgroundSize = parseInt(utils.style.get(target, 'background-size'), 10); + + // IE gives pixels even if background-size specified as % in CSS. Boourns. + if (window.navigator.userAgent.match(/msie|trident/i)) { + actionData.volume.backgroundSize = (actionData.volume.backgroundSize / actionData.volume.width) * 100; + } + + } + + function handleMouseDown(e) { + + var links, + target; + + target = e.target || e.srcElement; + + if (isRightClick(e)) { + return; + } + + // normalize to , if applicable. + if (target.nodeName.toLowerCase() !== 'a') { + + links = target.getElementsByTagName('a'); + if (links && links.length) { + target = target.getElementsByTagName('a')[0]; + } + + } + + if (utils.css.has(target, 'sm2-volume-control')) { + + // drag case for volume + + getActionData(target); + + utils.events.add(document, 'mousemove', actions.adjustVolume); + utils.events.add(document, 'touchmove', actions.adjustVolume); + utils.events.add(document, 'mouseup', actions.releaseVolume); + utils.events.add(document, 'touchend', actions.releaseVolume); + + // and apply right away + actions.adjustVolume(e); + + } + + } + + function handleMouse(e) { + + var target, barX, barWidth, x, clientX, newPosition, sound; + + target = dom.progressTrack; + + barX = utils.position.getOffX(target); + barWidth = target.offsetWidth; + clientX = utils.events.getClientX(e); + + x = (clientX - barX); + + newPosition = (x / barWidth); + + sound = soundObject; + + if (sound && sound.duration) { + + sound.setPosition(sound.duration * newPosition); + + // a little hackish: ensure UI updates immediately with current position, even if audio is buffering and hasn't moved there yet. + if (sound._iO && sound._iO.whileplaying) { + sound._iO.whileplaying.apply(sound); + } + + } + + if (e.preventDefault) { + e.preventDefault(); + } + + return false; + + } + + function releaseMouse(e) { + + utils.events.remove(document, 'mousemove', handleMouse); + utils.events.remove(document, 'touchmove', handleMouse); + + utils.css.remove(dom.o, 'grabbing'); + + utils.events.remove(document, 'mouseup', releaseMouse); + utils.events.remove(document, 'touchend', releaseMouse); + + utils.events.preventDefault(e); + + return false; + + } + + function handleProgressMouseDown(e) { + + if (isRightClick(e)) { + return; + } + + utils.css.add(dom.o, 'grabbing'); + + utils.events.add(document, 'mousemove', handleMouse); + utils.events.add(document, 'touchmove', handleMouse); + utils.events.add(document, 'mouseup', releaseMouse); + utils.events.add(document, 'touchend', releaseMouse); + + handleMouse(e); + + } + + function handleClick(e) { + + var evt, + target, + offset, + targetNodeName, + methodName, + href, + handled; + + evt = (e || window.event); + + target = evt.target || evt.srcElement; + + if (target && target.nodeName) { + + targetNodeName = target.nodeName.toLowerCase(); + + if (targetNodeName !== 'a') { + + // old IE (IE 8) might return nested elements inside the , eg., etc. Try to find the parent . + + if (target.parentNode) { + + do { + target = target.parentNode; + targetNodeName = target.nodeName.toLowerCase(); + } while (targetNodeName !== 'a' && target.parentNode); + + if (!target) { + // something went wrong. bail. + return false; + } + + } + + } + + if (targetNodeName === 'a') { + + // yep, it's a link. + + href = target.href; + + if (soundManager.canPlayURL(href)) { + + // not excluded + if (!utils.css.has(target, playerOptions.excludeClass)) { + + // find this in the playlist + + playLink(target); + + handled = true; + + } + + } else { + + // is this one of the action buttons, eg., play/pause, volume, etc.? + offset = target.href.lastIndexOf('#'); + + if (offset !== -1) { + + methodName = target.href.substr(offset + 1); + + if (methodName && actions[methodName]) { + handled = true; + actions[methodName](e); + } + + } + + } + + // fall-through case + + if (handled) { + // prevent browser fall-through + return utils.events.preventDefault(evt); + } + + } + + } + + return true; + + } + + function init() { + + // init DOM? + + if (!playerNode && window.console && console.warn) { + console.warn('init(): No playerNode element?'); + } + + dom.o = playerNode; + + // are we dealing with a crap browser? apply legacy CSS if so. + if (window.navigator.userAgent.match(/msie [678]/i)) { + utils.css.add(dom.o, css.legacy); + } + + if (window.navigator.userAgent.match(/mobile/i)) { + // majority of mobile devices don't let HTML5 audio set volume. + utils.css.add(dom.o, css.noVolume); + } + + dom.progress = utils.dom.get(dom.o, '.sm2-progress-ball'); + + dom.progressTrack = utils.dom.get(dom.o, '.sm2-progress-track'); + + dom.progressBar = utils.dom.get(dom.o, '.sm2-progress-bar'); + + dom.volume = utils.dom.get(dom.o, 'a.sm2-volume-control'); + + // measure volume control dimensions + if (dom.volume) { + getActionData(dom.volume); + } + + dom.duration = utils.dom.get(dom.o, '.sm2-inline-duration'); + + dom.time = utils.dom.get(dom.o, '.sm2-inline-time'); + + playlistController = new PlaylistController(); + + defaultItem = playlistController.getItem(0); + + playlistController.select(defaultItem); + + if (defaultItem) { + setTitle(defaultItem); + } + + utils.events.add(dom.o, 'mousedown', handleMouseDown); + utils.events.add(dom.o, 'touchstart', handleMouseDown); + utils.events.add(dom.o, 'click', handleClick); + utils.events.add(dom.progressTrack, 'mousedown', handleProgressMouseDown); + utils.events.add(dom.progressTrack, 'touchstart', handleProgressMouseDown); + + } + + // --- + + actionData = { + + volume: { + x: 0, + y: 0, + width: 0, + height: 0, + backgroundSize: 0 + } + + }; + + actions = { + + play: function (offsetOrEvent) { + + /** + * This is an overloaded function that takes mouse/touch events or offset-based item indices. + * Remember, "auto-play" will not work on mobile devices unless this function is called immediately from a touch or click event. + * If you have the link but not the offset, you can also pass a fake event object with a target of an inside the playlist - e.g. { target: someMP3Link } + */ + + var target, + href, + e; + + if (offsetOrEvent !== undefined && !isNaN(offsetOrEvent)) { + // smells like a number. + playlistController.playItemByOffset(offsetOrEvent); + return; + } + + // DRY things a bit + e = offsetOrEvent; + + if (e && e.target) { + + target = e.target || e.srcElement; + + href = target.href; + + } + + // haaaack - if null due to no event, OR '#' due to play/pause link, get first link from playlist + if (!href || href.indexOf('#') !== -1) { + href = dom.playlist.getElementsByTagName('a')[0].href; + } + + if (!soundObject) { + soundObject = makeSound(href); + } + + // edge case: if the current sound is not playing, stop all others. + if (!soundObject.playState) { + stopOtherSounds(); + } + + // TODO: if user pauses + unpauses a sound that had an error, try to play next? + soundObject.togglePause(); + + // special case: clear "play next" timeout, if one exists. + // edge case: user pauses after a song failed to load. + if (soundObject.paused && playlistController.data.timer) { + window.clearTimeout(playlistController.data.timer); + playlistController.data.timer = null; + } + + }, + + pause: function () { + + if (soundObject && soundObject.readyState) { + soundObject.pause(); + } + + }, + + resume: function () { + + if (soundObject && soundObject.readyState) { + soundObject.resume(); + } + + }, + + stop: function () { + + // just an alias for pause, really. + // don't actually stop because that will mess up some UI state, i.e., dragging the slider. + return actions.pause(); + + }, + + next: function (/* e */) { + + var item, lastIndex; + + // special case: clear "play next" timeout, if one exists. + if (playlistController.data.timer) { + window.clearTimeout(playlistController.data.timer); + playlistController.data.timer = null; + } + + lastIndex = playlistController.data.selectedIndex; + + item = playlistController.getNext(true); + + // don't play the same item again + if (item && playlistController.data.selectedIndex !== lastIndex) { + playLink(item.getElementsByTagName('a')[0]); + } + + }, + + prev: function (/* e */) { + + var item, lastIndex; + + lastIndex = playlistController.data.selectedIndex; + + item = playlistController.getPrevious(); + + // don't play the same item again + if (item && playlistController.data.selectedIndex !== lastIndex) { + playLink(item.getElementsByTagName('a')[0]); + } + + }, + + shuffle: function (e) { + + // NOTE: not implemented yet. + + var target = (e ? e.target || e.srcElement : utils.dom.get(dom.o, '.shuffle')); + + if (target && !utils.css.has(target, css.disabled)) { + utils.css.toggle(target.parentNode, css.active); + playlistController.data.shuffleMode = !playlistController.data.shuffleMode; + } + + }, + + repeat: function (e) { + + var target = (e ? e.target || e.srcElement : utils.dom.get(dom.o, '.repeat')); + + if (target && !utils.css.has(target, css.disabled)) { + utils.css.toggle(target.parentNode, css.active); + playlistController.data.loopMode = !playlistController.data.loopMode; + } + + }, + + menu: function (ignoreToggle) { + + var isOpen; + + isOpen = utils.css.has(dom.o, css.playlistOpen); + + // hackish: reset scrollTop in default first open case. odd, but some browsers have a non-zero scroll offset the first time the playlist opens. + if (playlistController && !playlistController.data.selectedIndex && !firstOpen) { + dom.playlist.scrollTop = 0; + firstOpen = true; + } + + // sniff out booleans from mouse events, as this is referenced directly by event handlers. + if (typeof ignoreToggle !== 'boolean' || !ignoreToggle) { + + if (!isOpen) { + // explicitly set height:0, so the first closed -> open animation runs properly + dom.playlistContainer.style.height = '0px'; + } + + isOpen = utils.css.toggle(dom.o, css.playlistOpen); + + } + + // playlist + dom.playlistContainer.style.height = (isOpen ? dom.playlistContainer.scrollHeight : 0) + 'px'; + + }, + + adjustVolume: function (e) { + + /** + * NOTE: this is the mousemove() event handler version. + * Use setVolume(50), etc., to assign volume directly. + */ + + var backgroundMargin, + pixelMargin, + target, + value, + volume; + + value = 0; + + target = dom.volume; + + // safety net + if (e === undefined) { + return false; + } + + // normalize between mouse and touch events + var clientX = utils.events.getClientX(e); + + if (!e || clientX === undefined) { + // called directly or with a non-mouseEvent object, etc. + // proxy to the proper method. + if (arguments.length && window.console && window.console.warn) { + console.warn('Bar UI: call setVolume(' + e + ') instead of adjustVolume(' + e + ').'); + } + return actions.setVolume.apply(this, arguments); + } + + // based on getStyle() result + // figure out spacing around background image based on background size, eg. 60% background size. + // 60% wide means 20% margin on each side. + backgroundMargin = (100 - actionData.volume.backgroundSize) / 2; + + // relative position of mouse over element + value = Math.max(0, Math.min(1, (clientX - actionData.volume.x) / actionData.volume.width)); + + target.style.clip = 'rect(0px, ' + (actionData.volume.width * value) + 'px, ' + actionData.volume.height + 'px, ' + (actionData.volume.width * (backgroundMargin / 100)) + 'px)'; + + // determine logical volume, including background margin + pixelMargin = ((backgroundMargin / 100) * actionData.volume.width); + + volume = Math.max(0, Math.min(1, ((clientX - actionData.volume.x) - pixelMargin) / (actionData.volume.width - (pixelMargin * 2)))) * 100; + + // set volume + if (soundObject) { + soundObject.setVolume(volume); + } + + defaultVolume = volume; + + return utils.events.preventDefault(e); + + }, + + releaseVolume: function (/* e */) { + + utils.events.remove(document, 'mousemove', actions.adjustVolume); + utils.events.remove(document, 'touchmove', actions.adjustVolume); + utils.events.remove(document, 'mouseup', actions.releaseVolume); + utils.events.remove(document, 'touchend', actions.releaseVolume); + + }, + + setVolume: function (volume) { + + // set volume (0-100) and update volume slider UI. + + var backgroundSize, + backgroundMargin, + backgroundOffset, + target, + from, + to; + + if (volume === undefined || isNaN(volume)) { + return; + } + + if (dom.volume) { + + target = dom.volume; + + // based on getStyle() result + backgroundSize = actionData.volume.backgroundSize; + + // figure out spacing around background image based on background size, eg. 60% background size. + // 60% wide means 20% margin on each side. + backgroundMargin = (100 - backgroundSize) / 2; + + // margin as pixel value relative to width + backgroundOffset = actionData.volume.width * (backgroundMargin / 100); + + from = backgroundOffset; + to = from + ((actionData.volume.width - (backgroundOffset * 2)) * (volume / 100)); + + target.style.clip = 'rect(0px, ' + to + 'px, ' + actionData.volume.height + 'px, ' + from + 'px)'; + + } + + // apply volume to sound, as applicable + if (soundObject) { + soundObject.setVolume(volume); + } + + defaultVolume = volume; + + } + + }; + + init(); + + // TODO: mixin actions -> exports + + exports = { + // Per-instance events: window.sm2BarPlayers[0].on = { ... } etc. See global players.on example above for reference. + on: null, + actions: actions, + dom: dom, + playlistController: playlistController + }; + + return exports; + + }; + + // barebones utilities for logic, CSS, DOM, events etc. + + utils = { + + array: (function () { + + function compare(property) { + + var result; + + return function (a, b) { + + if (a[property] < b[property]) { + result = -1; + } else if (a[property] > b[property]) { + result = 1; + } else { + result = 0; + } + return result; + }; + + } + + function shuffle(array) { + + // Fisher-Yates shuffle algo + + var i, j, temp; + + for (i = array.length - 1; i > 0; i--) { + j = Math.floor(Math.random() * (i + 1)); + temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + + return array; + + } + + return { + compare: compare, + shuffle: shuffle + }; + + }()), + + css: (function () { + + function hasClass(o, cStr) { + + return (o.className !== undefined ? new RegExp('(^|\\s)' + cStr + '(\\s|$)').test(o.className) : false); + + } + + function addClass(o, cStr) { + + if (!o || !cStr || hasClass(o, cStr)) { + return; // safety net + } + o.className = (o.className ? o.className + ' ' : '') + cStr; + + } + + function removeClass(o, cStr) { + + if (!o || !cStr || !hasClass(o, cStr)) { + return; + } + o.className = o.className.replace(new RegExp('( ' + cStr + ')|(' + cStr + ')', 'g'), ''); + + } + + function swapClass(o, cStr1, cStr2) { + + var tmpClass = { + className: o.className + }; + + removeClass(tmpClass, cStr1); + addClass(tmpClass, cStr2); + + o.className = tmpClass.className; + + } + + function toggleClass(o, cStr) { + + var found, + method; + + found = hasClass(o, cStr); + + method = (found ? removeClass : addClass); + + method(o, cStr); + + // indicate the new state... + return !found; + + } + + return { + has: hasClass, + add: addClass, + remove: removeClass, + swap: swapClass, + toggle: toggleClass + }; + + }()), + + dom: (function () { + + function getAll(param1, param2) { + + var node, + selector, + results; + + if (arguments.length === 1) { + + // .selector case + node = document.documentElement; + // first param is actually the selector + selector = param1; + + } else { + + // node, .selector + node = param1; + selector = param2; + + } + + // sorry, IE 7 users; IE 8+ required. + if (node && node.querySelectorAll) { + + results = node.querySelectorAll(selector); + + } + + return results; + + } + + function get(/* parentNode, selector */) { + + var results = getAll.apply(this, arguments); + + // hackish: if an array, return the last item. + if (results && results.length) { + return results[results.length - 1]; + } + + // handle "not found" case + return results && results.length === 0 ? null : results; + + } + + function ancestor(nodeName, element, checkCurrent) { + + if (!element || !nodeName) { + return element; + } + + nodeName = nodeName.toUpperCase(); + + // return if current node matches. + if (checkCurrent && element && element.nodeName === nodeName) { + return element; + } + + while (element && element.nodeName !== nodeName && element.parentNode) { + element = element.parentNode; + } + + return (element && element.nodeName === nodeName ? element : null); + + } + + return { + ancestor: ancestor, + get: get, + getAll: getAll + }; + + }()), + + position: (function () { + + function getOffX(o) { + + // http://www.xs4all.nl/~ppk/js/findpos.html + var curleft = 0; + + if (o.offsetParent) { + + while (o.offsetParent) { + + curleft += o.offsetLeft; + + o = o.offsetParent; + + } + + } else if (o.x) { + + curleft += o.x; + + } + + return curleft; + + } + + function getOffY(o) { + + // http://www.xs4all.nl/~ppk/js/findpos.html + var curtop = 0; + + if (o.offsetParent) { + + while (o.offsetParent) { + + curtop += o.offsetTop; + + o = o.offsetParent; + + } + + } else if (o.y) { + + curtop += o.y; + + } + + return curtop; + + } + + return { + getOffX: getOffX, + getOffY: getOffY + }; + + }()), + + style: (function () { + + function get(node, styleProp) { + + // http://www.quirksmode.org/dom/getstyles.html + var value; + + if (node.currentStyle) { + + value = node.currentStyle[styleProp]; + + } else if (window.getComputedStyle) { + + value = document.defaultView.getComputedStyle(node, null).getPropertyValue(styleProp); + + } + + return value; + + } + + return { + get: get + }; + + }()), + + events: (function () { + + var add, remove, preventDefault, getClientX; + + add = function (o, evtName, evtHandler) { + // return an object with a convenient detach method. + var eventObject = { + detach: function () { + return remove(o, evtName, evtHandler); + } + }; + if (window.addEventListener) { + o.addEventListener(evtName, evtHandler, false); + } else { + o.attachEvent('on' + evtName, evtHandler); + } + return eventObject; + }; + + remove = (window.removeEventListener !== undefined ? function (o, evtName, evtHandler) { + return o.removeEventListener(evtName, evtHandler, false); + } : function (o, evtName, evtHandler) { + return o.detachEvent('on' + evtName, evtHandler); + }); + + preventDefault = function (e) { + if (e.preventDefault) { + e.preventDefault(); + } else { + e.returnValue = false; + e.cancelBubble = true; + } + return false; + }; + + getClientX = function (e) { + // normalize between desktop (mouse) and touch (mobile/tablet/?) events. + // note pageX for touch, which normalizes zoom/scroll/pan vs. clientX. + return (e && (e.clientX || (e.touches && e.touches[0] && e.touches[0].pageX))); + }; + + return { + add: add, + preventDefault: preventDefault, + remove: remove, + getClientX: getClientX + }; + + }()), + + features: (function () { + + var getAnimationFrame, + localAnimationFrame, + localFeatures, + prop, + styles, + testDiv, + transform; + + testDiv = document.createElement('div'); + + /** + * hat tip: paul irish + * http://paulirish.com/2011/requestanimationframe-for-smart-animating/ + * https://gist.github.com/838785 + */ + + localAnimationFrame = (window.requestAnimationFrame + || window.webkitRequestAnimationFrame + || window.mozRequestAnimationFrame + || window.oRequestAnimationFrame + || window.msRequestAnimationFrame + || null); + + // apply to window, avoid "illegal invocation" errors in Chrome + getAnimationFrame = localAnimationFrame ? function () { + return localAnimationFrame.apply(window, arguments); + } : null; + + function has(propName) { + + // test for feature support + return (testDiv.style[propName] !== undefined ? propName : null); + + } + + // note local scope. + localFeatures = { + + transform: { + ie: has('-ms-transform'), + moz: has('MozTransform'), + opera: has('OTransform'), + webkit: has('webkitTransform'), + w3: has('transform'), + prop: null // the normalized property value + }, + + rotate: { + has3D: false, + prop: null + }, + + getAnimationFrame: getAnimationFrame + + }; + + localFeatures.transform.prop = ( + localFeatures.transform.w3 || + localFeatures.transform.moz || + localFeatures.transform.webkit || + localFeatures.transform.ie || + localFeatures.transform.opera + ); + + function attempt(style) { + + try { + testDiv.style[transform] = style; + } catch (e) { + // that *definitely* didn't work. + return false; + } + // if we can read back the style, it should be cool. + return !!testDiv.style[transform]; + + } + + if (localFeatures.transform.prop) { + + // try to derive the rotate/3D support. + transform = localFeatures.transform.prop; + styles = { + css_2d: 'rotate(0deg)', + css_3d: 'rotate3d(0,0,0,0deg)' + }; + + if (attempt(styles.css_3d)) { + localFeatures.rotate.has3D = true; + prop = 'rotate3d'; + } else if (attempt(styles.css_2d)) { + prop = 'rotate'; + } + + localFeatures.rotate.prop = prop; + + } + + testDiv = null; + + return localFeatures; + + }()) + + }; + + // --- + + // expose to global + window.sm2BarPlayers = players; + window.sm2BarPlayerOptions = playerOptions; + window.SM2BarPlayer = Player; + +}(window)); diff --git a/cps/static/js/libs/soundmanager2.js b/cps/static/js/libs/soundmanager2.js new file mode 100644 index 00000000..87a751d3 --- /dev/null +++ b/cps/static/js/libs/soundmanager2.js @@ -0,0 +1,6294 @@ +/** @license + * + * SoundManager 2: JavaScript Sound for the Web + * ---------------------------------------------- + * http://schillmania.com/projects/soundmanager2/ + * + * Copyright (c) 2007, Scott Schiller. All rights reserved. + * Code provided under the BSD License: + * http://schillmania.com/projects/soundmanager2/license.txt + * + * V2.97a.20170601 + */ + +/** + * About this file + * ------------------------------------------------------------------------------------- + * This is the fully-commented source version of the SoundManager 2 API, + * recommended for use during development and testing. + * + * See soundmanager2-nodebug-jsmin.js for an optimized build (~11KB with gzip.) + * http://schillmania.com/projects/soundmanager2/doc/getstarted/#basic-inclusion + * Alternately, serve this file with gzip for 75% compression savings (~30KB over HTTP.) + * + * You may notice and comments in this source; these are delimiters for + * debug blocks which are removed in the -nodebug builds, further optimizing code size. + * + * Also, as you may note: Whoa, reliable cross-platform/device audio support is hard! ;) + */ + +(function SM2(window, _undefined) { + +/* global Audio, document, window, navigator, define, module, SM2_DEFER, opera, setTimeout, setInterval, clearTimeout, sm2Debugger */ + +'use strict'; + +if (!window || !window.document) { + + // Don't cross the [environment] streams. SM2 expects to be running in a browser, not under node.js etc. + // Additionally, if a browser somehow manages to fail this test, as Egon said: "It would be bad." + + throw new Error('SoundManager requires a browser with window and document objects.'); + +} + +var soundManager = null; + +/** + * The SoundManager constructor. + * + * @constructor + * @param {string} smURL Optional: Path to SWF files + * @param {string} smID Optional: The ID to use for the SWF container element + * @this {SoundManager} + * @return {SoundManager} The new SoundManager instance + */ + +function SoundManager(smURL, smID) { + + /** + * soundManager configuration options list + * defines top-level configuration properties to be applied to the soundManager instance (eg. soundManager.flashVersion) + * to set these properties, use the setup() method - eg., soundManager.setup({url: '/swf/', flashVersion: 9}) + */ + + this.setupOptions = { + + url: (smURL || null), // path (directory) where SoundManager 2 SWFs exist, eg., /path/to/swfs/ + flashVersion: 8, // flash build to use (8 or 9.) Some API features require 9. + debugMode: true, // enable debugging output (console.log() with HTML fallback) + debugFlash: false, // enable debugging output inside SWF, troubleshoot Flash/browser issues + useConsole: true, // use console.log() if available (otherwise, writes to #soundmanager-debug element) + consoleOnly: true, // if console is being used, do not create/write to #soundmanager-debug + waitForWindowLoad: false, // force SM2 to wait for window.onload() before trying to call soundManager.onload() + bgColor: '#ffffff', // SWF background color. N/A when wmode = 'transparent' + useHighPerformance: false, // position:fixed flash movie can help increase js/flash speed, minimize lag + flashPollingInterval: null, // msec affecting whileplaying/loading callback frequency. If null, default of 50 msec is used. + html5PollingInterval: null, // msec affecting whileplaying() for HTML5 audio, excluding mobile devices. If null, native HTML5 update events are used. + flashLoadTimeout: 1000, // msec to wait for flash movie to load before failing (0 = infinity) + wmode: null, // flash rendering mode - null, 'transparent', or 'opaque' (last two allow z-index to work) + allowScriptAccess: 'always', // for scripting the SWF (object/embed property), 'always' or 'sameDomain' + useFlashBlock: false, // *requires flashblock.css, see demos* - allow recovery from flash blockers. Wait indefinitely and apply timeout CSS to SWF, if applicable. + useHTML5Audio: true, // use HTML5 Audio() where API is supported (most Safari, Chrome versions), Firefox (MP3/MP4 support varies.) Ideally, transparent vs. Flash API where possible. + forceUseGlobalHTML5Audio: false, // if true, a single Audio() object is used for all sounds - and only one can play at a time. + ignoreMobileRestrictions: false, // if true, SM2 will not apply global HTML5 audio rules to mobile UAs. iOS > 7 and WebViews may allow multiple Audio() instances. + html5Test: /^(probably|maybe)$/i, // HTML5 Audio() format support test. Use /^probably$/i; if you want to be more conservative. + preferFlash: false, // overrides useHTML5audio, will use Flash for MP3/MP4/AAC if present. Potential option if HTML5 playback with these formats is quirky. + noSWFCache: false, // if true, appends ?ts={date} to break aggressive SWF caching. + idPrefix: 'sound' // if an id is not provided to createSound(), this prefix is used for generated IDs - 'sound0', 'sound1' etc. + + }; + + this.defaultOptions = { + + /** + * the default configuration for sound objects made with createSound() and related methods + * eg., volume, auto-load behaviour and so forth + */ + + autoLoad: false, // enable automatic loading (otherwise .load() will be called on demand with .play(), the latter being nicer on bandwidth - if you want to .load yourself, you also can) + autoPlay: false, // enable playing of file as soon as possible (much faster if "stream" is true) + from: null, // position to start playback within a sound (msec), default = beginning + loops: 1, // how many times to repeat the sound (position will wrap around to 0, setPosition() will break out of loop when >0) + onid3: null, // callback function for "ID3 data is added/available" + onerror: null, // callback function for "load failed" (or, playback/network/decode error under HTML5.) + onload: null, // callback function for "load finished" + whileloading: null, // callback function for "download progress update" (X of Y bytes received) + onplay: null, // callback for "play" start + onpause: null, // callback for "pause" + onresume: null, // callback for "resume" (pause toggle) + whileplaying: null, // callback during play (position update) + onposition: null, // object containing times and function callbacks for positions of interest + onstop: null, // callback for "user stop" + onfinish: null, // callback function for "sound finished playing" + multiShot: true, // let sounds "restart" or layer on top of each other when played multiple times, rather than one-shot/one at a time + multiShotEvents: false, // fire multiple sound events (currently onfinish() only) when multiShot is enabled + position: null, // offset (milliseconds) to seek to within loaded sound data. + pan: 0, // "pan" settings, left-to-right, -100 to 100 + playbackRate: 1, // rate at which to play the sound (HTML5-only) + stream: true, // allows playing before entire file has loaded (recommended) + to: null, // position to end playback within a sound (msec), default = end + type: null, // MIME-like hint for file pattern / canPlay() tests, eg. audio/mp3 + usePolicyFile: false, // enable crossdomain.xml request for audio on remote domains (for ID3/waveform access) + volume: 100 // self-explanatory. 0-100, the latter being the max. + + }; + + this.flash9Options = { + + /** + * flash 9-only options, + * merged into defaultOptions if flash 9 is being used + */ + + onfailure: null, // callback function for when playing fails (Flash 9, MovieStar + RTMP-only) + isMovieStar: null, // "MovieStar" MPEG4 audio mode. Null (default) = auto detect MP4, AAC etc. based on URL. true = force on, ignore URL + usePeakData: false, // enable left/right channel peak (level) data + useWaveformData: false, // enable sound spectrum (raw waveform data) - NOTE: May increase CPU load. + useEQData: false, // enable sound EQ (frequency spectrum data) - NOTE: May increase CPU load. + onbufferchange: null, // callback for "isBuffering" property change + ondataerror: null // callback for waveform/eq data access error (flash playing audio in other tabs/domains) + + }; + + this.movieStarOptions = { + + /** + * flash 9.0r115+ MPEG4 audio options, + * merged into defaultOptions if flash 9+movieStar mode is enabled + */ + + bufferTime: 3, // seconds of data to buffer before playback begins (null = flash default of 0.1 seconds - if AAC playback is gappy, try increasing.) + serverURL: null, // rtmp: FMS or FMIS server to connect to, required when requesting media via RTMP or one of its variants + onconnect: null, // rtmp: callback for connection to flash media server + duration: null // rtmp: song duration (msec) + + }; + + this.audioFormats = { + + /** + * determines HTML5 support + flash requirements. + * if no support (via flash and/or HTML5) for a "required" format, SM2 will fail to start. + * flash fallback is used for MP3 or MP4 if HTML5 can't play it (or if preferFlash = true) + */ + + mp3: { + type: ['audio/mpeg; codecs="mp3"', 'audio/mpeg', 'audio/mp3', 'audio/MPA', 'audio/mpa-robust'], + required: true + }, + + mp4: { + related: ['aac', 'm4a', 'm4b'], // additional formats under the MP4 container + type: ['audio/mp4; codecs="mp4a.40.2"', 'audio/aac', 'audio/x-m4a', 'audio/MP4A-LATM', 'audio/mpeg4-generic'], + required: false + }, + + ogg: { + type: ['audio/ogg; codecs=vorbis'], + required: false + }, + + opus: { + type: ['audio/ogg; codecs=opus', 'audio/opus'], + required: false + }, + + wav: { + type: ['audio/wav; codecs="1"', 'audio/wav', 'audio/wave', 'audio/x-wav'], + required: false + }, + + flac: { + type: ['audio/flac'], + required: false + } + + }; + + // HTML attributes (id + class names) for the SWF container + + this.movieID = 'sm2-container'; + this.id = (smID || 'sm2movie'); + + this.debugID = 'soundmanager-debug'; + this.debugURLParam = /([#?&])debug=1/i; + + // dynamic attributes + + this.versionNumber = 'V2.97a.20170601'; + this.version = null; + this.movieURL = null; + this.altURL = null; + this.swfLoaded = false; + this.enabled = false; + this.oMC = null; + this.sounds = {}; + this.soundIDs = []; + this.muted = false; + this.didFlashBlock = false; + this.filePattern = null; + + this.filePatterns = { + flash8: /\.mp3(\?.*)?$/i, + flash9: /\.mp3(\?.*)?$/i + }; + + // support indicators, set at init + + this.features = { + buffering: false, + peakData: false, + waveformData: false, + eqData: false, + movieStar: false + }; + + // flash sandbox info, used primarily in troubleshooting + + this.sandbox = { + // + type: null, + types: { + remote: 'remote (domain-based) rules', + localWithFile: 'local with file access (no internet access)', + localWithNetwork: 'local with network (internet access only, no local access)', + localTrusted: 'local, trusted (local+internet access)' + }, + description: null, + noRemote: null, + noLocal: null + // + }; + + /** + * format support (html5/flash) + * stores canPlayType() results based on audioFormats. + * eg. { mp3: boolean, mp4: boolean } + * treat as read-only. + */ + + this.html5 = { + usingFlash: null // set if/when flash fallback is needed + }; + + // file type support hash + this.flash = {}; + + // determined at init time + this.html5Only = false; + + // used for special cases (eg. iPad/iPhone/palm OS?) + this.ignoreFlash = false; + + /** + * a few private internals (OK, a lot. :D) + */ + + var SMSound, + sm2 = this, globalHTML5Audio = null, flash = null, sm = 'soundManager', smc = sm + ': ', h5 = 'HTML5::', id, ua = navigator.userAgent, wl = window.location.href.toString(), doc = document, doNothing, setProperties, init, fV, on_queue = [], debugOpen = true, debugTS, didAppend = false, appendSuccess = false, didInit = false, disabled = false, windowLoaded = false, _wDS, wdCount = 0, initComplete, mixin, assign, extraOptions, addOnEvent, processOnEvents, initUserOnload, delayWaitForEI, waitForEI, rebootIntoHTML5, setVersionInfo, handleFocus, strings, initMovie, domContentLoaded, winOnLoad, didDCLoaded, getDocument, createMovie, catchError, setPolling, initDebug, debugLevels = ['log', 'info', 'warn', 'error'], defaultFlashVersion = 8, disableObject, failSafely, normalizeMovieURL, oRemoved = null, oRemovedHTML = null, str, flashBlockHandler, getSWFCSS, swfCSS, toggleDebug, loopFix, policyFix, complain, idCheck, waitingForEI = false, initPending = false, startTimer, stopTimer, timerExecute, h5TimerCount = 0, h5IntervalTimer = null, parseURL, messages = [], + canIgnoreFlash, needsFlash = null, featureCheck, html5OK, html5CanPlay, html5ErrorCodes, html5Ext, html5Unload, domContentLoadedIE, testHTML5, event, slice = Array.prototype.slice, useGlobalHTML5Audio = false, lastGlobalHTML5URL, hasFlash, detectFlash, badSafariFix, html5_events, showSupport, flushMessages, wrapCallback, idCounter = 0, didSetup, msecScale = 1000, + is_iDevice = ua.match(/(ipad|iphone|ipod)/i), isAndroid = ua.match(/android/i), isIE = ua.match(/msie|trident/i), + isWebkit = ua.match(/webkit/i), + isSafari = (ua.match(/safari/i) && !ua.match(/chrome/i)), + isOpera = (ua.match(/opera/i)), + mobileHTML5 = (ua.match(/(mobile|pre\/|xoom)/i) || is_iDevice || isAndroid), + isBadSafari = (!wl.match(/usehtml5audio/i) && !wl.match(/sm2-ignorebadua/i) && isSafari && !ua.match(/silk/i) && ua.match(/OS\sX\s10_6_([3-7])/i)), // Safari 4 and 5 (excluding Kindle Fire, "Silk") occasionally fail to load/play HTML5 audio on Snow Leopard 10.6.3 through 10.6.7 due to bug(s) in QuickTime X and/or other underlying frameworks. :/ Confirmed bug. https://bugs.webkit.org/show_bug.cgi?id=32159 + hasConsole = (window.console !== _undefined && console.log !== _undefined), + isFocused = (doc.hasFocus !== _undefined ? doc.hasFocus() : null), + tryInitOnFocus = (isSafari && (doc.hasFocus === _undefined || !doc.hasFocus())), + okToDisable = !tryInitOnFocus, + flashMIME = /(mp3|mp4|mpa|m4a|m4b)/i, + emptyURL = 'about:blank', // safe URL to unload, or load nothing from (flash 8 + most HTML5 UAs) + emptyWAV = 'data:audio/wave;base64,/UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAD//w==', // tiny WAV for HTML5 unloading + overHTTP = (doc.location ? doc.location.protocol.match(/http/i) : null), + http = (!overHTTP ? '//' : ''), + // mp3, mp4, aac etc. + netStreamMimeTypes = /^\s*audio\/(?:x-)?(?:mpeg4|aac|flv|mov|mp4|m4v|m4a|m4b|mp4v|3gp|3g2)\s*(?:$|;)/i, + // Flash v9.0r115+ "moviestar" formats + netStreamTypes = ['mpeg4', 'aac', 'flv', 'mov', 'mp4', 'm4v', 'f4v', 'm4a', 'm4b', 'mp4v', '3gp', '3g2'], + netStreamPattern = new RegExp('\\.(' + netStreamTypes.join('|') + ')(\\?.*)?$', 'i'); + + this.mimePattern = /^\s*audio\/(?:x-)?(?:mp(?:eg|3))\s*(?:$|;)/i; // default mp3 set + + // use altURL if not "online" + this.useAltURL = !overHTTP; + + swfCSS = { + swfBox: 'sm2-object-box', + swfDefault: 'movieContainer', + swfError: 'swf_error', // SWF loaded, but SM2 couldn't start (other error) + swfTimedout: 'swf_timedout', + swfLoaded: 'swf_loaded', + swfUnblocked: 'swf_unblocked', // or loaded OK + sm2Debug: 'sm2_debug', + highPerf: 'high_performance', + flashDebug: 'flash_debug' + }; + + /** + * HTML5 error codes, per W3C + * Error code 1, MEDIA_ERR_ABORTED: Client aborted download at user's request. + * Error code 2, MEDIA_ERR_NETWORK: A network error of some description caused the user agent to stop fetching the media resource, after the resource was established to be usable. + * Error code 3, MEDIA_ERR_DECODE: An error of some description occurred while decoding the media resource, after the resource was established to be usable. + * Error code 4, MEDIA_ERR_SRC_NOT_SUPPORTED: Media (audio file) not supported ("not usable.") + * Reference: https://html.spec.whatwg.org/multipage/embedded-content.html#error-codes + */ + html5ErrorCodes = [ + null, + 'MEDIA_ERR_ABORTED', + 'MEDIA_ERR_NETWORK', + 'MEDIA_ERR_DECODE', + 'MEDIA_ERR_SRC_NOT_SUPPORTED' + ]; + + /** + * basic HTML5 Audio() support test + * try...catch because of IE 9 "not implemented" nonsense + * https://github.com/Modernizr/Modernizr/issues/224 + */ + + this.hasHTML5 = (function() { + try { + // new Audio(null) for stupid Opera 9.64 case, which throws not_enough_arguments exception otherwise. + return (Audio !== _undefined && (isOpera && opera !== _undefined && opera.version() < 10 ? new Audio(null) : new Audio()).canPlayType !== _undefined); + } catch(e) { + return false; + } + }()); + + /** + * Public SoundManager API + * ----------------------- + */ + + /** + * Configures top-level soundManager properties. + * + * @param {object} options Option parameters, eg. { flashVersion: 9, url: '/path/to/swfs/' } + * onready and ontimeout are also accepted parameters. call soundManager.setup() to see the full list. + */ + + this.setup = function(options) { + + var noURL = (!sm2.url); + + // warn if flash options have already been applied + + if (options !== _undefined && didInit && needsFlash && sm2.ok() && (options.flashVersion !== _undefined || options.url !== _undefined || options.html5Test !== _undefined)) { + complain(str('setupLate')); + } + + // TODO: defer: true? + + assign(options); + + if (!useGlobalHTML5Audio) { + + if (mobileHTML5) { + + // force the singleton HTML5 pattern on mobile, by default. + if (!sm2.setupOptions.ignoreMobileRestrictions || sm2.setupOptions.forceUseGlobalHTML5Audio) { + messages.push(strings.globalHTML5); + useGlobalHTML5Audio = true; + } + + } else if (sm2.setupOptions.forceUseGlobalHTML5Audio) { + + // only apply singleton HTML5 on desktop if forced. + messages.push(strings.globalHTML5); + useGlobalHTML5Audio = true; + + } + + } + + if (!didSetup && mobileHTML5) { + + if (sm2.setupOptions.ignoreMobileRestrictions) { + + messages.push(strings.ignoreMobile); + + } else { + + // prefer HTML5 for mobile + tablet-like devices, probably more reliable vs. flash at this point. + + // + if (!sm2.setupOptions.useHTML5Audio || sm2.setupOptions.preferFlash) { + // notify that defaults are being changed. + sm2._wD(strings.mobileUA); + } + // + + sm2.setupOptions.useHTML5Audio = true; + sm2.setupOptions.preferFlash = false; + + if (is_iDevice) { + + // no flash here. + sm2.ignoreFlash = true; + + } else if ((isAndroid && !ua.match(/android\s2\.3/i)) || !isAndroid) { + + /** + * Android devices tend to work better with a single audio instance, specifically for chained playback of sounds in sequence. + * Common use case: exiting sound onfinish() -> createSound() -> play() + * Presuming similar restrictions for other mobile, non-Android, non-iOS devices. + */ + + // + sm2._wD(strings.globalHTML5); + // + + useGlobalHTML5Audio = true; + + } + + } + + } + + // special case 1: "Late setup". SM2 loaded normally, but user didn't assign flash URL eg., setup({url:...}) before SM2 init. Treat as delayed init. + + if (options) { + + if (noURL && didDCLoaded && options.url !== _undefined) { + sm2.beginDelayedInit(); + } + + // special case 2: If lazy-loading SM2 (DOMContentLoaded has already happened) and user calls setup() with url: parameter, try to init ASAP. + + if (!didDCLoaded && options.url !== _undefined && doc.readyState === 'complete') { + setTimeout(domContentLoaded, 1); + } + + } + + didSetup = true; + + return sm2; + + }; + + this.ok = function() { + + return (needsFlash ? (didInit && !disabled) : (sm2.useHTML5Audio && sm2.hasHTML5)); + + }; + + this.supported = this.ok; // legacy + + this.getMovie = function(movie_id) { + + // safety net: some old browsers differ on SWF references, possibly related to ExternalInterface / flash version + return id(movie_id) || doc[movie_id] || window[movie_id]; + + }; + + /** + * Creates a SMSound sound object instance. Can also be overloaded, e.g., createSound('mySound', '/some.mp3'); + * + * @param {object} oOptions Sound options (at minimum, url parameter is required.) + * @return {object} SMSound The new SMSound object. + */ + + this.createSound = function(oOptions, _url) { + + var cs, cs_string, options, oSound = null; + + // + cs = sm + '.createSound(): '; + cs_string = cs + str(!didInit ? 'notReady' : 'notOK'); + // + + if (!didInit || !sm2.ok()) { + complain(cs_string); + return false; + } + + if (_url !== _undefined) { + // function overloading in JS! :) ... assume simple createSound(id, url) use case. + oOptions = { + id: oOptions, + url: _url + }; + } + + // inherit from defaultOptions + options = mixin(oOptions); + + options.url = parseURL(options.url); + + // generate an id, if needed. + if (options.id === _undefined) { + options.id = sm2.setupOptions.idPrefix + (idCounter++); + } + + // + if (options.id.toString().charAt(0).match(/^[0-9]$/)) { + sm2._wD(cs + str('badID', options.id), 2); + } + + sm2._wD(cs + options.id + (options.url ? ' (' + options.url + ')' : ''), 1); + // + + if (idCheck(options.id, true)) { + sm2._wD(cs + options.id + ' exists', 1); + return sm2.sounds[options.id]; + } + + function make() { + + options = loopFix(options); + sm2.sounds[options.id] = new SMSound(options); + sm2.soundIDs.push(options.id); + return sm2.sounds[options.id]; + + } + + if (html5OK(options)) { + + oSound = make(); + // + if (!sm2.html5Only) { + sm2._wD(options.id + ': Using HTML5'); + } + // + oSound._setup_html5(options); + + } else { + + if (sm2.html5Only) { + sm2._wD(options.id + ': No HTML5 support for this sound, and no Flash. Exiting.'); + return make(); + } + + // TODO: Move HTML5/flash checks into generic URL parsing/handling function. + + if (sm2.html5.usingFlash && options.url && options.url.match(/data:/i)) { + // data: URIs not supported by Flash, either. + sm2._wD(options.id + ': data: URIs not supported via Flash. Exiting.'); + return make(); + } + + if (fV > 8) { + if (options.isMovieStar === null) { + // attempt to detect MPEG-4 formats + options.isMovieStar = !!(options.serverURL || (options.type ? options.type.match(netStreamMimeTypes) : false) || (options.url && options.url.match(netStreamPattern))); + } + // + if (options.isMovieStar) { + sm2._wD(cs + 'using MovieStar handling'); + if (options.loops > 1) { + _wDS('noNSLoop'); + } + } + // + } + + options = policyFix(options, cs); + oSound = make(); + + if (fV === 8) { + flash._createSound(options.id, options.loops || 1, options.usePolicyFile); + } else { + flash._createSound(options.id, options.url, options.usePeakData, options.useWaveformData, options.useEQData, options.isMovieStar, (options.isMovieStar ? options.bufferTime : false), options.loops || 1, options.serverURL, options.duration || null, options.autoPlay, true, options.autoLoad, options.usePolicyFile); + if (!options.serverURL) { + // We are connected immediately + oSound.connected = true; + if (options.onconnect) { + options.onconnect.apply(oSound); + } + } + } + + if (!options.serverURL && (options.autoLoad || options.autoPlay)) { + // call load for non-rtmp streams + oSound.load(options); + } + + } + + // rtmp will play in onconnect + if (!options.serverURL && options.autoPlay) { + oSound.play(); + } + + return oSound; + + }; + + /** + * Destroys a SMSound sound object instance. + * + * @param {string} sID The ID of the sound to destroy + */ + + this.destroySound = function(sID, _bFromSound) { + + // explicitly destroy a sound before normal page unload, etc. + + if (!idCheck(sID)) return false; + + var oS = sm2.sounds[sID], i; + + oS.stop(); + + // Disable all callbacks after stop(), when the sound is being destroyed + oS._iO = {}; + + oS.unload(); + + for (i = 0; i < sm2.soundIDs.length; i++) { + if (sm2.soundIDs[i] === sID) { + sm2.soundIDs.splice(i, 1); + break; + } + } + + if (!_bFromSound) { + // ignore if being called from SMSound instance + oS.destruct(true); + } + + oS = null; + delete sm2.sounds[sID]; + + return true; + + }; + + /** + * Calls the load() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @param {object} oOptions Optional: Sound options + */ + + this.load = function(sID, oOptions) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].load(oOptions); + + }; + + /** + * Calls the unload() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + */ + + this.unload = function(sID) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].unload(); + + }; + + /** + * Calls the onPosition() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @param {number} nPosition The position to watch for + * @param {function} oMethod The relevant callback to fire + * @param {object} oScope Optional: The scope to apply the callback to + * @return {SMSound} The SMSound object + */ + + this.onPosition = function(sID, nPosition, oMethod, oScope) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].onposition(nPosition, oMethod, oScope); + + }; + + // legacy/backwards-compability: lower-case method name + this.onposition = this.onPosition; + + /** + * Calls the clearOnPosition() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @param {number} nPosition The position to watch for + * @param {function} oMethod Optional: The relevant callback to fire + * @return {SMSound} The SMSound object + */ + + this.clearOnPosition = function(sID, nPosition, oMethod) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].clearOnPosition(nPosition, oMethod); + + }; + + /** + * Calls the play() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @param {object} oOptions Optional: Sound options + * @return {SMSound} The SMSound object + */ + + this.play = function(sID, oOptions) { + + var result = null, + // legacy function-overloading use case: play('mySound', '/path/to/some.mp3'); + overloaded = (oOptions && !(oOptions instanceof Object)); + + if (!didInit || !sm2.ok()) { + complain(sm + '.play(): ' + str(!didInit ? 'notReady' : 'notOK')); + return false; + } + + if (!idCheck(sID, overloaded)) { + + // no sound found for the given ID. Bail. + if (!overloaded) return false; + + if (overloaded) { + oOptions = { + url: oOptions + }; + } + + if (oOptions && oOptions.url) { + // overloading use case, create+play: .play('someID', {url:'/path/to.mp3'}); + sm2._wD(sm + '.play(): Attempting to create "' + sID + '"', 1); + oOptions.id = sID; + result = sm2.createSound(oOptions).play(); + } + + } else if (overloaded) { + + // existing sound object case + oOptions = { + url: oOptions + }; + + } + + if (result === null) { + // default case + result = sm2.sounds[sID].play(oOptions); + } + + return result; + + }; + + // just for convenience + this.start = this.play; + + /** + * Calls the setPlaybackRate() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @return {SMSound} The SMSound object + */ + + this.setPlaybackRate = function(sID, rate, allowOverride) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].setPlaybackRate(rate, allowOverride); + + }; + + /** + * Calls the setPosition() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @param {number} nMsecOffset Position (milliseconds) + * @return {SMSound} The SMSound object + */ + + this.setPosition = function(sID, nMsecOffset) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].setPosition(nMsecOffset); + + }; + + /** + * Calls the stop() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @return {SMSound} The SMSound object + */ + + this.stop = function(sID) { + + if (!idCheck(sID)) return false; + + sm2._wD(sm + '.stop(' + sID + ')', 1); + + return sm2.sounds[sID].stop(); + + }; + + /** + * Stops all currently-playing sounds. + */ + + this.stopAll = function() { + + var oSound; + sm2._wD(sm + '.stopAll()', 1); + + for (oSound in sm2.sounds) { + if (sm2.sounds.hasOwnProperty(oSound)) { + // apply only to sound objects + sm2.sounds[oSound].stop(); + } + } + + }; + + /** + * Calls the pause() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @return {SMSound} The SMSound object + */ + + this.pause = function(sID) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].pause(); + + }; + + /** + * Pauses all currently-playing sounds. + */ + + this.pauseAll = function() { + + var i; + for (i = sm2.soundIDs.length - 1; i >= 0; i--) { + sm2.sounds[sm2.soundIDs[i]].pause(); + } + + }; + + /** + * Calls the resume() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @return {SMSound} The SMSound object + */ + + this.resume = function(sID) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].resume(); + + }; + + /** + * Resumes all currently-paused sounds. + */ + + this.resumeAll = function() { + + var i; + for (i = sm2.soundIDs.length - 1; i >= 0; i--) { + sm2.sounds[sm2.soundIDs[i]].resume(); + } + + }; + + /** + * Calls the togglePause() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @return {SMSound} The SMSound object + */ + + this.togglePause = function(sID) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].togglePause(); + + }; + + /** + * Calls the setPan() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @param {number} nPan The pan value (-100 to 100) + * @return {SMSound} The SMSound object + */ + + this.setPan = function(sID, nPan) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].setPan(nPan); + + }; + + /** + * Calls the setVolume() method of a SMSound object by ID + * Overloaded case: pass only volume argument eg., setVolume(50) to apply to all sounds. + * + * @param {string} sID The ID of the sound + * @param {number} nVol The volume value (0 to 100) + * @return {SMSound} The SMSound object + */ + + this.setVolume = function(sID, nVol) { + + // setVolume(50) function overloading case - apply to all sounds + + var i, j; + + if (sID !== _undefined && !isNaN(sID) && nVol === _undefined) { + for (i = 0, j = sm2.soundIDs.length; i < j; i++) { + sm2.sounds[sm2.soundIDs[i]].setVolume(sID); + } + return false; + } + + // setVolume('mySound', 50) case + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].setVolume(nVol); + + }; + + /** + * Calls the mute() method of either a single SMSound object by ID, or all sound objects. + * + * @param {string} sID Optional: The ID of the sound (if omitted, all sounds will be used.) + */ + + this.mute = function(sID) { + + var i = 0; + + if (sID instanceof String) { + sID = null; + } + + if (!sID) { + + sm2._wD(sm + '.mute(): Muting all sounds'); + for (i = sm2.soundIDs.length - 1; i >= 0; i--) { + sm2.sounds[sm2.soundIDs[i]].mute(); + } + sm2.muted = true; + + } else { + + if (!idCheck(sID)) return false; + + sm2._wD(sm + '.mute(): Muting "' + sID + '"'); + return sm2.sounds[sID].mute(); + + } + + return true; + + }; + + /** + * Mutes all sounds. + */ + + this.muteAll = function() { + + sm2.mute(); + + }; + + /** + * Calls the unmute() method of either a single SMSound object by ID, or all sound objects. + * + * @param {string} sID Optional: The ID of the sound (if omitted, all sounds will be used.) + */ + + this.unmute = function(sID) { + + var i; + + if (sID instanceof String) { + sID = null; + } + + if (!sID) { + + sm2._wD(sm + '.unmute(): Unmuting all sounds'); + for (i = sm2.soundIDs.length - 1; i >= 0; i--) { + sm2.sounds[sm2.soundIDs[i]].unmute(); + } + sm2.muted = false; + + } else { + + if (!idCheck(sID)) return false; + + sm2._wD(sm + '.unmute(): Unmuting "' + sID + '"'); + + return sm2.sounds[sID].unmute(); + + } + + return true; + + }; + + /** + * Unmutes all sounds. + */ + + this.unmuteAll = function() { + + sm2.unmute(); + + }; + + /** + * Calls the toggleMute() method of a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @return {SMSound} The SMSound object + */ + + this.toggleMute = function(sID) { + + if (!idCheck(sID)) return false; + + return sm2.sounds[sID].toggleMute(); + + }; + + /** + * Retrieves the memory used by the flash plugin. + * + * @return {number} The amount of memory in use + */ + + this.getMemoryUse = function() { + + // flash-only + var ram = 0; + + if (flash && fV !== 8) { + ram = parseInt(flash._getMemoryUse(), 10); + } + + return ram; + + }; + + /** + * Undocumented: NOPs soundManager and all SMSound objects. + */ + + this.disable = function(bNoDisable) { + + // destroy all functions + var i; + + if (bNoDisable === _undefined) { + bNoDisable = false; + } + + // already disabled? + if (disabled) return false; + + disabled = true; + + _wDS('shutdown', 1); + + for (i = sm2.soundIDs.length - 1; i >= 0; i--) { + disableObject(sm2.sounds[sm2.soundIDs[i]]); + } + + disableObject(sm2); + + // fire "complete", despite fail + initComplete(bNoDisable); + + event.remove(window, 'load', initUserOnload); + + return true; + + }; + + /** + * Determines playability of a MIME type, eg. 'audio/mp3'. + */ + + this.canPlayMIME = function(sMIME) { + + var result; + + if (sm2.hasHTML5) { + result = html5CanPlay({ + type: sMIME + }); + } + + if (!result && needsFlash) { + // if flash 9, test netStream (movieStar) types as well. + result = (sMIME && sm2.ok() ? !!((fV > 8 ? sMIME.match(netStreamMimeTypes) : null) || sMIME.match(sm2.mimePattern)) : null); // TODO: make less "weird" (per JSLint) + } + + return result; + + }; + + /** + * Determines playability of a URL based on audio support. + * + * @param {string} sURL The URL to test + * @return {boolean} URL playability + */ + + this.canPlayURL = function(sURL) { + + var result; + + if (sm2.hasHTML5) { + result = html5CanPlay({ + url: sURL + }); + } + + if (!result && needsFlash) { + result = (sURL && sm2.ok() ? !!(sURL.match(sm2.filePattern)) : null); + } + + return result; + + }; + + /** + * Determines playability of an HTML DOM <a> object (or similar object literal) based on audio support. + * + * @param {object} oLink an HTML DOM <a> object or object literal including href and/or type attributes + * @return {boolean} URL playability + */ + + this.canPlayLink = function(oLink) { + + if (oLink.type !== _undefined && oLink.type && sm2.canPlayMIME(oLink.type)) return true; + + return sm2.canPlayURL(oLink.href); + + }; + + /** + * Retrieves a SMSound object by ID. + * + * @param {string} sID The ID of the sound + * @return {SMSound} The SMSound object + */ + + this.getSoundById = function(sID, _suppressDebug) { + + if (!sID) return null; + + var result = sm2.sounds[sID]; + + // + if (!result && !_suppressDebug) { + sm2._wD(sm + '.getSoundById(): Sound "' + sID + '" not found.', 2); + } + // + + return result; + + }; + + /** + * Queues a callback for execution when SoundManager has successfully initialized. + * + * @param {function} oMethod The callback method to fire + * @param {object} oScope Optional: The scope to apply to the callback + */ + + this.onready = function(oMethod, oScope) { + + var sType = 'onready', + result = false; + + if (typeof oMethod === 'function') { + + // + if (didInit) { + sm2._wD(str('queue', sType)); + } + // + + if (!oScope) { + oScope = window; + } + + addOnEvent(sType, oMethod, oScope); + processOnEvents(); + + result = true; + + } else { + + throw str('needFunction', sType); + + } + + return result; + + }; + + /** + * Queues a callback for execution when SoundManager has failed to initialize. + * + * @param {function} oMethod The callback method to fire + * @param {object} oScope Optional: The scope to apply to the callback + */ + + this.ontimeout = function(oMethod, oScope) { + + var sType = 'ontimeout', + result = false; + + if (typeof oMethod === 'function') { + + // + if (didInit) { + sm2._wD(str('queue', sType)); + } + // + + if (!oScope) { + oScope = window; + } + + addOnEvent(sType, oMethod, oScope); + processOnEvents({ type: sType }); + + result = true; + + } else { + + throw str('needFunction', sType); + + } + + return result; + + }; + + /** + * Writes console.log()-style debug output to a console or in-browser element. + * Applies when debugMode = true + * + * @param {string} sText The console message + * @param {object} nType Optional log level (number), or object. Number case: Log type/style where 0 = 'info', 1 = 'warn', 2 = 'error'. Object case: Object to be dumped. + */ + + this._writeDebug = function(sText, sTypeOrObject) { + + // pseudo-private console.log()-style output + // + + var sDID = 'soundmanager-debug', o, oItem; + + if (!sm2.setupOptions.debugMode) return false; + + if (hasConsole && sm2.useConsole) { + if (sTypeOrObject && typeof sTypeOrObject === 'object') { + // object passed; dump to console. + console.log(sText, sTypeOrObject); + } else if (debugLevels[sTypeOrObject] !== _undefined) { + console[debugLevels[sTypeOrObject]](sText); + } else { + console.log(sText); + } + if (sm2.consoleOnly) return true; + } + + o = id(sDID); + + if (!o) return false; + + oItem = doc.createElement('div'); + + if (++wdCount % 2 === 0) { + oItem.className = 'sm2-alt'; + } + + if (sTypeOrObject === _undefined) { + sTypeOrObject = 0; + } else { + sTypeOrObject = parseInt(sTypeOrObject, 10); + } + + oItem.appendChild(doc.createTextNode(sText)); + + if (sTypeOrObject) { + if (sTypeOrObject >= 2) { + oItem.style.fontWeight = 'bold'; + } + if (sTypeOrObject === 3) { + oItem.style.color = '#ff3333'; + } + } + + // top-to-bottom + // o.appendChild(oItem); + + // bottom-to-top + o.insertBefore(oItem, o.firstChild); + + o = null; + // + + return true; + + }; + + // + // last-resort debugging option + if (wl.indexOf('sm2-debug=alert') !== -1) { + this._writeDebug = function(sText) { + window.alert(sText); + }; + } + // + + // alias + this._wD = this._writeDebug; + + /** + * Provides debug / state information on all SMSound objects. + */ + + this._debug = function() { + + // + var i, j; + _wDS('currentObj', 1); + + for (i = 0, j = sm2.soundIDs.length; i < j; i++) { + sm2.sounds[sm2.soundIDs[i]]._debug(); + } + // + + }; + + /** + * Restarts and re-initializes the SoundManager instance. + * + * @param {boolean} resetEvents Optional: When true, removes all registered onready and ontimeout event callbacks. + * @param {boolean} excludeInit Options: When true, does not call beginDelayedInit() (which would restart SM2). + * @return {object} soundManager The soundManager instance. + */ + + this.reboot = function(resetEvents, excludeInit) { + + // reset some (or all) state, and re-init unless otherwise specified. + + // + if (sm2.soundIDs.length) { + sm2._wD('Destroying ' + sm2.soundIDs.length + ' SMSound object' + (sm2.soundIDs.length !== 1 ? 's' : '') + '...'); + } + // + + var i, j, k; + + for (i = sm2.soundIDs.length - 1; i >= 0; i--) { + sm2.sounds[sm2.soundIDs[i]].destruct(); + } + + // trash ze flash (remove from the DOM) + + if (flash) { + + try { + + if (isIE) { + oRemovedHTML = flash.innerHTML; + } + + oRemoved = flash.parentNode.removeChild(flash); + + } catch(e) { + + // Remove failed? May be due to flash blockers silently removing the SWF object/embed node from the DOM. Warn and continue. + + _wDS('badRemove', 2); + + } + + } + + // actually, force recreate of movie. + + oRemovedHTML = oRemoved = needsFlash = flash = null; + + sm2.enabled = didDCLoaded = didInit = waitingForEI = initPending = didAppend = appendSuccess = disabled = useGlobalHTML5Audio = sm2.swfLoaded = false; + + sm2.soundIDs = []; + sm2.sounds = {}; + + idCounter = 0; + didSetup = false; + + if (!resetEvents) { + // reset callbacks for onready, ontimeout etc. so that they will fire again on re-init + for (i in on_queue) { + if (on_queue.hasOwnProperty(i)) { + for (j = 0, k = on_queue[i].length; j < k; j++) { + on_queue[i][j].fired = false; + } + } + } + } else { + // remove all callbacks entirely + on_queue = []; + } + + // + if (!excludeInit) { + sm2._wD(sm + ': Rebooting...'); + } + // + + // reset HTML5 and flash canPlay test results + + sm2.html5 = { + usingFlash: null + }; + + sm2.flash = {}; + + // reset device-specific HTML/flash mode switches + + sm2.html5Only = false; + sm2.ignoreFlash = false; + + window.setTimeout(function() { + + // by default, re-init + + if (!excludeInit) { + sm2.beginDelayedInit(); + } + + }, 20); + + return sm2; + + }; + + this.reset = function() { + + /** + * Shuts down and restores the SoundManager instance to its original loaded state, without an explicit reboot. All onready/ontimeout handlers are removed. + * After this call, SM2 may be re-initialized via soundManager.beginDelayedInit(). + * @return {object} soundManager The soundManager instance. + */ + + _wDS('reset'); + + return sm2.reboot(true, true); + + }; + + /** + * Undocumented: Determines the SM2 flash movie's load progress. + * + * @return {number or null} Percent loaded, or if invalid/unsupported, null. + */ + + this.getMoviePercent = function() { + + /** + * Interesting syntax notes... + * Flash/ExternalInterface (ActiveX/NPAPI) bridge methods are not typeof "function" nor instanceof Function, but are still valid. + * Furthermore, using (flash && flash.PercentLoaded) causes IE to throw "object doesn't support this property or method". + * Thus, 'in' syntax must be used. + */ + + return (flash && 'PercentLoaded' in flash ? flash.PercentLoaded() : null); + + }; + + /** + * Additional helper for manually invoking SM2's init process after DOM Ready / window.onload(). + */ + + this.beginDelayedInit = function() { + + windowLoaded = true; + domContentLoaded(); + + setTimeout(function() { + + if (initPending) return false; + + createMovie(); + initMovie(); + initPending = true; + + return true; + + }, 20); + + delayWaitForEI(); + + }; + + /** + * Destroys the SoundManager instance and all SMSound instances. + */ + + this.destruct = function() { + + sm2._wD(sm + '.destruct()'); + sm2.disable(true); + + }; + + /** + * SMSound() (sound object) constructor + * ------------------------------------ + * + * @param {object} oOptions Sound options (id and url are required attributes) + * @return {SMSound} The new SMSound object + */ + + SMSound = function(oOptions) { + + var s = this, resetProperties, add_html5_events, remove_html5_events, stop_html5_timer, start_html5_timer, attachOnPosition, onplay_called = false, onPositionItems = [], onPositionFired = 0, detachOnPosition, applyFromTo, lastURL = null, lastHTML5State, urlOmitted; + + lastHTML5State = { + // tracks duration + position (time) + duration: null, + time: null + }; + + this.id = oOptions.id; + + // legacy + this.sID = this.id; + + this.url = oOptions.url; + this.options = mixin(oOptions); + + // per-play-instance-specific options + this.instanceOptions = this.options; + + // short alias + this._iO = this.instanceOptions; + + // assign property defaults + this.pan = this.options.pan; + this.volume = this.options.volume; + + // whether or not this object is using HTML5 + this.isHTML5 = false; + + // internal HTML5 Audio() object reference + this._a = null; + + // for flash 8 special-case createSound() without url, followed by load/play with url case + urlOmitted = (!this.url); + + /** + * SMSound() public methods + * ------------------------ + */ + + this.id3 = {}; + + /** + * Writes SMSound object parameters to debug console + */ + + this._debug = function() { + + // + sm2._wD(s.id + ': Merged options:', s.options); + // + + }; + + /** + * Begins loading a sound per its *url*. + * + * @param {object} options Optional: Sound options + * @return {SMSound} The SMSound object + */ + + this.load = function(options) { + + var oSound = null, instanceOptions; + + if (options !== _undefined) { + s._iO = mixin(options, s.options); + } else { + options = s.options; + s._iO = options; + if (lastURL && lastURL !== s.url) { + _wDS('manURL'); + s._iO.url = s.url; + s.url = null; + } + } + + if (!s._iO.url) { + s._iO.url = s.url; + } + + s._iO.url = parseURL(s._iO.url); + + // ensure we're in sync + s.instanceOptions = s._iO; + + // local shortcut + instanceOptions = s._iO; + + sm2._wD(s.id + ': load (' + instanceOptions.url + ')'); + + if (!instanceOptions.url && !s.url) { + sm2._wD(s.id + ': load(): url is unassigned. Exiting.', 2); + return s; + } + + // + if (!s.isHTML5 && fV === 8 && !s.url && !instanceOptions.autoPlay) { + // flash 8 load() -> play() won't work before onload has fired. + sm2._wD(s.id + ': Flash 8 load() limitation: Wait for onload() before calling play().', 1); + } + // + + if (instanceOptions.url === s.url && s.readyState !== 0 && s.readyState !== 2) { + _wDS('onURL', 1); + // if loaded and an onload() exists, fire immediately. + if (s.readyState === 3 && instanceOptions.onload) { + // assume success based on truthy duration. + wrapCallback(s, function() { + instanceOptions.onload.apply(s, [(!!s.duration)]); + }); + } + return s; + } + + // reset a few state properties + + s.loaded = false; + s.readyState = 1; + s.playState = 0; + s.id3 = {}; + + // TODO: If switching from HTML5 -> flash (or vice versa), stop currently-playing audio. + + if (html5OK(instanceOptions)) { + + oSound = s._setup_html5(instanceOptions); + + if (!oSound._called_load) { + + s._html5_canplay = false; + + // TODO: review called_load / html5_canplay logic + + // if url provided directly to load(), assign it here. + + if (s.url !== instanceOptions.url) { + + sm2._wD(_wDS('manURL') + ': ' + instanceOptions.url); + + s._a.src = instanceOptions.url; + + // TODO: review / re-apply all relevant options (volume, loop, onposition etc.) + + // reset position for new URL + s.setPosition(0); + + } + + // given explicit load call, try to preload. + + // early HTML5 implementation (non-standard) + s._a.autobuffer = 'auto'; + + // standard property, values: none / metadata / auto + // reference: http://msdn.microsoft.com/en-us/library/ie/ff974759%28v=vs.85%29.aspx + s._a.preload = 'auto'; + + s._a._called_load = true; + + } else { + + sm2._wD(s.id + ': Ignoring request to load again'); + + } + + } else { + + if (sm2.html5Only) { + sm2._wD(s.id + ': No flash support. Exiting.'); + return s; + } + + if (s._iO.url && s._iO.url.match(/data:/i)) { + // data: URIs not supported by Flash, either. + sm2._wD(s.id + ': data: URIs not supported via Flash. Exiting.'); + return s; + } + + try { + s.isHTML5 = false; + s._iO = policyFix(loopFix(instanceOptions)); + // if we have "position", disable auto-play as we'll be seeking to that position at onload(). + if (s._iO.autoPlay && (s._iO.position || s._iO.from)) { + sm2._wD(s.id + ': Disabling autoPlay because of non-zero offset case'); + s._iO.autoPlay = false; + } + // re-assign local shortcut + instanceOptions = s._iO; + if (fV === 8) { + flash._load(s.id, instanceOptions.url, instanceOptions.stream, instanceOptions.autoPlay, instanceOptions.usePolicyFile); + } else { + flash._load(s.id, instanceOptions.url, !!(instanceOptions.stream), !!(instanceOptions.autoPlay), instanceOptions.loops || 1, !!(instanceOptions.autoLoad), instanceOptions.usePolicyFile); + } + } catch(e) { + _wDS('smError', 2); + debugTS('onload', false); + catchError({ + type: 'SMSOUND_LOAD_JS_EXCEPTION', + fatal: true + }); + } + + } + + // after all of this, ensure sound url is up to date. + s.url = instanceOptions.url; + + return s; + + }; + + /** + * Unloads a sound, canceling any open HTTP requests. + * + * @return {SMSound} The SMSound object + */ + + this.unload = function() { + + // Flash 8/AS2 can't "close" a stream - fake it by loading an empty URL + // Flash 9/AS3: Close stream, preventing further load + // HTML5: Most UAs will use empty URL + + if (s.readyState !== 0) { + + sm2._wD(s.id + ': unload()'); + + if (!s.isHTML5) { + + if (fV === 8) { + flash._unload(s.id, emptyURL); + } else { + flash._unload(s.id); + } + + } else { + + stop_html5_timer(); + + if (s._a) { + + s._a.pause(); + + // update empty URL, too + lastURL = html5Unload(s._a); + + } + + } + + // reset load/status flags + resetProperties(); + + } + + return s; + + }; + + /** + * Unloads and destroys a sound. + */ + + this.destruct = function(_bFromSM) { + + sm2._wD(s.id + ': Destruct'); + + if (!s.isHTML5) { + + // kill sound within Flash + // Disable the onfailure handler + s._iO.onfailure = null; + flash._destroySound(s.id); + + } else { + + stop_html5_timer(); + + if (s._a) { + s._a.pause(); + html5Unload(s._a); + if (!useGlobalHTML5Audio) { + remove_html5_events(); + } + // break obvious circular reference + s._a._s = null; + s._a = null; + } + + } + + if (!_bFromSM) { + // ensure deletion from controller + sm2.destroySound(s.id, true); + } + + }; + + /** + * Begins playing a sound. + * + * @param {object} options Optional: Sound options + * @return {SMSound} The SMSound object + */ + + this.play = function(options, _updatePlayState) { + + var fN, allowMulti, a, onready, + audioClone, onended, oncanplay, + startOK = true; + + // + fN = s.id + ': play(): '; + // + + // default to true + _updatePlayState = (_updatePlayState === _undefined ? true : _updatePlayState); + + if (!options) { + options = {}; + } + + // first, use local URL (if specified) + if (s.url) { + s._iO.url = s.url; + } + + // mix in any options defined at createSound() + s._iO = mixin(s._iO, s.options); + + // mix in any options specific to this method + s._iO = mixin(options, s._iO); + + s._iO.url = parseURL(s._iO.url); + + s.instanceOptions = s._iO; + + // RTMP-only + if (!s.isHTML5 && s._iO.serverURL && !s.connected) { + if (!s.getAutoPlay()) { + sm2._wD(fN + ' Netstream not connected yet - setting autoPlay'); + s.setAutoPlay(true); + } + // play will be called in onconnect() + return s; + } + + if (html5OK(s._iO)) { + s._setup_html5(s._iO); + start_html5_timer(); + } + + if (s.playState === 1 && !s.paused) { + + allowMulti = s._iO.multiShot; + + if (!allowMulti) { + + sm2._wD(fN + 'Already playing (one-shot)', 1); + + if (s.isHTML5) { + // go back to original position. + s.setPosition(s._iO.position); + } + + return s; + + } + + sm2._wD(fN + 'Already playing (multi-shot)', 1); + + } + + // edge case: play() with explicit URL parameter + if (options.url && options.url !== s.url) { + + // special case for createSound() followed by load() / play() with url; avoid double-load case. + if (!s.readyState && !s.isHTML5 && fV === 8 && urlOmitted) { + + urlOmitted = false; + + } else { + + // load using merged options + s.load(s._iO); + + } + + } + + if (!s.loaded) { + + if (s.readyState === 0) { + + sm2._wD(fN + 'Attempting to load'); + + // try to get this sound playing ASAP + if (!s.isHTML5 && !sm2.html5Only) { + + // flash: assign directly because setAutoPlay() increments the instanceCount + s._iO.autoPlay = true; + s.load(s._iO); + + } else if (s.isHTML5) { + + // iOS needs this when recycling sounds, loading a new URL on an existing object. + s.load(s._iO); + + } else { + + sm2._wD(fN + 'Unsupported type. Exiting.'); + + return s; + + } + + // HTML5 hack - re-set instanceOptions? + s.instanceOptions = s._iO; + + } else if (s.readyState === 2) { + + sm2._wD(fN + 'Could not load - exiting', 2); + + return s; + + } else { + + sm2._wD(fN + 'Loading - attempting to play...'); + + } + + } else { + + // "play()" + sm2._wD(fN.substr(0, fN.lastIndexOf(':'))); + + } + + if (!s.isHTML5 && fV === 9 && s.position > 0 && s.position === s.duration) { + // flash 9 needs a position reset if play() is called while at the end of a sound. + sm2._wD(fN + 'Sound at end, resetting to position: 0'); + options.position = 0; + } + + /** + * Streams will pause when their buffer is full if they are being loaded. + * In this case paused is true, but the song hasn't started playing yet. + * If we just call resume() the onplay() callback will never be called. + * So only call resume() if the position is > 0. + * Another reason is because options like volume won't have been applied yet. + * For normal sounds, just resume. + */ + + if (s.paused && s.position >= 0 && (!s._iO.serverURL || s.position > 0)) { + + // https://gist.github.com/37b17df75cc4d7a90bf6 + sm2._wD(fN + 'Resuming from paused state', 1); + s.resume(); + + } else { + + s._iO = mixin(options, s._iO); + + /** + * Preload in the event of play() with position under Flash, + * or from/to parameters and non-RTMP case + */ + if (((!s.isHTML5 && s._iO.position !== null && s._iO.position > 0) || (s._iO.from !== null && s._iO.from > 0) || s._iO.to !== null) && s.instanceCount === 0 && s.playState === 0 && !s._iO.serverURL) { + + onready = function() { + // sound "canplay" or onload() + // re-apply position/from/to to instance options, and start playback + s._iO = mixin(options, s._iO); + s.play(s._iO); + }; + + // HTML5 needs to at least have "canplay" fired before seeking. + if (s.isHTML5 && !s._html5_canplay) { + + // this hasn't been loaded yet. load it first, and then do this again. + sm2._wD(fN + 'Beginning load for non-zero offset case'); + + s.load({ + // note: custom HTML5-only event added for from/to implementation. + _oncanplay: onready + }); + + } else if (!s.isHTML5 && !s.loaded && (!s.readyState || s.readyState !== 2)) { + + // to be safe, preload the whole thing in Flash. + + sm2._wD(fN + 'Preloading for non-zero offset case'); + + s.load({ + onload: onready + }); + + } + + // otherwise, we're ready to go. re-apply local options, and continue + + s._iO = applyFromTo(); + + } + + // sm2._wD(fN + 'Starting to play'); + + // increment instance counter, where enabled + supported + if (!s.instanceCount || s._iO.multiShotEvents || (s.isHTML5 && s._iO.multiShot && !useGlobalHTML5Audio) || (!s.isHTML5 && fV > 8 && !s.getAutoPlay())) { + s.instanceCount++; + } + + // if first play and onposition parameters exist, apply them now + if (s._iO.onposition && s.playState === 0) { + attachOnPosition(s); + } + + s.playState = 1; + s.paused = false; + + s.position = (s._iO.position !== _undefined && !isNaN(s._iO.position) ? s._iO.position : 0); + + if (!s.isHTML5) { + s._iO = policyFix(loopFix(s._iO)); + } + + if (s._iO.onplay && _updatePlayState) { + s._iO.onplay.apply(s); + onplay_called = true; + } + + s.setVolume(s._iO.volume, true); + s.setPan(s._iO.pan, true); + + if (s._iO.playbackRate !== 1) { + s.setPlaybackRate(s._iO.playbackRate); + } + + if (!s.isHTML5) { + + startOK = flash._start(s.id, s._iO.loops || 1, (fV === 9 ? s.position : s.position / msecScale), s._iO.multiShot || false); + + if (fV === 9 && !startOK) { + // edge case: no sound hardware, or 32-channel flash ceiling hit. + // applies only to Flash 9, non-NetStream/MovieStar sounds. + // http://help.adobe.com/en_US/FlashPlatform/reference/actionscript/3/flash/media/Sound.html#play%28%29 + sm2._wD(fN + 'No sound hardware, or 32-sound ceiling hit', 2); + if (s._iO.onplayerror) { + s._iO.onplayerror.apply(s); + } + + } + + } else if (s.instanceCount < 2) { + + // HTML5 single-instance case + + start_html5_timer(); + + a = s._setup_html5(); + + s.setPosition(s._iO.position); + + a.play(); + + } else { + + // HTML5 multi-shot case + + sm2._wD(s.id + ': Cloning Audio() for instance #' + s.instanceCount + '...'); + + audioClone = new Audio(s._iO.url); + + onended = function() { + event.remove(audioClone, 'ended', onended); + s._onfinish(s); + // cleanup + html5Unload(audioClone); + audioClone = null; + }; + + oncanplay = function() { + event.remove(audioClone, 'canplay', oncanplay); + try { + audioClone.currentTime = s._iO.position / msecScale; + } catch(err) { + complain(s.id + ': multiShot play() failed to apply position of ' + (s._iO.position / msecScale)); + } + audioClone.play(); + }; + + event.add(audioClone, 'ended', onended); + + // apply volume to clones, too + if (s._iO.volume !== _undefined) { + audioClone.volume = Math.max(0, Math.min(1, s._iO.volume / 100)); + } + + // playing multiple muted sounds? if you do this, you're weird ;) - but let's cover it. + if (s.muted) { + audioClone.muted = true; + } + + if (s._iO.position) { + // HTML5 audio can't seek before onplay() event has fired. + // wait for canplay, then seek to position and start playback. + event.add(audioClone, 'canplay', oncanplay); + } else { + // begin playback at currentTime: 0 + audioClone.play(); + } + + } + + } + + return s; + + }; + + // just for convenience + this.start = this.play; + + /** + * Stops playing a sound (and optionally, all sounds) + * + * @param {boolean} bAll Optional: Whether to stop all sounds + * @return {SMSound} The SMSound object + */ + + this.stop = function(bAll) { + + var instanceOptions = s._iO, + originalPosition; + + if (s.playState === 1) { + + sm2._wD(s.id + ': stop()'); + + s._onbufferchange(0); + s._resetOnPosition(0); + s.paused = false; + + if (!s.isHTML5) { + s.playState = 0; + } + + // remove onPosition listeners, if any + detachOnPosition(); + + // and "to" position, if set + if (instanceOptions.to) { + s.clearOnPosition(instanceOptions.to); + } + + if (!s.isHTML5) { + + flash._stop(s.id, bAll); + + // hack for netStream: just unload + if (instanceOptions.serverURL) { + s.unload(); + } + + } else if (s._a) { + + originalPosition = s.position; + + // act like Flash, though + s.setPosition(0); + + // hack: reflect old position for onstop() (also like Flash) + s.position = originalPosition; + + // html5 has no stop() + // NOTE: pausing means iOS requires interaction to resume. + s._a.pause(); + + s.playState = 0; + + // and update UI + s._onTimer(); + + stop_html5_timer(); + + } + + s.instanceCount = 0; + s._iO = {}; + + if (instanceOptions.onstop) { + instanceOptions.onstop.apply(s); + } + + } + + return s; + + }; + + /** + * Undocumented/internal: Sets autoPlay for RTMP. + * + * @param {boolean} autoPlay state + */ + + this.setAutoPlay = function(autoPlay) { + + sm2._wD(s.id + ': Autoplay turned ' + (autoPlay ? 'on' : 'off')); + s._iO.autoPlay = autoPlay; + + if (!s.isHTML5) { + flash._setAutoPlay(s.id, autoPlay); + if (autoPlay) { + // only increment the instanceCount if the sound isn't loaded (TODO: verify RTMP) + if (!s.instanceCount && s.readyState === 1) { + s.instanceCount++; + sm2._wD(s.id + ': Incremented instance count to ' + s.instanceCount); + } + } + } + + }; + + /** + * Undocumented/internal: Returns the autoPlay boolean. + * + * @return {boolean} The current autoPlay value + */ + + this.getAutoPlay = function() { + + return s._iO.autoPlay; + + }; + + /** + * Sets the playback rate of a sound (HTML5-only.) + * + * @param {number} playbackRate (+/-) + * @return {SMSound} The SMSound object + */ + + this.setPlaybackRate = function(playbackRate) { + + // Per Mozilla, limit acceptable values to prevent playback from stopping (unless allowOverride is truthy.) + // https://developer.mozilla.org/en-US/Apps/Build/Audio_and_video_delivery/WebAudio_playbackRate_explained + var normalizedRate = Math.max(0.5, Math.min(4, playbackRate)); + + // + if (normalizedRate !== playbackRate) { + sm2._wD(s.id + ': setPlaybackRate(' + playbackRate + '): limiting rate to ' + normalizedRate, 2); + } + // + + if (s.isHTML5) { + try { + s._iO.playbackRate = normalizedRate; + s._a.playbackRate = normalizedRate; + } catch(e) { + sm2._wD(s.id + ': setPlaybackRate(' + normalizedRate + ') failed: ' + e.message, 2); + } + } + + return s; + + }; + + /** + * Sets the position of a sound. + * + * @param {number} nMsecOffset Position (milliseconds) + * @return {SMSound} The SMSound object + */ + + this.setPosition = function(nMsecOffset) { + + if (nMsecOffset === _undefined) { + nMsecOffset = 0; + } + + var position, position1K, + // Use the duration from the instance options, if we don't have a track duration yet. + // position >= 0 and <= current available (loaded) duration + offset = (s.isHTML5 ? Math.max(nMsecOffset, 0) : Math.min(s.duration || s._iO.duration, Math.max(nMsecOffset, 0))); + + s.position = offset; + position1K = s.position / msecScale; + s._resetOnPosition(s.position); + s._iO.position = offset; + + if (!s.isHTML5) { + + position = (fV === 9 ? s.position : position1K); + + if (s.readyState && s.readyState !== 2) { + // if paused or not playing, will not resume (by playing) + flash._setPosition(s.id, position, (s.paused || !s.playState), s._iO.multiShot); + } + + } else if (s._a) { + + // Set the position in the canplay handler if the sound is not ready yet + if (s._html5_canplay) { + + if (s._a.currentTime.toFixed(3) !== position1K.toFixed(3)) { + + /** + * DOM/JS errors/exceptions to watch out for: + * if seek is beyond (loaded?) position, "DOM exception 11" + * "INDEX_SIZE_ERR": DOM exception 1 + */ + sm2._wD(s.id + ': setPosition(' + position1K + ')'); + + try { + s._a.currentTime = position1K; + if (s.playState === 0 || s.paused) { + // allow seek without auto-play/resume + s._a.pause(); + } + } catch(e) { + sm2._wD(s.id + ': setPosition(' + position1K + ') failed: ' + e.message, 2); + } + + } + + } else if (position1K) { + + // warn on non-zero seek attempts + sm2._wD(s.id + ': setPosition(' + position1K + '): Cannot seek yet, sound not ready', 2); + return s; + + } + + if (s.paused) { + + // if paused, refresh UI right away by forcing update + s._onTimer(true); + + } + + } + + return s; + + }; + + /** + * Pauses sound playback. + * + * @return {SMSound} The SMSound object + */ + + this.pause = function(_bCallFlash) { + + if (s.paused || (s.playState === 0 && s.readyState !== 1)) return s; + + sm2._wD(s.id + ': pause()'); + s.paused = true; + + if (!s.isHTML5) { + if (_bCallFlash || _bCallFlash === _undefined) { + flash._pause(s.id, s._iO.multiShot); + } + } else { + s._setup_html5().pause(); + stop_html5_timer(); + } + + if (s._iO.onpause) { + s._iO.onpause.apply(s); + } + + return s; + + }; + + /** + * Resumes sound playback. + * + * @return {SMSound} The SMSound object + */ + + /** + * When auto-loaded streams pause on buffer full they have a playState of 0. + * We need to make sure that the playState is set to 1 when these streams "resume". + * When a paused stream is resumed, we need to trigger the onplay() callback if it + * hasn't been called already. In this case since the sound is being played for the + * first time, I think it's more appropriate to call onplay() rather than onresume(). + */ + + this.resume = function() { + + var instanceOptions = s._iO; + + if (!s.paused) return s; + + sm2._wD(s.id + ': resume()'); + s.paused = false; + s.playState = 1; + + if (!s.isHTML5) { + + if (instanceOptions.isMovieStar && !instanceOptions.serverURL) { + // Bizarre Webkit bug (Chrome reported via 8tracks.com dudes): AAC content paused for 30+ seconds(?) will not resume without a reposition. + s.setPosition(s.position); + } + + // flash method is toggle-based (pause/resume) + flash._pause(s.id, instanceOptions.multiShot); + + } else { + + s._setup_html5().play(); + start_html5_timer(); + + } + + if (!onplay_called && instanceOptions.onplay) { + + instanceOptions.onplay.apply(s); + onplay_called = true; + + } else if (instanceOptions.onresume) { + + instanceOptions.onresume.apply(s); + + } + + return s; + + }; + + /** + * Toggles sound playback. + * + * @return {SMSound} The SMSound object + */ + + this.togglePause = function() { + + sm2._wD(s.id + ': togglePause()'); + + if (s.playState === 0) { + s.play({ + position: (fV === 9 && !s.isHTML5 ? s.position : s.position / msecScale) + }); + return s; + } + + if (s.paused) { + s.resume(); + } else { + s.pause(); + } + + return s; + + }; + + /** + * Sets the panning (L-R) effect. + * + * @param {number} nPan The pan value (-100 to 100) + * @return {SMSound} The SMSound object + */ + + this.setPan = function(nPan, bInstanceOnly) { + + if (nPan === _undefined) { + nPan = 0; + } + + if (bInstanceOnly === _undefined) { + bInstanceOnly = false; + } + + if (!s.isHTML5) { + flash._setPan(s.id, nPan); + } // else { no HTML5 pan? } + + s._iO.pan = nPan; + + if (!bInstanceOnly) { + s.pan = nPan; + s.options.pan = nPan; + } + + return s; + + }; + + /** + * Sets the volume. + * + * @param {number} nVol The volume value (0 to 100) + * @return {SMSound} The SMSound object + */ + + this.setVolume = function(nVol, _bInstanceOnly) { + + /** + * Note: Setting volume has no effect on iOS "special snowflake" devices. + * Hardware volume control overrides software, and volume + * will always return 1 per Apple docs. (iOS 4 + 5.) + * http://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/HTML-canvas-guide/AddingSoundtoCanvasAnimations/AddingSoundtoCanvasAnimations.html + */ + + if (nVol === _undefined) { + nVol = 100; + } + + if (_bInstanceOnly === _undefined) { + _bInstanceOnly = false; + } + + if (!s.isHTML5) { + + flash._setVolume(s.id, (sm2.muted && !s.muted) || s.muted ? 0 : nVol); + + } else if (s._a) { + + if (sm2.muted && !s.muted) { + s.muted = true; + s._a.muted = true; + } + + // valid range for native HTML5 Audio(): 0-1 + s._a.volume = Math.max(0, Math.min(1, nVol / 100)); + + } + + s._iO.volume = nVol; + + if (!_bInstanceOnly) { + s.volume = nVol; + s.options.volume = nVol; + } + + return s; + + }; + + /** + * Mutes the sound. + * + * @return {SMSound} The SMSound object + */ + + this.mute = function() { + + s.muted = true; + + if (!s.isHTML5) { + flash._setVolume(s.id, 0); + } else if (s._a) { + s._a.muted = true; + } + + return s; + + }; + + /** + * Unmutes the sound. + * + * @return {SMSound} The SMSound object + */ + + this.unmute = function() { + + s.muted = false; + var hasIO = (s._iO.volume !== _undefined); + + if (!s.isHTML5) { + flash._setVolume(s.id, hasIO ? s._iO.volume : s.options.volume); + } else if (s._a) { + s._a.muted = false; + } + + return s; + + }; + + /** + * Toggles the muted state of a sound. + * + * @return {SMSound} The SMSound object + */ + + this.toggleMute = function() { + + return (s.muted ? s.unmute() : s.mute()); + + }; + + /** + * Registers a callback to be fired when a sound reaches a given position during playback. + * + * @param {number} nPosition The position to watch for + * @param {function} oMethod The relevant callback to fire + * @param {object} oScope Optional: The scope to apply the callback to + * @return {SMSound} The SMSound object + */ + + this.onPosition = function(nPosition, oMethod, oScope) { + + // TODO: basic dupe checking? + + onPositionItems.push({ + position: parseInt(nPosition, 10), + method: oMethod, + scope: (oScope !== _undefined ? oScope : s), + fired: false + }); + + return s; + + }; + + // legacy/backwards-compability: lower-case method name + this.onposition = this.onPosition; + + /** + * Removes registered callback(s) from a sound, by position and/or callback. + * + * @param {number} nPosition The position to clear callback(s) for + * @param {function} oMethod Optional: Identify one callback to be removed when multiple listeners exist for one position + * @return {SMSound} The SMSound object + */ + + this.clearOnPosition = function(nPosition, oMethod) { + + var i; + + nPosition = parseInt(nPosition, 10); + + if (isNaN(nPosition)) { + // safety check + return; + } + + for (i = 0; i < onPositionItems.length; i++) { + + if (nPosition === onPositionItems[i].position) { + // remove this item if no method was specified, or, if the method matches + + if (!oMethod || (oMethod === onPositionItems[i].method)) { + + if (onPositionItems[i].fired) { + // decrement "fired" counter, too + onPositionFired--; + } + + onPositionItems.splice(i, 1); + + } + + } + + } + + }; + + this._processOnPosition = function() { + + var i, item, j = onPositionItems.length; + + if (!j || !s.playState || onPositionFired >= j) return false; + + for (i = j - 1; i >= 0; i--) { + + item = onPositionItems[i]; + + if (!item.fired && s.position >= item.position) { + + item.fired = true; + onPositionFired++; + item.method.apply(item.scope, [item.position]); + + // reset j -- onPositionItems.length can be changed in the item callback above... occasionally breaking the loop. + j = onPositionItems.length; + + } + + } + + return true; + + }; + + this._resetOnPosition = function(nPosition) { + + // reset "fired" for items interested in this position + var i, item, j = onPositionItems.length; + + if (!j) return false; + + for (i = j - 1; i >= 0; i--) { + + item = onPositionItems[i]; + + if (item.fired && nPosition <= item.position) { + item.fired = false; + onPositionFired--; + } + + } + + return true; + + }; + + /** + * SMSound() private internals + * -------------------------------- + */ + + applyFromTo = function() { + + var instanceOptions = s._iO, + f = instanceOptions.from, + t = instanceOptions.to, + start, end; + + end = function() { + + // end has been reached. + sm2._wD(s.id + ': "To" time of ' + t + ' reached.'); + + // detach listener + s.clearOnPosition(t, end); + + // stop should clear this, too + s.stop(); + + }; + + start = function() { + + sm2._wD(s.id + ': Playing "from" ' + f); + + // add listener for end + if (t !== null && !isNaN(t)) { + s.onPosition(t, end); + } + + }; + + if (f !== null && !isNaN(f)) { + + // apply to instance options, guaranteeing correct start position. + instanceOptions.position = f; + + // multiShot timing can't be tracked, so prevent that. + instanceOptions.multiShot = false; + + start(); + + } + + // return updated instanceOptions including starting position + return instanceOptions; + + }; + + attachOnPosition = function() { + + var item, + op = s._iO.onposition; + + // attach onposition things, if any, now. + + if (op) { + + for (item in op) { + if (op.hasOwnProperty(item)) { + s.onPosition(parseInt(item, 10), op[item]); + } + } + + } + + }; + + detachOnPosition = function() { + + var item, + op = s._iO.onposition; + + // detach any onposition()-style listeners. + + if (op) { + + for (item in op) { + if (op.hasOwnProperty(item)) { + s.clearOnPosition(parseInt(item, 10)); + } + } + + } + + }; + + start_html5_timer = function() { + + if (s.isHTML5) { + startTimer(s); + } + + }; + + stop_html5_timer = function() { + + if (s.isHTML5) { + stopTimer(s); + } + + }; + + resetProperties = function(retainPosition) { + + if (!retainPosition) { + onPositionItems = []; + onPositionFired = 0; + } + + onplay_called = false; + + s._hasTimer = null; + s._a = null; + s._html5_canplay = false; + s.bytesLoaded = null; + s.bytesTotal = null; + s.duration = (s._iO && s._iO.duration ? s._iO.duration : null); + s.durationEstimate = null; + s.buffered = []; + + // legacy: 1D array + s.eqData = []; + + s.eqData.left = []; + s.eqData.right = []; + + s.failures = 0; + s.isBuffering = false; + s.instanceOptions = {}; + s.instanceCount = 0; + s.loaded = false; + s.metadata = {}; + + // 0 = uninitialised, 1 = loading, 2 = failed/error, 3 = loaded/success + s.readyState = 0; + + s.muted = false; + s.paused = false; + + s.peakData = { + left: 0, + right: 0 + }; + + s.waveformData = { + left: [], + right: [] + }; + + s.playState = 0; + s.position = null; + + s.id3 = {}; + + }; + + resetProperties(); + + /** + * Pseudo-private SMSound internals + * -------------------------------- + */ + + this._onTimer = function(bForce) { + + /** + * HTML5-only _whileplaying() etc. + * called from both HTML5 native events, and polling/interval-based timers + * mimics flash and fires only when time/duration change, so as to be polling-friendly + */ + + var duration, isNew = false, time, x = {}; + + if (s._hasTimer || bForce) { + + // TODO: May not need to track readyState (1 = loading) + + if (s._a && (bForce || ((s.playState > 0 || s.readyState === 1) && !s.paused))) { + + duration = s._get_html5_duration(); + + if (duration !== lastHTML5State.duration) { + + lastHTML5State.duration = duration; + s.duration = duration; + isNew = true; + + } + + // TODO: investigate why this goes wack if not set/re-set each time. + s.durationEstimate = s.duration; + + time = (s._a.currentTime * msecScale || 0); + + if (time !== lastHTML5State.time) { + + lastHTML5State.time = time; + isNew = true; + + } + + if (isNew || bForce) { + + s._whileplaying(time, x, x, x, x); + + } + + }/* else { + + // sm2._wD('_onTimer: Warn for "'+s.id+'": '+(!s._a?'Could not find element. ':'')+(s.playState === 0?'playState bad, 0?':'playState = '+s.playState+', OK')); + + return false; + + }*/ + + } + + return isNew; + + }; + + this._get_html5_duration = function() { + + var instanceOptions = s._iO, + // if audio object exists, use its duration - else, instance option duration (if provided - it's a hack, really, and should be retired) OR null + d = (s._a && s._a.duration ? s._a.duration * msecScale : (instanceOptions && instanceOptions.duration ? instanceOptions.duration : null)), + result = (d && !isNaN(d) && d !== Infinity ? d : null); + + return result; + + }; + + this._apply_loop = function(a, nLoops) { + + /** + * boolean instead of "loop", for webkit? - spec says string. http://www.w3.org/TR/html-markup/audio.html#audio.attrs.loop + * note that loop is either off or infinite under HTML5, unlike Flash which allows arbitrary loop counts to be specified. + */ + + // + if (!a.loop && nLoops > 1) { + sm2._wD('Note: Native HTML5 looping is infinite.', 1); + } + // + + a.loop = (nLoops > 1 ? 'loop' : ''); + + }; + + this._setup_html5 = function(options) { + + var instanceOptions = mixin(s._iO, options), + a = useGlobalHTML5Audio ? globalHTML5Audio : s._a, + dURL = decodeURI(instanceOptions.url), + sameURL; + + /** + * "First things first, I, Poppa..." (reset the previous state of the old sound, if playing) + * Fixes case with devices that can only play one sound at a time + * Otherwise, other sounds in mid-play will be terminated without warning and in a stuck state + */ + + if (useGlobalHTML5Audio) { + + if (dURL === decodeURI(lastGlobalHTML5URL)) { + // global HTML5 audio: re-use of URL + sameURL = true; + } + + } else if (dURL === decodeURI(lastURL)) { + + // options URL is the same as the "last" URL, and we used (loaded) it + sameURL = true; + + } + + if (a) { + + if (a._s) { + + if (useGlobalHTML5Audio) { + + if (a._s && a._s.playState && !sameURL) { + + // global HTML5 audio case, and loading a new URL. stop the currently-playing one. + a._s.stop(); + + } + + } else if (!useGlobalHTML5Audio && dURL === decodeURI(lastURL)) { + + // non-global HTML5 reuse case: same url, ignore request + s._apply_loop(a, instanceOptions.loops); + + return a; + + } + + } + + if (!sameURL) { + + // don't retain onPosition() stuff with new URLs. + + if (lastURL) { + resetProperties(false); + } + + // assign new HTML5 URL + + a.src = instanceOptions.url; + + s.url = instanceOptions.url; + + lastURL = instanceOptions.url; + + lastGlobalHTML5URL = instanceOptions.url; + + a._called_load = false; + + } + + } else { + + if (instanceOptions.autoLoad || instanceOptions.autoPlay) { + + s._a = new Audio(instanceOptions.url); + s._a.load(); + + } else { + + // null for stupid Opera 9.64 case + s._a = (isOpera && opera.version() < 10 ? new Audio(null) : new Audio()); + + } + + // assign local reference + a = s._a; + + a._called_load = false; + + if (useGlobalHTML5Audio) { + + globalHTML5Audio = a; + + } + + } + + s.isHTML5 = true; + + // store a ref on the track + s._a = a; + + // store a ref on the audio + a._s = s; + + add_html5_events(); + + s._apply_loop(a, instanceOptions.loops); + + if (instanceOptions.autoLoad || instanceOptions.autoPlay) { + + s.load(); + + } else { + + // early HTML5 implementation (non-standard) + a.autobuffer = false; + + // standard ('none' is also an option.) + a.preload = 'auto'; + + } + + return a; + + }; + + add_html5_events = function() { + + if (s._a._added_events) return false; + + var f; + + function add(oEvt, oFn, bCapture) { + return s._a ? s._a.addEventListener(oEvt, oFn, bCapture || false) : null; + } + + s._a._added_events = true; + + for (f in html5_events) { + if (html5_events.hasOwnProperty(f)) { + add(f, html5_events[f]); + } + } + + return true; + + }; + + remove_html5_events = function() { + + // Remove event listeners + + var f; + + function remove(oEvt, oFn, bCapture) { + return (s._a ? s._a.removeEventListener(oEvt, oFn, bCapture || false) : null); + } + + sm2._wD(s.id + ': Removing event listeners'); + s._a._added_events = false; + + for (f in html5_events) { + if (html5_events.hasOwnProperty(f)) { + remove(f, html5_events[f]); + } + } + + }; + + /** + * Pseudo-private event internals + * ------------------------------ + */ + + this._onload = function(nSuccess) { + + var fN, + // check for duration to prevent false positives from flash 8 when loading from cache. + loadOK = !!nSuccess || (!s.isHTML5 && fV === 8 && s.duration); + + // + fN = s.id + ': '; + sm2._wD(fN + (loadOK ? 'onload()' : 'Failed to load / invalid sound?' + (!s.duration ? ' Zero-length duration reported.' : ' -') + ' (' + s.url + ')'), (loadOK ? 1 : 2)); + + if (!loadOK && !s.isHTML5) { + if (sm2.sandbox.noRemote === true) { + sm2._wD(fN + str('noNet'), 1); + } + if (sm2.sandbox.noLocal === true) { + sm2._wD(fN + str('noLocal'), 1); + } + } + // + + s.loaded = loadOK; + s.readyState = (loadOK ? 3 : 2); + s._onbufferchange(0); + + if (!loadOK && !s.isHTML5) { + // note: no error code from Flash. + s._onerror(); + } + + if (s._iO.onload) { + wrapCallback(s, function() { + s._iO.onload.apply(s, [loadOK]); + }); + } + + return true; + + }; + + this._onerror = function(errorCode, description) { + + // https://html.spec.whatwg.org/multipage/embedded-content.html#error-codes + if (s._iO.onerror) { + wrapCallback(s, function() { + s._iO.onerror.apply(s, [errorCode, description]); + }); + } + + }; + + this._onbufferchange = function(nIsBuffering) { + + // ignore if not playing + if (s.playState === 0) return false; + + if ((nIsBuffering && s.isBuffering) || (!nIsBuffering && !s.isBuffering)) return false; + + s.isBuffering = (nIsBuffering === 1); + + if (s._iO.onbufferchange) { + sm2._wD(s.id + ': Buffer state change: ' + nIsBuffering); + s._iO.onbufferchange.apply(s, [nIsBuffering]); + } + + return true; + + }; + + /** + * Playback may have stopped due to buffering, or related reason. + * This state can be encountered on iOS < 6 when auto-play is blocked. + */ + + this._onsuspend = function() { + + if (s._iO.onsuspend) { + sm2._wD(s.id + ': Playback suspended'); + s._iO.onsuspend.apply(s); + } + + return true; + + }; + + /** + * flash 9/movieStar + RTMP-only method, should fire only once at most + * at this point we just recreate failed sounds rather than trying to reconnect + */ + + this._onfailure = function(msg, level, code) { + + s.failures++; + sm2._wD(s.id + ': Failure (' + s.failures + '): ' + msg); + + if (s._iO.onfailure && s.failures === 1) { + s._iO.onfailure(msg, level, code); + } else { + sm2._wD(s.id + ': Ignoring failure'); + } + + }; + + /** + * flash 9/movieStar + RTMP-only method for unhandled warnings/exceptions from Flash + * e.g., RTMP "method missing" warning (non-fatal) for getStreamLength on server + */ + + this._onwarning = function(msg, level, code) { + + if (s._iO.onwarning) { + s._iO.onwarning(msg, level, code); + } + + }; + + this._onfinish = function() { + + // store local copy before it gets trashed... + var io_onfinish = s._iO.onfinish; + + s._onbufferchange(0); + s._resetOnPosition(0); + + // reset some state items + if (s.instanceCount) { + + s.instanceCount--; + + if (!s.instanceCount) { + + // remove onPosition listeners, if any + detachOnPosition(); + + // reset instance options + s.playState = 0; + s.paused = false; + s.instanceCount = 0; + s.instanceOptions = {}; + s._iO = {}; + stop_html5_timer(); + + // reset position, too + if (s.isHTML5) { + s.position = 0; + } + + } + + if (!s.instanceCount || s._iO.multiShotEvents) { + // fire onfinish for last, or every instance + if (io_onfinish) { + sm2._wD(s.id + ': onfinish()'); + wrapCallback(s, function() { + io_onfinish.apply(s); + }); + } + } + + } + + }; + + this._whileloading = function(nBytesLoaded, nBytesTotal, nDuration, nBufferLength) { + + var instanceOptions = s._iO; + + s.bytesLoaded = nBytesLoaded; + s.bytesTotal = nBytesTotal; + s.duration = Math.floor(nDuration); + s.bufferLength = nBufferLength; + + if (!s.isHTML5 && !instanceOptions.isMovieStar) { + + if (instanceOptions.duration) { + // use duration from options, if specified and larger. nobody should be specifying duration in options, actually, and it should be retired. + s.durationEstimate = (s.duration > instanceOptions.duration) ? s.duration : instanceOptions.duration; + } else { + s.durationEstimate = parseInt((s.bytesTotal / s.bytesLoaded) * s.duration, 10); + } + + } else { + + s.durationEstimate = s.duration; + + } + + // for flash, reflect sequential-load-style buffering + if (!s.isHTML5) { + s.buffered = [{ + start: 0, + end: s.duration + }]; + } + + // allow whileloading to fire even if "load" fired under HTML5, due to HTTP range/partials + if ((s.readyState !== 3 || s.isHTML5) && instanceOptions.whileloading) { + instanceOptions.whileloading.apply(s); + } + + }; + + this._whileplaying = function(nPosition, oPeakData, oWaveformDataLeft, oWaveformDataRight, oEQData) { + + var instanceOptions = s._iO, + eqLeft; + + // flash safety net + if (isNaN(nPosition) || nPosition === null) return false; + + // Safari HTML5 play() may return small -ve values when starting from position: 0, eg. -50.120396875. Unexpected/invalid per W3, I think. Normalize to 0. + s.position = Math.max(0, nPosition); + + s._processOnPosition(); + + if (!s.isHTML5 && fV > 8) { + + if (instanceOptions.usePeakData && oPeakData !== _undefined && oPeakData) { + s.peakData = { + left: oPeakData.leftPeak, + right: oPeakData.rightPeak + }; + } + + if (instanceOptions.useWaveformData && oWaveformDataLeft !== _undefined && oWaveformDataLeft) { + s.waveformData = { + left: oWaveformDataLeft.split(','), + right: oWaveformDataRight.split(',') + }; + } + + if (instanceOptions.useEQData) { + if (oEQData !== _undefined && oEQData && oEQData.leftEQ) { + eqLeft = oEQData.leftEQ.split(','); + s.eqData = eqLeft; + s.eqData.left = eqLeft; + if (oEQData.rightEQ !== _undefined && oEQData.rightEQ) { + s.eqData.right = oEQData.rightEQ.split(','); + } + } + } + + } + + if (s.playState === 1) { + + // special case/hack: ensure buffering is false if loading from cache (and not yet started) + if (!s.isHTML5 && fV === 8 && !s.position && s.isBuffering) { + s._onbufferchange(0); + } + + if (instanceOptions.whileplaying) { + // flash may call after actual finish + instanceOptions.whileplaying.apply(s); + } + + } + + return true; + + }; + + this._oncaptiondata = function(oData) { + + /** + * internal: flash 9 + NetStream (MovieStar/RTMP-only) feature + * + * @param {object} oData + */ + + sm2._wD(s.id + ': Caption data received.'); + + s.captiondata = oData; + + if (s._iO.oncaptiondata) { + s._iO.oncaptiondata.apply(s, [oData]); + } + + }; + + this._onmetadata = function(oMDProps, oMDData) { + + /** + * internal: flash 9 + NetStream (MovieStar/RTMP-only) feature + * RTMP may include song title, MovieStar content may include encoding info + * + * @param {array} oMDProps (names) + * @param {array} oMDData (values) + */ + + sm2._wD(s.id + ': Metadata received.'); + + var oData = {}, i, j; + + for (i = 0, j = oMDProps.length; i < j; i++) { + oData[oMDProps[i]] = oMDData[i]; + } + + s.metadata = oData; + + if (s._iO.onmetadata) { + s._iO.onmetadata.call(s, s.metadata); + } + + }; + + this._onid3 = function(oID3Props, oID3Data) { + + /** + * internal: flash 8 + flash 9 ID3 feature + * may include artist, song title etc. + * + * @param {array} oID3Props (names) + * @param {array} oID3Data (values) + */ + + sm2._wD(s.id + ': ID3 data received.'); + + var oData = [], i, j; + + for (i = 0, j = oID3Props.length; i < j; i++) { + oData[oID3Props[i]] = oID3Data[i]; + } + + s.id3 = mixin(s.id3, oData); + + if (s._iO.onid3) { + s._iO.onid3.apply(s); + } + + }; + + // flash/RTMP-only + + this._onconnect = function(bSuccess) { + + bSuccess = (bSuccess === 1); + sm2._wD(s.id + ': ' + (bSuccess ? 'Connected.' : 'Failed to connect? - ' + s.url), (bSuccess ? 1 : 2)); + s.connected = bSuccess; + + if (bSuccess) { + + s.failures = 0; + + if (idCheck(s.id)) { + if (s.getAutoPlay()) { + // only update the play state if auto playing + s.play(_undefined, s.getAutoPlay()); + } else if (s._iO.autoLoad) { + s.load(); + } + } + + if (s._iO.onconnect) { + s._iO.onconnect.apply(s, [bSuccess]); + } + + } + + }; + + this._ondataerror = function(sError) { + + // flash 9 wave/eq data handler + // hack: called at start, and end from flash at/after onfinish() + if (s.playState > 0) { + sm2._wD(s.id + ': Data error: ' + sError); + if (s._iO.ondataerror) { + s._iO.ondataerror.apply(s); + } + } + + }; + + // + this._debug(); + // + + }; // SMSound() + + /** + * Private SoundManager internals + * ------------------------------ + */ + + getDocument = function() { + + return (doc.body || doc.getElementsByTagName('div')[0]); + + }; + + id = function(sID) { + + return doc.getElementById(sID); + + }; + + mixin = function(oMain, oAdd) { + + // non-destructive merge + var o1 = (oMain || {}), o2, o; + + // if unspecified, o2 is the default options object + o2 = (oAdd === _undefined ? sm2.defaultOptions : oAdd); + + for (o in o2) { + + if (o2.hasOwnProperty(o) && o1[o] === _undefined) { + + if (typeof o2[o] !== 'object' || o2[o] === null) { + + // assign directly + o1[o] = o2[o]; + + } else { + + // recurse through o2 + o1[o] = mixin(o1[o], o2[o]); + + } + + } + + } + + return o1; + + }; + + wrapCallback = function(oSound, callback) { + + /** + * 03/03/2013: Fix for Flash Player 11.6.602.171 + Flash 8 (flashVersion = 8) SWF issue + * setTimeout() fix for certain SMSound callbacks like onload() and onfinish(), where subsequent calls like play() and load() fail when Flash Player 11.6.602.171 is installed, and using soundManager with flashVersion = 8 (which is the default). + * Not sure of exact cause. Suspect race condition and/or invalid (NaN-style) position argument trickling down to the next JS -> Flash _start() call, in the play() case. + * Fix: setTimeout() to yield, plus safer null / NaN checking on position argument provided to Flash. + * https://getsatisfaction.com/schillmania/topics/recent_chrome_update_seems_to_have_broken_my_sm2_audio_player + */ + if (!oSound.isHTML5 && fV === 8) { + window.setTimeout(callback, 0); + } else { + callback(); + } + + }; + + // additional soundManager properties that soundManager.setup() will accept + + extraOptions = { + onready: 1, + ontimeout: 1, + defaultOptions: 1, + flash9Options: 1, + movieStarOptions: 1 + }; + + assign = function(o, oParent) { + + /** + * recursive assignment of properties, soundManager.setup() helper + * allows property assignment based on whitelist + */ + + var i, + result = true, + hasParent = (oParent !== _undefined), + setupOptions = sm2.setupOptions, + bonusOptions = extraOptions; + + // + + // if soundManager.setup() called, show accepted parameters. + + if (o === _undefined) { + + result = []; + + for (i in setupOptions) { + + if (setupOptions.hasOwnProperty(i)) { + result.push(i); + } + + } + + for (i in bonusOptions) { + + if (bonusOptions.hasOwnProperty(i)) { + + if (typeof sm2[i] === 'object') { + result.push(i + ': {...}'); + } else if (sm2[i] instanceof Function) { + result.push(i + ': function() {...}'); + } else { + result.push(i); + } + + } + + } + + sm2._wD(str('setup', result.join(', '))); + + return false; + + } + + // + + for (i in o) { + + if (o.hasOwnProperty(i)) { + + // if not an {object} we want to recurse through... + + if (typeof o[i] !== 'object' || o[i] === null || o[i] instanceof Array || o[i] instanceof RegExp) { + + // check "allowed" options + + if (hasParent && bonusOptions[oParent] !== _undefined) { + + // valid recursive / nested object option, eg., { defaultOptions: { volume: 50 } } + sm2[oParent][i] = o[i]; + + } else if (setupOptions[i] !== _undefined) { + + // special case: assign to setupOptions object, which soundManager property references + sm2.setupOptions[i] = o[i]; + + // assign directly to soundManager, too + sm2[i] = o[i]; + + } else if (bonusOptions[i] === _undefined) { + + // invalid or disallowed parameter. complain. + complain(str((sm2[i] === _undefined ? 'setupUndef' : 'setupError'), i), 2); + + result = false; + + } else if (sm2[i] instanceof Function) { + + /** + * valid extraOptions (bonusOptions) parameter. + * is it a method, like onready/ontimeout? call it. + * multiple parameters should be in an array, eg. soundManager.setup({onready: [myHandler, myScope]}); + */ + sm2[i].apply(sm2, (o[i] instanceof Array ? o[i] : [o[i]])); + + } else { + + // good old-fashioned direct assignment + sm2[i] = o[i]; + + } + + } else if (bonusOptions[i] === _undefined) { + + // recursion case, eg., { defaultOptions: { ... } } + + // invalid or disallowed parameter. complain. + complain(str((sm2[i] === _undefined ? 'setupUndef' : 'setupError'), i), 2); + + result = false; + + } else { + + // recurse through object + return assign(o[i], i); + + } + + } + + } + + return result; + + }; + + function preferFlashCheck(kind) { + + // whether flash should play a given type + return (sm2.preferFlash && hasFlash && !sm2.ignoreFlash && (sm2.flash[kind] !== _undefined && sm2.flash[kind])); + + } + + /** + * Internal DOM2-level event helpers + * --------------------------------- + */ + + event = (function() { + + // normalize event methods + var old = (window.attachEvent), + evt = { + add: (old ? 'attachEvent' : 'addEventListener'), + remove: (old ? 'detachEvent' : 'removeEventListener') + }; + + // normalize "on" event prefix, optional capture argument + function getArgs(oArgs) { + + var args = slice.call(oArgs), + len = args.length; + + if (old) { + // prefix + args[1] = 'on' + args[1]; + if (len > 3) { + // no capture + args.pop(); + } + } else if (len === 3) { + args.push(false); + } + + return args; + + } + + function apply(args, sType) { + + // normalize and call the event method, with the proper arguments + var element = args.shift(), + method = [evt[sType]]; + + if (old) { + // old IE can't do apply(). + element[method](args[0], args[1]); + } else { + element[method].apply(element, args); + } + + } + + function add() { + apply(getArgs(arguments), 'add'); + } + + function remove() { + apply(getArgs(arguments), 'remove'); + } + + return { + add: add, + remove: remove + }; + + }()); + + /** + * Internal HTML5 event handling + * ----------------------------- + */ + + function html5_event(oFn) { + + // wrap html5 event handlers so we don't call them on destroyed and/or unloaded sounds + + return function(e) { + + var s = this._s, + result; + + if (!s || !s._a) { + // + if (s && s.id) { + sm2._wD(s.id + ': Ignoring ' + e.type); + } else { + sm2._wD(h5 + 'Ignoring ' + e.type); + } + // + result = null; + } else { + result = oFn.call(this, e); + } + + return result; + + }; + + } + + html5_events = { + + // HTML5 event-name-to-handler map + + abort: html5_event(function() { + + sm2._wD(this._s.id + ': abort'); + + }), + + // enough has loaded to play + + canplay: html5_event(function() { + + var s = this._s, + position1K; + + if (s._html5_canplay) { + // this event has already fired. ignore. + return; + } + + s._html5_canplay = true; + sm2._wD(s.id + ': canplay'); + s._onbufferchange(0); + + // position according to instance options + position1K = (s._iO.position !== _undefined && !isNaN(s._iO.position) ? s._iO.position / msecScale : null); + + // set the position if position was provided before the sound loaded + if (this.currentTime !== position1K) { + sm2._wD(s.id + ': canplay: Setting position to ' + position1K); + try { + this.currentTime = position1K; + } catch(ee) { + sm2._wD(s.id + ': canplay: Setting position of ' + position1K + ' failed: ' + ee.message, 2); + } + } + + // hack for HTML5 from/to case + if (s._iO._oncanplay) { + s._iO._oncanplay(); + } + + }), + + canplaythrough: html5_event(function() { + + var s = this._s; + + if (!s.loaded) { + s._onbufferchange(0); + s._whileloading(s.bytesLoaded, s.bytesTotal, s._get_html5_duration()); + s._onload(true); + } + + }), + + durationchange: html5_event(function() { + + // durationchange may fire at various times, probably the safest way to capture accurate/final duration. + + var s = this._s, + duration; + + duration = s._get_html5_duration(); + + if (!isNaN(duration) && duration !== s.duration) { + + sm2._wD(this._s.id + ': durationchange (' + duration + ')' + (s.duration ? ', previously ' + s.duration : '')); + + s.durationEstimate = s.duration = duration; + + } + + }), + + // TODO: Reserved for potential use + /* + emptied: html5_event(function() { + + sm2._wD(this._s.id + ': emptied'); + + }), + */ + + ended: html5_event(function() { + + var s = this._s; + + sm2._wD(s.id + ': ended'); + + s._onfinish(); + + }), + + error: html5_event(function() { + + var description = (html5ErrorCodes[this.error.code] || null); + sm2._wD(this._s.id + ': HTML5 error, code ' + this.error.code + (description ? ' (' + description + ')' : '')); + this._s._onload(false); + this._s._onerror(this.error.code, description); + + }), + + loadeddata: html5_event(function() { + + var s = this._s; + + sm2._wD(s.id + ': loadeddata'); + + // safari seems to nicely report progress events, eventually totalling 100% + if (!s._loaded && !isSafari) { + s.duration = s._get_html5_duration(); + } + + }), + + loadedmetadata: html5_event(function() { + + sm2._wD(this._s.id + ': loadedmetadata'); + + }), + + loadstart: html5_event(function() { + + sm2._wD(this._s.id + ': loadstart'); + // assume buffering at first + this._s._onbufferchange(1); + + }), + + play: html5_event(function() { + + // sm2._wD(this._s.id + ': play()'); + // once play starts, no buffering + this._s._onbufferchange(0); + + }), + + playing: html5_event(function() { + + sm2._wD(this._s.id + ': playing ' + String.fromCharCode(9835)); + // once play starts, no buffering + this._s._onbufferchange(0); + + }), + + progress: html5_event(function(e) { + + // note: can fire repeatedly after "loaded" event, due to use of HTTP range/partials + + var s = this._s, + i, j, progStr, buffered = 0, + isProgress = (e.type === 'progress'), + ranges = e.target.buffered, + // firefox 3.6 implements e.loaded/total (bytes) + loaded = (e.loaded || 0), + total = (e.total || 1); + + // reset the "buffered" (loaded byte ranges) array + s.buffered = []; + + if (ranges && ranges.length) { + + // if loaded is 0, try TimeRanges implementation as % of load + // https://developer.mozilla.org/en/DOM/TimeRanges + + // re-build "buffered" array + // HTML5 returns seconds. SM2 API uses msec for setPosition() etc., whether Flash or HTML5. + for (i = 0, j = ranges.length; i < j; i++) { + s.buffered.push({ + start: ranges.start(i) * msecScale, + end: ranges.end(i) * msecScale + }); + } + + // use the last value locally + buffered = (ranges.end(0) - ranges.start(0)) * msecScale; + + // linear case, buffer sum; does not account for seeking and HTTP partials / byte ranges + loaded = Math.min(1, buffered / (e.target.duration * msecScale)); + + // + if (isProgress && ranges.length > 1) { + progStr = []; + j = ranges.length; + for (i = 0; i < j; i++) { + progStr.push((e.target.buffered.start(i) * msecScale) + '-' + (e.target.buffered.end(i) * msecScale)); + } + sm2._wD(this._s.id + ': progress, timeRanges: ' + progStr.join(', ')); + } + + if (isProgress && !isNaN(loaded)) { + sm2._wD(this._s.id + ': progress, ' + Math.floor(loaded * 100) + '% loaded'); + } + // + + } + + if (!isNaN(loaded)) { + + // TODO: prevent calls with duplicate values. + s._whileloading(loaded, total, s._get_html5_duration()); + if (loaded && total && loaded === total) { + // in case "onload" doesn't fire (eg. gecko 1.9.2) + html5_events.canplaythrough.call(this, e); + } + + } + + }), + + ratechange: html5_event(function() { + + sm2._wD(this._s.id + ': ratechange'); + + }), + + suspend: html5_event(function(e) { + + // download paused/stopped, may have finished (eg. onload) + var s = this._s; + + sm2._wD(this._s.id + ': suspend'); + html5_events.progress.call(this, e); + s._onsuspend(); + + }), + + stalled: html5_event(function() { + + sm2._wD(this._s.id + ': stalled'); + + }), + + timeupdate: html5_event(function() { + + this._s._onTimer(); + + }), + + waiting: html5_event(function() { + + var s = this._s; + + // see also: seeking + sm2._wD(this._s.id + ': waiting'); + + // playback faster than download rate, etc. + s._onbufferchange(1); + + }) + + }; + + html5OK = function(iO) { + + // playability test based on URL or MIME type + + var result; + + if (!iO || (!iO.type && !iO.url && !iO.serverURL)) { + + // nothing to check + result = false; + + } else if (iO.serverURL || (iO.type && preferFlashCheck(iO.type))) { + + // RTMP, or preferring flash + result = false; + + } else { + + // Use type, if specified. Pass data: URIs to HTML5. If HTML5-only mode, no other options, so just give 'er + result = ((iO.type ? html5CanPlay({ type: iO.type }) : html5CanPlay({ url: iO.url }) || sm2.html5Only || iO.url.match(/data:/i))); + + } + + return result; + + }; + + html5Unload = function(oAudio) { + + /** + * Internal method: Unload media, and cancel any current/pending network requests. + * Firefox can load an empty URL, which allegedly destroys the decoder and stops the download. + * https://developer.mozilla.org/En/Using_audio_and_video_in_Firefox#Stopping_the_download_of_media + * However, Firefox has been seen loading a relative URL from '' and thus requesting the hosting page on unload. + * Other UA behaviour is unclear, so everyone else gets an about:blank-style URL. + */ + + var url; + + if (oAudio) { + + // Firefox and Chrome accept short WAVe data: URIs. Chome dislikes audio/wav, but accepts audio/wav for data: MIME. + // Desktop Safari complains / fails on data: URI, so it gets about:blank. + url = (isSafari ? emptyURL : (sm2.html5.canPlayType('audio/wav') ? emptyWAV : emptyURL)); + + oAudio.src = url; + + // reset some state, too + if (oAudio._called_unload !== _undefined) { + oAudio._called_load = false; + } + + } + + if (useGlobalHTML5Audio) { + + // ensure URL state is trashed, also + lastGlobalHTML5URL = null; + + } + + return url; + + }; + + html5CanPlay = function(o) { + + /** + * Try to find MIME, test and return truthiness + * o = { + * url: '/path/to/an.mp3', + * type: 'audio/mp3' + * } + */ + + if (!sm2.useHTML5Audio || !sm2.hasHTML5) return false; + + var url = (o.url || null), + mime = (o.type || null), + aF = sm2.audioFormats, + result, + offset, + fileExt, + item; + + // account for known cases like audio/mp3 + + if (mime && sm2.html5[mime] !== _undefined) return (sm2.html5[mime] && !preferFlashCheck(mime)); + + if (!html5Ext) { + + html5Ext = []; + + for (item in aF) { + + if (aF.hasOwnProperty(item)) { + + html5Ext.push(item); + + if (aF[item].related) { + html5Ext = html5Ext.concat(aF[item].related); + } + + } + + } + + html5Ext = new RegExp('\\.(' + html5Ext.join('|') + ')(\\?.*)?$', 'i'); + + } + + // TODO: Strip URL queries, etc. + fileExt = (url ? url.toLowerCase().match(html5Ext) : null); + + if (!fileExt || !fileExt.length) { + + if (!mime) { + + result = false; + + } else { + + // audio/mp3 -> mp3, result should be known + offset = mime.indexOf(';'); + + // strip "audio/X; codecs..." + fileExt = (offset !== -1 ? mime.substr(0, offset) : mime).substr(6); + + } + + } else { + + // match the raw extension name - "mp3", for example + fileExt = fileExt[1]; + + } + + if (fileExt && sm2.html5[fileExt] !== _undefined) { + + // result known + result = (sm2.html5[fileExt] && !preferFlashCheck(fileExt)); + + } else { + + mime = 'audio/' + fileExt; + result = sm2.html5.canPlayType({ type: mime }); + + sm2.html5[fileExt] = result; + + // sm2._wD('canPlayType, found result: ' + result); + result = (result && sm2.html5[mime] && !preferFlashCheck(mime)); + } + + return result; + + }; + + testHTML5 = function() { + + /** + * Internal: Iterates over audioFormats, determining support eg. audio/mp3, audio/mpeg and so on + * assigns results to html5[] and flash[]. + */ + + if (!sm2.useHTML5Audio || !sm2.hasHTML5) { + + // without HTML5, we need Flash. + sm2.html5.usingFlash = true; + needsFlash = true; + + return false; + + } + + // double-whammy: Opera 9.64 throws WRONG_ARGUMENTS_ERR if no parameter passed to Audio(), and Webkit + iOS happily tries to load "null" as a URL. :/ + var a = (Audio !== _undefined ? (isOpera && opera.version() < 10 ? new Audio(null) : new Audio()) : null), + item, lookup, support = {}, aF, i; + + function cp(m) { + + var canPlay, j, + result = false, + isOK = false; + + if (!a || typeof a.canPlayType !== 'function') return result; + + if (m instanceof Array) { + + // iterate through all mime types, return any successes + + for (i = 0, j = m.length; i < j; i++) { + + if (sm2.html5[m[i]] || a.canPlayType(m[i]).match(sm2.html5Test)) { + + isOK = true; + sm2.html5[m[i]] = true; + + // note flash support, too + sm2.flash[m[i]] = !!(m[i].match(flashMIME)); + + } + + } + + result = isOK; + + } else { + + canPlay = (a && typeof a.canPlayType === 'function' ? a.canPlayType(m) : false); + result = !!(canPlay && (canPlay.match(sm2.html5Test))); + + } + + return result; + + } + + // test all registered formats + codecs + + aF = sm2.audioFormats; + + for (item in aF) { + + if (aF.hasOwnProperty(item)) { + + lookup = 'audio/' + item; + + support[item] = cp(aF[item].type); + + // write back generic type too, eg. audio/mp3 + support[lookup] = support[item]; + + // assign flash + if (item.match(flashMIME)) { + + sm2.flash[item] = true; + sm2.flash[lookup] = true; + + } else { + + sm2.flash[item] = false; + sm2.flash[lookup] = false; + + } + + // assign result to related formats, too + + if (aF[item] && aF[item].related) { + + for (i = aF[item].related.length - 1; i >= 0; i--) { + + // eg. audio/m4a + support['audio/' + aF[item].related[i]] = support[item]; + sm2.html5[aF[item].related[i]] = support[item]; + sm2.flash[aF[item].related[i]] = support[item]; + + } + + } + + } + + } + + support.canPlayType = (a ? cp : null); + sm2.html5 = mixin(sm2.html5, support); + + sm2.html5.usingFlash = featureCheck(); + needsFlash = sm2.html5.usingFlash; + + return true; + + }; + + strings = { + + // + notReady: 'Unavailable - wait until onready() has fired.', + notOK: 'Audio support is not available.', + domError: sm + 'exception caught while appending SWF to DOM.', + spcWmode: 'Removing wmode, preventing known SWF loading issue(s)', + swf404: smc + 'Verify that %s is a valid path.', + tryDebug: 'Try ' + sm + '.debugFlash = true for more security details (output goes to SWF.)', + checkSWF: 'See SWF output for more debug info.', + localFail: smc + 'Non-HTTP page (' + doc.location.protocol + ' URL?) Review Flash player security settings for this special case:\nhttp://www.macromedia.com/support/documentation/en/flashplayer/help/settings_manager04.html\nMay need to add/allow path, eg. c:/sm2/ or /users/me/sm2/', + waitFocus: smc + 'Special case: Waiting for SWF to load with window focus...', + waitForever: smc + 'Waiting indefinitely for Flash (will recover if unblocked)...', + waitSWF: smc + 'Waiting for 100% SWF load...', + needFunction: smc + 'Function object expected for %s', + badID: 'Sound ID "%s" should be a string, starting with a non-numeric character', + currentObj: smc + '_debug(): Current sound objects', + waitOnload: smc + 'Waiting for window.onload()', + docLoaded: smc + 'Document already loaded', + onload: smc + 'initComplete(): calling soundManager.onload()', + onloadOK: sm + '.onload() complete', + didInit: smc + 'init(): Already called?', + secNote: 'Flash security note: Network/internet URLs will not load due to security restrictions. Access can be configured via Flash Player Global Security Settings Page: http://www.macromedia.com/support/documentation/en/flashplayer/help/settings_manager04.html', + badRemove: smc + 'Failed to remove Flash node.', + shutdown: sm + '.disable(): Shutting down', + queue: smc + 'Queueing %s handler', + smError: 'SMSound.load(): Exception: JS-Flash communication failed, or JS error.', + fbTimeout: 'No flash response, applying .' + swfCSS.swfTimedout + ' CSS...', + fbLoaded: 'Flash loaded', + fbHandler: smc + 'flashBlockHandler()', + manURL: 'SMSound.load(): Using manually-assigned URL', + onURL: sm + '.load(): current URL already assigned.', + badFV: sm + '.flashVersion must be 8 or 9. "%s" is invalid. Reverting to %s.', + as2loop: 'Note: Setting stream:false so looping can work (flash 8 limitation)', + noNSLoop: 'Note: Looping not implemented for MovieStar formats', + needfl9: 'Note: Switching to flash 9, required for MP4 formats.', + mfTimeout: 'Setting flashLoadTimeout = 0 (infinite) for off-screen, mobile flash case', + needFlash: smc + 'Fatal error: Flash is needed to play some required formats, but is not available.', + gotFocus: smc + 'Got window focus.', + policy: 'Enabling usePolicyFile for data access', + setup: sm + '.setup(): allowed parameters: %s', + setupError: sm + '.setup(): "%s" cannot be assigned with this method.', + setupUndef: sm + '.setup(): Could not find option "%s"', + setupLate: sm + '.setup(): url, flashVersion and html5Test property changes will not take effect until reboot().', + noURL: smc + 'Flash URL required. Call soundManager.setup({url:...}) to get started.', + sm2Loaded: 'SoundManager 2: Ready. ' + String.fromCharCode(10003), + reset: sm + '.reset(): Removing event callbacks', + mobileUA: 'Mobile UA detected, preferring HTML5 by default.', + globalHTML5: 'Using singleton HTML5 Audio() pattern for this device.', + ignoreMobile: 'Ignoring mobile restrictions for this device.' + // + + }; + + str = function() { + + // internal string replace helper. + // arguments: o [,items to replace] + // + + var args, + i, j, o, + sstr; + + // real array, please + args = slice.call(arguments); + + // first argument + o = args.shift(); + + sstr = (strings && strings[o] ? strings[o] : ''); + + if (sstr && args && args.length) { + for (i = 0, j = args.length; i < j; i++) { + sstr = sstr.replace('%s', args[i]); + } + } + + return sstr; + // + + }; + + loopFix = function(sOpt) { + + // flash 8 requires stream = false for looping to work + if (fV === 8 && sOpt.loops > 1 && sOpt.stream) { + _wDS('as2loop'); + sOpt.stream = false; + } + + return sOpt; + + }; + + policyFix = function(sOpt, sPre) { + + if (sOpt && !sOpt.usePolicyFile && (sOpt.onid3 || sOpt.usePeakData || sOpt.useWaveformData || sOpt.useEQData)) { + sm2._wD((sPre || '') + str('policy')); + sOpt.usePolicyFile = true; + } + + return sOpt; + + }; + + complain = function(sMsg) { + + // + if (hasConsole && console.warn !== _undefined) { + console.warn(sMsg); + } else { + sm2._wD(sMsg); + } + // + + }; + + doNothing = function() { + + return false; + + }; + + disableObject = function(o) { + + var oProp; + + for (oProp in o) { + if (o.hasOwnProperty(oProp) && typeof o[oProp] === 'function') { + o[oProp] = doNothing; + } + } + + oProp = null; + + }; + + failSafely = function(bNoDisable) { + + // general failure exception handler + + if (bNoDisable === _undefined) { + bNoDisable = false; + } + + if (disabled || bNoDisable) { + sm2.disable(bNoDisable); + } + + }; + + normalizeMovieURL = function(movieURL) { + + var urlParams = null, url; + + if (movieURL) { + + if (movieURL.match(/\.swf(\?.*)?$/i)) { + + urlParams = movieURL.substr(movieURL.toLowerCase().lastIndexOf('.swf?') + 4); + + // assume user knows what they're doing + if (urlParams) return movieURL; + + } else if (movieURL.lastIndexOf('/') !== movieURL.length - 1) { + + // append trailing slash, if needed + movieURL += '/'; + + } + + } + + url = (movieURL && movieURL.lastIndexOf('/') !== -1 ? movieURL.substr(0, movieURL.lastIndexOf('/') + 1) : './') + sm2.movieURL; + + if (sm2.noSWFCache) { + url += ('?ts=' + new Date().getTime()); + } + + return url; + + }; + + setVersionInfo = function() { + + // short-hand for internal use + + fV = parseInt(sm2.flashVersion, 10); + + if (fV !== 8 && fV !== 9) { + sm2._wD(str('badFV', fV, defaultFlashVersion)); + sm2.flashVersion = fV = defaultFlashVersion; + } + + // debug flash movie, if applicable + + var isDebug = (sm2.debugMode || sm2.debugFlash ? '_debug.swf' : '.swf'); + + if (sm2.useHTML5Audio && !sm2.html5Only && sm2.audioFormats.mp4.required && fV < 9) { + sm2._wD(str('needfl9')); + sm2.flashVersion = fV = 9; + } + + sm2.version = sm2.versionNumber + (sm2.html5Only ? ' (HTML5-only mode)' : (fV === 9 ? ' (AS3/Flash 9)' : ' (AS2/Flash 8)')); + + // set up default options + if (fV > 8) { + + // +flash 9 base options + sm2.defaultOptions = mixin(sm2.defaultOptions, sm2.flash9Options); + sm2.features.buffering = true; + + // +moviestar support + sm2.defaultOptions = mixin(sm2.defaultOptions, sm2.movieStarOptions); + sm2.filePatterns.flash9 = new RegExp('\\.(mp3|' + netStreamTypes.join('|') + ')(\\?.*)?$', 'i'); + sm2.features.movieStar = true; + + } else { + + sm2.features.movieStar = false; + + } + + // regExp for flash canPlay(), etc. + sm2.filePattern = sm2.filePatterns[(fV !== 8 ? 'flash9' : 'flash8')]; + + // if applicable, use _debug versions of SWFs + sm2.movieURL = (fV === 8 ? 'soundmanager2.swf' : 'soundmanager2_flash9.swf').replace('.swf', isDebug); + + sm2.features.peakData = sm2.features.waveformData = sm2.features.eqData = (fV > 8); + + }; + + setPolling = function(bPolling, bHighPerformance) { + + if (!flash) { + return; + } + + flash._setPolling(bPolling, bHighPerformance); + + }; + + initDebug = function() { + + // starts debug mode, creating output
    for UAs without console object + + // allow force of debug mode via URL + // + if (sm2.debugURLParam.test(wl)) { + sm2.setupOptions.debugMode = sm2.debugMode = true; + } + + if (id(sm2.debugID)) { + return; + } + + var oD, oDebug, oTarget, oToggle, tmp; + + if (sm2.debugMode && !id(sm2.debugID) && (!hasConsole || !sm2.useConsole || !sm2.consoleOnly)) { + + oD = doc.createElement('div'); + oD.id = sm2.debugID + '-toggle'; + + oToggle = { + position: 'fixed', + bottom: '0px', + right: '0px', + width: '1.2em', + height: '1.2em', + lineHeight: '1.2em', + margin: '2px', + textAlign: 'center', + border: '1px solid #999', + cursor: 'pointer', + background: '#fff', + color: '#333', + zIndex: 10001 + }; + + oD.appendChild(doc.createTextNode('-')); + oD.onclick = toggleDebug; + oD.title = 'Toggle SM2 debug console'; + + if (ua.match(/msie 6/i)) { + oD.style.position = 'absolute'; + oD.style.cursor = 'hand'; + } + + for (tmp in oToggle) { + if (oToggle.hasOwnProperty(tmp)) { + oD.style[tmp] = oToggle[tmp]; + } + } + + oDebug = doc.createElement('div'); + oDebug.id = sm2.debugID; + oDebug.style.display = (sm2.debugMode ? 'block' : 'none'); + + if (sm2.debugMode && !id(oD.id)) { + try { + oTarget = getDocument(); + oTarget.appendChild(oD); + } catch(e2) { + throw new Error(str('domError') + ' \n' + e2.toString()); + } + oTarget.appendChild(oDebug); + } + + } + + oTarget = null; + // + + }; + + idCheck = this.getSoundById; + + // + _wDS = function(o, errorLevel) { + + return (!o ? '' : sm2._wD(str(o), errorLevel)); + + }; + + toggleDebug = function() { + + var o = id(sm2.debugID), + oT = id(sm2.debugID + '-toggle'); + + if (!o) { + return; + } + + if (debugOpen) { + // minimize + oT.innerHTML = '+'; + o.style.display = 'none'; + } else { + oT.innerHTML = '-'; + o.style.display = 'block'; + } + + debugOpen = !debugOpen; + + }; + + debugTS = function(sEventType, bSuccess, sMessage) { + + // troubleshooter debug hooks + + if (window.sm2Debugger !== _undefined) { + try { + sm2Debugger.handleEvent(sEventType, bSuccess, sMessage); + } catch(e) { + // oh well + return false; + } + } + + return true; + + }; + // + + getSWFCSS = function() { + + var css = []; + + if (sm2.debugMode) { + css.push(swfCSS.sm2Debug); + } + + if (sm2.debugFlash) { + css.push(swfCSS.flashDebug); + } + + if (sm2.useHighPerformance) { + css.push(swfCSS.highPerf); + } + + return css.join(' '); + + }; + + flashBlockHandler = function() { + + // *possible* flash block situation. + + var name = str('fbHandler'), + p = sm2.getMoviePercent(), + css = swfCSS, + error = { + type: 'FLASHBLOCK' + }; + + if (sm2.html5Only) { + // no flash, or unused + return; + } + + if (!sm2.ok()) { + + if (needsFlash) { + // make the movie more visible, so user can fix + sm2.oMC.className = getSWFCSS() + ' ' + css.swfDefault + ' ' + (p === null ? css.swfTimedout : css.swfError); + sm2._wD(name + ': ' + str('fbTimeout') + (p ? ' (' + str('fbLoaded') + ')' : '')); + } + + sm2.didFlashBlock = true; + + // fire onready(), complain lightly + processOnEvents({ + type: 'ontimeout', + ignoreInit: true, + error: error + }); + + catchError(error); + + } else { + + // SM2 loaded OK (or recovered) + + // + if (sm2.didFlashBlock) { + sm2._wD(name + ': Unblocked'); + } + // + + if (sm2.oMC) { + sm2.oMC.className = [getSWFCSS(), css.swfDefault, css.swfLoaded + (sm2.didFlashBlock ? ' ' + css.swfUnblocked : '')].join(' '); + } + + } + + }; + + addOnEvent = function(sType, oMethod, oScope) { + + if (on_queue[sType] === _undefined) { + on_queue[sType] = []; + } + + on_queue[sType].push({ + method: oMethod, + scope: (oScope || null), + fired: false + }); + + }; + + processOnEvents = function(oOptions) { + + // if unspecified, assume OK/error + + if (!oOptions) { + oOptions = { + type: (sm2.ok() ? 'onready' : 'ontimeout') + }; + } + + // not ready yet. + if (!didInit && oOptions && !oOptions.ignoreInit) return false; + + // invalid case + if (oOptions.type === 'ontimeout' && (sm2.ok() || (disabled && !oOptions.ignoreInit))) return false; + + var status = { + success: (oOptions && oOptions.ignoreInit ? sm2.ok() : !disabled) + }, + + // queue specified by type, or none + srcQueue = (oOptions && oOptions.type ? on_queue[oOptions.type] || [] : []), + + queue = [], i, j, + args = [status], + canRetry = (needsFlash && !sm2.ok()); + + if (oOptions.error) { + args[0].error = oOptions.error; + } + + for (i = 0, j = srcQueue.length; i < j; i++) { + if (srcQueue[i].fired !== true) { + queue.push(srcQueue[i]); + } + } + + if (queue.length) { + + // sm2._wD(sm + ': Firing ' + queue.length + ' ' + oOptions.type + '() item' + (queue.length === 1 ? '' : 's')); + for (i = 0, j = queue.length; i < j; i++) { + + if (queue[i].scope) { + queue[i].method.apply(queue[i].scope, args); + } else { + queue[i].method.apply(this, args); + } + + if (!canRetry) { + // useFlashBlock and SWF timeout case doesn't count here. + queue[i].fired = true; + + } + + } + + } + + return true; + + }; + + initUserOnload = function() { + + window.setTimeout(function() { + + if (sm2.useFlashBlock) { + flashBlockHandler(); + } + + processOnEvents(); + + // call user-defined "onload", scoped to window + + if (typeof sm2.onload === 'function') { + _wDS('onload', 1); + sm2.onload.apply(window); + _wDS('onloadOK', 1); + } + + if (sm2.waitForWindowLoad) { + event.add(window, 'load', initUserOnload); + } + + }, 1); + + }; + + detectFlash = function() { + + /** + * Hat tip: Flash Detect library (BSD, (C) 2007) by Carl "DocYes" S. Yestrau + * http://featureblend.com/javascript-flash-detection-library.html / http://featureblend.com/license.txt + */ + + // this work has already been done. + if (hasFlash !== _undefined) return hasFlash; + + var hasPlugin = false, n = navigator, obj, type, types, AX = window.ActiveXObject; + + // MS Edge 14 throws an "Unspecified Error" because n.plugins is inaccessible due to permissions + var nP; + + try { + nP = n.plugins; + } catch(e) { + nP = undefined; + } + + if (nP && nP.length) { + + type = 'application/x-shockwave-flash'; + types = n.mimeTypes; + + if (types && types[type] && types[type].enabledPlugin && types[type].enabledPlugin.description) { + hasPlugin = true; + } + + } else if (AX !== _undefined && !ua.match(/MSAppHost/i)) { + + // Windows 8 Store Apps (MSAppHost) are weird (compatibility?) and won't complain here, but will barf if Flash/ActiveX object is appended to the DOM. + try { + obj = new AX('ShockwaveFlash.ShockwaveFlash'); + } catch(e) { + // oh well + obj = null; + } + + hasPlugin = (!!obj); + + // cleanup, because it is ActiveX after all + obj = null; + + } + + hasFlash = hasPlugin; + + return hasPlugin; + + }; + + featureCheck = function() { + + var flashNeeded, + item, + formats = sm2.audioFormats, + // iPhone <= 3.1 has broken HTML5 audio(), but firmware 3.2 (original iPad) + iOS4 works. + isSpecial = (is_iDevice && !!(ua.match(/os (1|2|3_0|3_1)\s/i))); + + if (isSpecial) { + + // has Audio(), but is broken; let it load links directly. + sm2.hasHTML5 = false; + + // ignore flash case, however + sm2.html5Only = true; + + // hide the SWF, if present + if (sm2.oMC) { + sm2.oMC.style.display = 'none'; + } + + } else if (sm2.useHTML5Audio) { + + if (!sm2.html5 || !sm2.html5.canPlayType) { + sm2._wD('SoundManager: No HTML5 Audio() support detected.'); + sm2.hasHTML5 = false; + } + + // + if (isBadSafari) { + sm2._wD(smc + 'Note: Buggy HTML5 Audio in Safari on this OS X release, see https://bugs.webkit.org/show_bug.cgi?id=32159 - ' + (!hasFlash ? ' would use flash fallback for MP3/MP4, but none detected.' : 'will use flash fallback for MP3/MP4, if available'), 1); + } + // + + } + + if (sm2.useHTML5Audio && sm2.hasHTML5) { + + // sort out whether flash is optional, required or can be ignored. + + // innocent until proven guilty. + canIgnoreFlash = true; + + for (item in formats) { + + if (formats.hasOwnProperty(item)) { + + if (formats[item].required) { + + if (!sm2.html5.canPlayType(formats[item].type)) { + + // 100% HTML5 mode is not possible. + canIgnoreFlash = false; + flashNeeded = true; + + } else if (sm2.preferFlash && (sm2.flash[item] || sm2.flash[formats[item].type])) { + + // flash may be required, or preferred for this format. + flashNeeded = true; + + } + + } + + } + + } + + } + + // sanity check... + if (sm2.ignoreFlash) { + flashNeeded = false; + canIgnoreFlash = true; + } + + sm2.html5Only = (sm2.hasHTML5 && sm2.useHTML5Audio && !flashNeeded); + + return (!sm2.html5Only); + + }; + + parseURL = function(url) { + + /** + * Internal: Finds and returns the first playable URL (or failing that, the first URL.) + * @param {string or array} url A single URL string, OR, an array of URL strings or {url:'/path/to/resource', type:'audio/mp3'} objects. + */ + + var i, j, urlResult = 0, result; + + if (url instanceof Array) { + + // find the first good one + for (i = 0, j = url.length; i < j; i++) { + + if (url[i] instanceof Object) { + + // MIME check + if (sm2.canPlayMIME(url[i].type)) { + urlResult = i; + break; + } + + } else if (sm2.canPlayURL(url[i])) { + + // URL string check + urlResult = i; + break; + + } + + } + + // normalize to string + if (url[urlResult].url) { + url[urlResult] = url[urlResult].url; + } + + result = url[urlResult]; + + } else { + + // single URL case + result = url; + + } + + return result; + + }; + + + startTimer = function(oSound) { + + /** + * attach a timer to this sound, and start an interval if needed + */ + + if (!oSound._hasTimer) { + + oSound._hasTimer = true; + + if (!mobileHTML5 && sm2.html5PollingInterval) { + + if (h5IntervalTimer === null && h5TimerCount === 0) { + + h5IntervalTimer = setInterval(timerExecute, sm2.html5PollingInterval); + + } + + h5TimerCount++; + + } + + } + + }; + + stopTimer = function(oSound) { + + /** + * detach a timer + */ + + if (oSound._hasTimer) { + + oSound._hasTimer = false; + + if (!mobileHTML5 && sm2.html5PollingInterval) { + + // interval will stop itself at next execution. + + h5TimerCount--; + + } + + } + + }; + + timerExecute = function() { + + /** + * manual polling for HTML5 progress events, ie., whileplaying() + * (can achieve greater precision than conservative default HTML5 interval) + */ + + var i; + + if (h5IntervalTimer !== null && !h5TimerCount) { + + // no active timers, stop polling interval. + + clearInterval(h5IntervalTimer); + + h5IntervalTimer = null; + + return; + + } + + // check all HTML5 sounds with timers + + for (i = sm2.soundIDs.length - 1; i >= 0; i--) { + + if (sm2.sounds[sm2.soundIDs[i]].isHTML5 && sm2.sounds[sm2.soundIDs[i]]._hasTimer) { + sm2.sounds[sm2.soundIDs[i]]._onTimer(); + } + + } + + }; + + catchError = function(options) { + + options = (options !== _undefined ? options : {}); + + if (typeof sm2.onerror === 'function') { + sm2.onerror.apply(window, [{ + type: (options.type !== _undefined ? options.type : null) + }]); + } + + if (options.fatal !== _undefined && options.fatal) { + sm2.disable(); + } + + }; + + badSafariFix = function() { + + // special case: "bad" Safari (OS X 10.3 - 10.7) must fall back to flash for MP3/MP4 + if (!isBadSafari || !detectFlash()) { + // doesn't apply + return; + } + + var aF = sm2.audioFormats, i, item; + + for (item in aF) { + + if (aF.hasOwnProperty(item)) { + + if (item === 'mp3' || item === 'mp4') { + + sm2._wD(sm + ': Using flash fallback for ' + item + ' format'); + sm2.html5[item] = false; + + // assign result to related formats, too + if (aF[item] && aF[item].related) { + for (i = aF[item].related.length - 1; i >= 0; i--) { + sm2.html5[aF[item].related[i]] = false; + } + } + + } + + } + + } + + }; + + /** + * Pseudo-private flash/ExternalInterface methods + * ---------------------------------------------- + */ + + this._setSandboxType = function(sandboxType) { + + // + // Security sandbox according to Flash plugin + var sb = sm2.sandbox; + + sb.type = sandboxType; + sb.description = sb.types[(sb.types[sandboxType] !== _undefined ? sandboxType : 'unknown')]; + + if (sb.type === 'localWithFile') { + + sb.noRemote = true; + sb.noLocal = false; + _wDS('secNote', 2); + + } else if (sb.type === 'localWithNetwork') { + + sb.noRemote = false; + sb.noLocal = true; + + } else if (sb.type === 'localTrusted') { + + sb.noRemote = false; + sb.noLocal = false; + + } + // + + }; + + this._externalInterfaceOK = function(swfVersion) { + + // flash callback confirming flash loaded, EI working etc. + // swfVersion: SWF build string + + if (sm2.swfLoaded) { + return; + } + + var e; + + debugTS('swf', true); + debugTS('flashtojs', true); + sm2.swfLoaded = true; + tryInitOnFocus = false; + + if (isBadSafari) { + badSafariFix(); + } + + // complain if JS + SWF build/version strings don't match, excluding +DEV builds + // + if (!swfVersion || swfVersion.replace(/\+dev/i, '') !== sm2.versionNumber.replace(/\+dev/i, '')) { + + e = sm + ': Fatal: JavaScript file build "' + sm2.versionNumber + '" does not match Flash SWF build "' + swfVersion + '" at ' + sm2.url + '. Ensure both are up-to-date.'; + + // escape flash -> JS stack so this error fires in window. + setTimeout(function() { + throw new Error(e); + }, 0); + + // exit, init will fail with timeout + return; + + } + // + + // IE needs a larger timeout + setTimeout(init, isIE ? 100 : 1); + + }; + + /** + * Private initialization helpers + * ------------------------------ + */ + + createMovie = function(movieID, movieURL) { + + // ignore if already connected + if (didAppend && appendSuccess) return false; + + function initMsg() { + + // + + var options = [], + title, + msg = [], + delimiter = ' + '; + + title = 'SoundManager ' + sm2.version + (!sm2.html5Only && sm2.useHTML5Audio ? (sm2.hasHTML5 ? ' + HTML5 audio' : ', no HTML5 audio support') : ''); + + if (!sm2.html5Only) { + + if (sm2.preferFlash) { + options.push('preferFlash'); + } + + if (sm2.useHighPerformance) { + options.push('useHighPerformance'); + } + + if (sm2.flashPollingInterval) { + options.push('flashPollingInterval (' + sm2.flashPollingInterval + 'ms)'); + } + + if (sm2.html5PollingInterval) { + options.push('html5PollingInterval (' + sm2.html5PollingInterval + 'ms)'); + } + + if (sm2.wmode) { + options.push('wmode (' + sm2.wmode + ')'); + } + + if (sm2.debugFlash) { + options.push('debugFlash'); + } + + if (sm2.useFlashBlock) { + options.push('flashBlock'); + } + + } else if (sm2.html5PollingInterval) { + options.push('html5PollingInterval (' + sm2.html5PollingInterval + 'ms)'); + } + + if (options.length) { + msg = msg.concat([options.join(delimiter)]); + } + + sm2._wD(title + (msg.length ? delimiter + msg.join(', ') : ''), 1); + + showSupport(); + + // + + } + + if (sm2.html5Only) { + + // 100% HTML5 mode + setVersionInfo(); + + initMsg(); + sm2.oMC = id(sm2.movieID); + init(); + + // prevent multiple init attempts + didAppend = true; + + appendSuccess = true; + + return false; + + } + + // flash path + var remoteURL = (movieURL || sm2.url), + localURL = (sm2.altURL || remoteURL), + swfTitle = 'JS/Flash audio component (SoundManager 2)', + oTarget = getDocument(), + extraClass = getSWFCSS(), + isRTL = null, + html = doc.getElementsByTagName('html')[0], + oEmbed, oMovie, tmp, movieHTML, oEl, s, x, sClass; + + isRTL = (html && html.dir && html.dir.match(/rtl/i)); + movieID = (movieID === _undefined ? sm2.id : movieID); + + function param(name, value) { + return ''; + } + + // safety check for legacy (change to Flash 9 URL) + setVersionInfo(); + sm2.url = normalizeMovieURL(overHTTP ? remoteURL : localURL); + movieURL = sm2.url; + + sm2.wmode = (!sm2.wmode && sm2.useHighPerformance ? 'transparent' : sm2.wmode); + + if (sm2.wmode !== null && (ua.match(/msie 8/i) || (!isIE && !sm2.useHighPerformance)) && navigator.platform.match(/win32|win64/i)) { + /** + * extra-special case: movie doesn't load until scrolled into view when using wmode = anything but 'window' here + * does not apply when using high performance (position:fixed means on-screen), OR infinite flash load timeout + * wmode breaks IE 8 on Vista + Win7 too in some cases, as of January 2011 (?) + */ + messages.push(strings.spcWmode); + sm2.wmode = null; + } + + oEmbed = { + name: movieID, + id: movieID, + src: movieURL, + quality: 'high', + allowScriptAccess: sm2.allowScriptAccess, + bgcolor: sm2.bgColor, + pluginspage: http + 'www.macromedia.com/go/getflashplayer', + title: swfTitle, + type: 'application/x-shockwave-flash', + wmode: sm2.wmode, + // http://help.adobe.com/en_US/as3/mobile/WS4bebcd66a74275c36cfb8137124318eebc6-7ffd.html + hasPriority: 'true' + }; + + if (sm2.debugFlash) { + oEmbed.FlashVars = 'debug=1'; + } + + if (!sm2.wmode) { + // don't write empty attribute + delete oEmbed.wmode; + } + + if (isIE) { + + // IE is "special". + oMovie = doc.createElement('div'); + movieHTML = [ + '', + param('movie', movieURL), + param('AllowScriptAccess', sm2.allowScriptAccess), + param('quality', oEmbed.quality), + (sm2.wmode ? param('wmode', sm2.wmode) : ''), + param('bgcolor', sm2.bgColor), + param('hasPriority', 'true'), + (sm2.debugFlash ? param('FlashVars', oEmbed.FlashVars) : ''), + '' + ].join(''); + + } else { + + oMovie = doc.createElement('embed'); + for (tmp in oEmbed) { + if (oEmbed.hasOwnProperty(tmp)) { + oMovie.setAttribute(tmp, oEmbed[tmp]); + } + } + + } + + initDebug(); + extraClass = getSWFCSS(); + oTarget = getDocument(); + + if (oTarget) { + + sm2.oMC = (id(sm2.movieID) || doc.createElement('div')); + + if (!sm2.oMC.id) { + + sm2.oMC.id = sm2.movieID; + sm2.oMC.className = swfCSS.swfDefault + ' ' + extraClass; + s = null; + oEl = null; + + if (!sm2.useFlashBlock) { + if (sm2.useHighPerformance) { + // on-screen at all times + s = { + position: 'fixed', + width: '8px', + height: '8px', + // >= 6px for flash to run fast, >= 8px to start up under Firefox/win32 in some cases. odd? yes. + bottom: '0px', + left: '0px', + overflow: 'hidden' + }; + } else { + // hide off-screen, lower priority + s = { + position: 'absolute', + width: '6px', + height: '6px', + top: '-9999px', + left: '-9999px' + }; + if (isRTL) { + s.left = Math.abs(parseInt(s.left, 10)) + 'px'; + } + } + } + + if (isWebkit) { + // soundcloud-reported render/crash fix, safari 5 + sm2.oMC.style.zIndex = 10000; + } + + if (!sm2.debugFlash) { + for (x in s) { + if (s.hasOwnProperty(x)) { + sm2.oMC.style[x] = s[x]; + } + } + } + + try { + + if (!isIE) { + sm2.oMC.appendChild(oMovie); + } + + oTarget.appendChild(sm2.oMC); + + if (isIE) { + oEl = sm2.oMC.appendChild(doc.createElement('div')); + oEl.className = swfCSS.swfBox; + oEl.innerHTML = movieHTML; + } + + appendSuccess = true; + + } catch(e) { + + throw new Error(str('domError') + ' \n' + e.toString()); + + } + + } else { + + // SM2 container is already in the document (eg. flashblock use case) + sClass = sm2.oMC.className; + sm2.oMC.className = (sClass ? sClass + ' ' : swfCSS.swfDefault) + (extraClass ? ' ' + extraClass : ''); + sm2.oMC.appendChild(oMovie); + + if (isIE) { + oEl = sm2.oMC.appendChild(doc.createElement('div')); + oEl.className = swfCSS.swfBox; + oEl.innerHTML = movieHTML; + } + + appendSuccess = true; + + } + + } + + didAppend = true; + + initMsg(); + + // sm2._wD(sm + ': Trying to load ' + movieURL + (!overHTTP && sm2.altURL ? ' (alternate URL)' : ''), 1); + + return true; + + }; + + initMovie = function() { + + if (sm2.html5Only) { + createMovie(); + return false; + } + + // attempt to get, or create, movie (may already exist) + if (flash) return false; + + if (!sm2.url) { + + /** + * Something isn't right - we've reached init, but the soundManager url property has not been set. + * User has not called setup({url: ...}), or has not set soundManager.url (legacy use case) directly before init time. + * Notify and exit. If user calls setup() with a url: property, init will be restarted as in the deferred loading case. + */ + + _wDS('noURL'); + return false; + + } + + // inline markup case + flash = sm2.getMovie(sm2.id); + + if (!flash) { + + if (!oRemoved) { + + // try to create + createMovie(sm2.id, sm2.url); + + } else { + + // try to re-append removed movie after reboot() + if (!isIE) { + sm2.oMC.appendChild(oRemoved); + } else { + sm2.oMC.innerHTML = oRemovedHTML; + } + + oRemoved = null; + didAppend = true; + + } + + flash = sm2.getMovie(sm2.id); + + } + + if (typeof sm2.oninitmovie === 'function') { + setTimeout(sm2.oninitmovie, 1); + } + + // + flushMessages(); + // + + return true; + + }; + + delayWaitForEI = function() { + + setTimeout(waitForEI, 1000); + + }; + + rebootIntoHTML5 = function() { + + // special case: try for a reboot with preferFlash: false, if 100% HTML5 mode is possible and useFlashBlock is not enabled. + + window.setTimeout(function() { + + complain(smc + 'useFlashBlock is false, 100% HTML5 mode is possible. Rebooting with preferFlash: false...'); + + sm2.setup({ + preferFlash: false + }).reboot(); + + // if for some reason you want to detect this case, use an ontimeout() callback and look for html5Only and didFlashBlock == true. + sm2.didFlashBlock = true; + + sm2.beginDelayedInit(); + + }, 1); + + }; + + waitForEI = function() { + + var p, + loadIncomplete = false; + + if (!sm2.url) { + // No SWF url to load (noURL case) - exit for now. Will be retried when url is set. + return; + } + + if (waitingForEI) { + return; + } + + waitingForEI = true; + event.remove(window, 'load', delayWaitForEI); + + if (hasFlash && tryInitOnFocus && !isFocused) { + // Safari won't load flash in background tabs, only when focused. + _wDS('waitFocus'); + return; + } + + if (!didInit) { + p = sm2.getMoviePercent(); + if (p > 0 && p < 100) { + loadIncomplete = true; + } + } + + setTimeout(function() { + + p = sm2.getMoviePercent(); + + if (loadIncomplete) { + // special case: if movie *partially* loaded, retry until it's 100% before assuming failure. + waitingForEI = false; + sm2._wD(str('waitSWF')); + window.setTimeout(delayWaitForEI, 1); + return; + } + + // + if (!didInit) { + + sm2._wD(sm + ': No Flash response within expected time. Likely causes: ' + (p === 0 ? 'SWF load failed, ' : '') + 'Flash blocked or JS-Flash security error.' + (sm2.debugFlash ? ' ' + str('checkSWF') : ''), 2); + + if (!overHTTP && p) { + + _wDS('localFail', 2); + + if (!sm2.debugFlash) { + _wDS('tryDebug', 2); + } + + } + + if (p === 0) { + + // if 0 (not null), probably a 404. + sm2._wD(str('swf404', sm2.url), 1); + + } + + debugTS('flashtojs', false, ': Timed out' + (overHTTP ? ' (Check flash security or flash blockers)' : ' (No plugin/missing SWF?)')); + + } + // + + // give up / time-out, depending + + if (!didInit && okToDisable) { + + if (p === null) { + + // SWF failed to report load progress. Possibly blocked. + + if (sm2.useFlashBlock || sm2.flashLoadTimeout === 0) { + + if (sm2.useFlashBlock) { + + flashBlockHandler(); + + } + + _wDS('waitForever'); + + } else if (!sm2.useFlashBlock && canIgnoreFlash) { + + // no custom flash block handling, but SWF has timed out. Will recover if user unblocks / allows SWF load. + rebootIntoHTML5(); + + } else { + + _wDS('waitForever'); + + // fire any regular registered ontimeout() listeners. + processOnEvents({ + type: 'ontimeout', + ignoreInit: true, + error: { + type: 'INIT_FLASHBLOCK' + } + }); + + } + + } else if (sm2.flashLoadTimeout === 0) { + + // SWF loaded? Shouldn't be a blocking issue, then. + + _wDS('waitForever'); + + } else if (!sm2.useFlashBlock && canIgnoreFlash) { + + rebootIntoHTML5(); + + } else { + + failSafely(true); + + } + + } + + }, sm2.flashLoadTimeout); + + }; + + handleFocus = function() { + + function cleanup() { + event.remove(window, 'focus', handleFocus); + } + + if (isFocused || !tryInitOnFocus) { + // already focused, or not special Safari background tab case + cleanup(); + return true; + } + + okToDisable = true; + isFocused = true; + _wDS('gotFocus'); + + // allow init to restart + waitingForEI = false; + + // kick off ExternalInterface timeout, now that the SWF has started + delayWaitForEI(); + + cleanup(); + return true; + + }; + + flushMessages = function() { + + // + + // SM2 pre-init debug messages + if (messages.length) { + sm2._wD('SoundManager 2: ' + messages.join(' '), 1); + messages = []; + } + + // + + }; + + showSupport = function() { + + // + + flushMessages(); + + var item, tests = []; + + if (sm2.useHTML5Audio && sm2.hasHTML5) { + for (item in sm2.audioFormats) { + if (sm2.audioFormats.hasOwnProperty(item)) { + tests.push(item + ' = ' + sm2.html5[item] + (!sm2.html5[item] && needsFlash && sm2.flash[item] ? ' (using flash)' : (sm2.preferFlash && sm2.flash[item] && needsFlash ? ' (preferring flash)' : (!sm2.html5[item] ? ' (' + (sm2.audioFormats[item].required ? 'required, ' : '') + 'and no flash support)' : '')))); + } + } + sm2._wD('SoundManager 2 HTML5 support: ' + tests.join(', '), 1); + } + + // + + }; + + initComplete = function(bNoDisable) { + + if (didInit) return false; + + if (sm2.html5Only) { + // all good. + _wDS('sm2Loaded', 1); + didInit = true; + initUserOnload(); + debugTS('onload', true); + return true; + } + + var wasTimeout = (sm2.useFlashBlock && sm2.flashLoadTimeout && !sm2.getMoviePercent()), + result = true, + error; + + if (!wasTimeout) { + didInit = true; + } + + error = { + type: (!hasFlash && needsFlash ? 'NO_FLASH' : 'INIT_TIMEOUT') + }; + + sm2._wD('SoundManager 2 ' + (disabled ? 'failed to load' : 'loaded') + ' (' + (disabled ? 'Flash security/load error' : 'OK') + ') ' + String.fromCharCode(disabled ? 10006 : 10003), disabled ? 2 : 1); + + if (disabled || bNoDisable) { + + if (sm2.useFlashBlock && sm2.oMC) { + sm2.oMC.className = getSWFCSS() + ' ' + (sm2.getMoviePercent() === null ? swfCSS.swfTimedout : swfCSS.swfError); + } + + processOnEvents({ + type: 'ontimeout', + error: error, + ignoreInit: true + }); + + debugTS('onload', false); + catchError(error); + + result = false; + + } else { + + debugTS('onload', true); + + } + + if (!disabled) { + + if (sm2.waitForWindowLoad && !windowLoaded) { + + _wDS('waitOnload'); + event.add(window, 'load', initUserOnload); + + } else { + + // + if (sm2.waitForWindowLoad && windowLoaded) { + _wDS('docLoaded'); + } + // + + initUserOnload(); + + } + + } + + return result; + + }; + + /** + * apply top-level setupOptions object as local properties, eg., this.setupOptions.flashVersion -> this.flashVersion (soundManager.flashVersion) + * this maintains backward compatibility, and allows properties to be defined separately for use by soundManager.setup(). + */ + + setProperties = function() { + + var i, + o = sm2.setupOptions; + + for (i in o) { + + if (o.hasOwnProperty(i)) { + + // assign local property if not already defined + + if (sm2[i] === _undefined) { + + sm2[i] = o[i]; + + } else if (sm2[i] !== o[i]) { + + // legacy support: write manually-assigned property (eg., soundManager.url) back to setupOptions to keep things in sync + sm2.setupOptions[i] = sm2[i]; + + } + + } + + } + + }; + + + init = function() { + + // called after onload() + + if (didInit) { + _wDS('didInit'); + return false; + } + + function cleanup() { + event.remove(window, 'load', sm2.beginDelayedInit); + } + + if (sm2.html5Only) { + + if (!didInit) { + // we don't need no steenking flash! + cleanup(); + sm2.enabled = true; + initComplete(); + } + + return true; + + } + + // flash path + initMovie(); + + try { + + // attempt to talk to Flash + flash._externalInterfaceTest(false); + + /** + * Apply user-specified polling interval, OR, if "high performance" set, faster vs. default polling + * (determines frequency of whileloading/whileplaying callbacks, effectively driving UI framerates) + */ + setPolling(true, (sm2.flashPollingInterval || (sm2.useHighPerformance ? 10 : 50))); + + if (!sm2.debugMode) { + // stop the SWF from making debug output calls to JS + flash._disableDebug(); + } + + sm2.enabled = true; + debugTS('jstoflash', true); + + if (!sm2.html5Only) { + // prevent browser from showing cached page state (or rather, restoring "suspended" page state) via back button, because flash may be dead + // http://www.webkit.org/blog/516/webkit-page-cache-ii-the-unload-event/ + event.add(window, 'unload', doNothing); + } + + } catch(e) { + + sm2._wD('js/flash exception: ' + e.toString()); + + debugTS('jstoflash', false); + + catchError({ + type: 'JS_TO_FLASH_EXCEPTION', + fatal: true + }); + + // don't disable, for reboot() + failSafely(true); + + initComplete(); + + return false; + + } + + initComplete(); + + // disconnect events + cleanup(); + + return true; + + }; + + domContentLoaded = function() { + + if (didDCLoaded) return false; + + didDCLoaded = true; + + // assign top-level soundManager properties eg. soundManager.url + setProperties(); + + initDebug(); + + if (!hasFlash && sm2.hasHTML5) { + + sm2._wD('SoundManager 2: No Flash detected' + (!sm2.useHTML5Audio ? ', enabling HTML5.' : '. Trying HTML5-only mode.'), 1); + + sm2.setup({ + useHTML5Audio: true, + // make sure we aren't preferring flash, either + // TODO: preferFlash should not matter if flash is not installed. Currently, stuff breaks without the below tweak. + preferFlash: false + }); + + } + + testHTML5(); + + if (!hasFlash && needsFlash) { + + messages.push(strings.needFlash); + + // TODO: Fatal here vs. timeout approach, etc. + // hack: fail sooner. + sm2.setup({ + flashLoadTimeout: 1 + }); + + } + + if (doc.removeEventListener) { + doc.removeEventListener('DOMContentLoaded', domContentLoaded, false); + } + + initMovie(); + + return true; + + }; + + domContentLoadedIE = function() { + + if (doc.readyState === 'complete') { + domContentLoaded(); + doc.detachEvent('onreadystatechange', domContentLoadedIE); + } + + return true; + + }; + + winOnLoad = function() { + + // catch edge case of initComplete() firing after window.load() + windowLoaded = true; + + // catch case where DOMContentLoaded has been sent, but we're still in doc.readyState = 'interactive' + domContentLoaded(); + + event.remove(window, 'load', winOnLoad); + + }; + + // sniff up-front + detectFlash(); + + // focus and window load, init (primarily flash-driven) + event.add(window, 'focus', handleFocus); + event.add(window, 'load', delayWaitForEI); + event.add(window, 'load', winOnLoad); + + if (doc.addEventListener) { + + doc.addEventListener('DOMContentLoaded', domContentLoaded, false); + + } else if (doc.attachEvent) { + + doc.attachEvent('onreadystatechange', domContentLoadedIE); + + } else { + + // no add/attachevent support - safe to assume no JS -> Flash either + debugTS('onload', false); + catchError({ + type: 'NO_DOM2_EVENTS', + fatal: true + }); + + } + +} // SoundManager() + +// SM2_DEFER details: http://www.schillmania.com/projects/soundmanager2/doc/getstarted/#lazy-loading + +if (window.SM2_DEFER === _undefined || !SM2_DEFER) { + soundManager = new SoundManager(); +} + +/** + * SoundManager public interfaces + * ------------------------------ + */ + +if (typeof module === 'object' && module && typeof module.exports === 'object') { + + /** + * commonJS module + */ + + module.exports.SoundManager = SoundManager; + module.exports.soundManager = soundManager; + +} else if (typeof define === 'function' && define.amd) { + + /** + * AMD - requireJS + * basic usage: + * require(["/path/to/soundmanager2.js"], function(SoundManager) { + * SoundManager.getInstance().setup({ + * url: '/swf/', + * onready: function() { ... } + * }) + * }); + * + * SM2_DEFER usage: + * window.SM2_DEFER = true; + * require(["/path/to/soundmanager2.js"], function(SoundManager) { + * SoundManager.getInstance(function() { + * var soundManager = new SoundManager.constructor(); + * soundManager.setup({ + * url: '/swf/', + * ... + * }); + * ... + * soundManager.beginDelayedInit(); + * return soundManager; + * }) + * }); + */ + + define(function() { + /** + * Retrieve the global instance of SoundManager. + * If a global instance does not exist it can be created using a callback. + * + * @param {Function} smBuilder Optional: Callback used to create a new SoundManager instance + * @return {SoundManager} The global SoundManager instance + */ + function getInstance(smBuilder) { + if (!window.soundManager && smBuilder instanceof Function) { + var instance = smBuilder(SoundManager); + if (instance instanceof SoundManager) { + window.soundManager = instance; + } + } + return window.soundManager; + } + return { + constructor: SoundManager, + getInstance: getInstance + }; + }); + +} + +// standard browser case + +// constructor +window.SoundManager = SoundManager; + +/** + * note: SM2 requires a window global due to Flash, which makes calls to window.soundManager. + * Flash may not always be needed, but this is not known until async init and SM2 may even "reboot" into Flash mode. + */ + +// public API, flash callbacks etc. +window.soundManager = soundManager; + +}(window)); diff --git a/cps/templates/detail.html b/cps/templates/detail.html index a49670e6..4460a9f8 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -58,9 +58,10 @@ {% endif %} {% endif %} {% if reader_list %} + {% if audioentries|length %}
    - +
    - {% endif %} + {% endif %} + + {% endif %}

    {{entry.title|shortentitle(40)}}

    diff --git a/cps/templates/index.html b/cps/templates/index.html index 1df6e62e..6d4b88ab 100755 --- a/cps/templates/index.html +++ b/cps/templates/index.html @@ -72,6 +72,11 @@ & {% endif %} {% endfor %} + {% for format in entry.data %} + {% if format.format|lower == 'mp3' %} + + {% endif %} + {%endfor%}

    {% if entry.ratings.__len__() > 0 %}
    diff --git a/cps/templates/listenmp3.html b/cps/templates/listenmp3.html new file mode 100644 index 00000000..e7ec1da6 --- /dev/null +++ b/cps/templates/listenmp3.html @@ -0,0 +1,140 @@ + + + + + + + {{ entry.title }} + + + + + + + + + + + + + + + + + + + + + +
    +
    +

    {{ entry.title }}

    + +
    + {% if entry.has_cover %} + {{ entry.title }} {% else %} + {{ entry.title }} {% endif %} +
    + + {% if entry.ratings.__len__() > 0 %} +
    +

    + {% for number in range((entry.ratings[0].rating/2)|int(2)) %} + + {% if loop.last and loop.index + < 5 %} {% for numer in range(5 - loop.index) %} + + {% endfor %} {% endif %} {% endfor %} +

    +
    + {% endif %} + +

    {{_('Description:')}}

    + {{entry.comments[0].text|safe}} +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    + +
    +
    + + + +
    +
    + +
    +
    +
    0:00
    +
    +
    +
    +
    +
    +
    +
    +
    +
    0:00
    +
    +
    +
    + +
    +
    + + volume +
    +
    +
    + +
    + +
    +
    +
    + + + +
    +
      +
    • + +
    • +
    +
    +
    +
    + + +
    + + + \ No newline at end of file diff --git a/cps/templates/read.html b/cps/templates/read.html index 380e106a..c3158bee 100644 --- a/cps/templates/read.html +++ b/cps/templates/read.html @@ -82,9 +82,9 @@ filePath: "{{ url_for('static', filename='js/libs/') }}", cssPath: "{{ url_for('static', filename='css/') }}", bookmarkUrl: "{{ url_for('bookmark', book_id=bookid, book_format='EPUB') }}", - bookUrl: "{{ url_for('get_download_link_ext', book_id=bookid, book_format="epub", anyname='file.epub') }}", + bookUrl: "{{ url_for('get_download_link_ext', book_id=bookid, book_format='epub', anyname='file.epub') }}", bookmark: "{{ bookmark.bookmark_key if bookmark != None }}", - useBookmarks: {{ g.user.is_authenticated | tojson }} + useBookmarks: "{{ g.user.is_authenticated | tojson }}" }; diff --git a/cps/web.py b/cps/web.py index 92681da6..3dabaa2e 100644 --- a/cps/web.py +++ b/cps/web.py @@ -136,8 +136,13 @@ gdrive_watch_callback_token = 'target=calibreweb-watch_files' py3_gevent_link = None py3_restart_Typ = False EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx', - 'fb2', 'html', 'rtf', 'odt'} -EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz'} + 'fb2', 'html', 'rtf', 'odt', 'mp3', 'm4a', 'm4b'} +EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'html', 'rtf', 'odt'} +EXTENSIONS_AUDIO = {'mp3', 'm4a', 'm4b'} + +# EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] + (['rar','cbr'] if rar_support else [])) + + # Main code @@ -152,6 +157,13 @@ mimetypes.add_type('application/x-cbr', '.cbr') mimetypes.add_type('application/x-cbz', '.cbz') mimetypes.add_type('application/x-cbt', '.cbt') mimetypes.add_type('image/vnd.djvu', '.djvu') +mimetypes.add_type('application/mpeg', '.mpeg') +mimetypes.add_type('application/mpeg', '.mp3') +mimetypes.add_type('application/mp4', '.m4a') +mimetypes.add_type('application/mp4', '.m4b') +mimetypes.add_type('application/ogg', '.ogg') +mimetypes.add_type('application/ogg', '.oga') + app = (Flask(__name__)) @@ -1586,7 +1598,12 @@ def show_book(book_id): kindle_list = helper.check_send_to_kindle(entries) reader_list = helper.check_read_formats(entries) - return render_title_template('detail.html', entry=entries, cc=cc, is_xhr=request.is_xhr, + audioentries = [] + for media_format in entries.data: + if media_format.format.lower() in EXTENSIONS_AUDIO: + audioentries.append(media_format.format.lower()) + + return render_title_template('detail.html', entry=entries, audioentries=audioentries, cc=cc, is_xhr=request.is_xhr, title=entries.title, books_shelfs=book_in_shelfs, have_read=have_read, kindle_list=kindle_list, reader_list=reader_list, page="book") else: @@ -2035,7 +2052,7 @@ def serve_book(book_id, book_format): book_format = book_format.split(".")[0] book = db.session.query(db.Books).filter(db.Books.id == book_id).first() data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == book_format.upper()).first() - app.logger.info(data.name) + app.logger.info('Serving book: %s', data.name) if config.config_use_google_drive: headers = Headers() try: @@ -2132,17 +2149,29 @@ def read_book(book_id, book_format): return redirect(url_for("index")) # check if book was downloaded before - lbookmark = None + bookmark = None if current_user.is_authenticated: - lbookmark = ub.session.query(ub.Bookmark).filter(ub.and_(ub.Bookmark.user_id == int(current_user.id), + bookmark = ub.session.query(ub.Bookmark).filter(ub.and_(ub.Bookmark.user_id == int(current_user.id), ub.Bookmark.book_id == book_id, ub.Bookmark.format == book_format.upper())).first() if book_format.lower() == "epub": - return render_title_template('read.html', bookid=book_id, title=_(u"Read a Book"), bookmark=lbookmark) + return render_title_template('read.html', bookid=book_id, title=_(u"Read a Book"), bookmark=bookmark) elif book_format.lower() == "pdf": return render_title_template('readpdf.html', pdffile=book_id, title=_(u"Read a Book")) elif book_format.lower() == "txt": return render_title_template('readtxt.html', txtfile=book_id, title=_(u"Read a Book")) + elif book_format.lower() == "mp3": + entries = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first() + return render_title_template('listenmp3.html', mp3file=book_id, audioformat=book_format.lower(), + title=_(u"Read a Book"), entry=entries, bookmark=bookmark) + elif book_format.lower() == "m4b": + entries = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first() + return render_title_template('listenmp3.html', mp3file=book_id, audioformat=book_format.lower(), + title=_(u"Read a Book"), entry=entries, bookmark=bookmark) + elif book_format.lower() == "m4a": + entries = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first() + return render_title_template('listenmp3.html', mp3file=book_id, audioformat=book_format.lower(), + title=_(u"Read a Book"), entry=entries, bookmark=bookmark) else: book_dir = os.path.join(config.get_main_dir, "cps", "static", str(book_id)) if not os.path.exists(book_dir): @@ -3843,7 +3872,7 @@ def upload(): gdriveutils.updateGdriveCalibreFromLocal() if error: flash(error, category="error") - uploadText=(u"File %s" % book.title) + uploadText=_(u"File %(file)s uploaded", file=book.title) helper.global_WorkerThread.add_upload(current_user.nickname, "" + uploadText + "") diff --git a/readme.md b/readme.md index 803ba257..1d6bc14b 100755 --- a/readme.md +++ b/readme.md @@ -6,6 +6,7 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d ![screenshot](https://raw.githubusercontent.com/janeczku/docker-calibre-web/master/screenshot.png) + ## Features - Bootstrap 3 HTML5 interface @@ -27,6 +28,7 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d - Ability to hide content based on categories for certain users - Self update capability - "Magic Link" login to make it easy to log on eReaders +- Audiobook support (see notes below) ## Quick start @@ -209,7 +211,15 @@ enables the service. Starting the script with `-h` lists all supported command line options Currently supported are 2 options, which are both useful for running multiple instances of Calibre-Web -`"-p path"` allows to specify the location of the settings database -`"-g path"` allows to specify the location of the google-drive database -`"-c path"` allows to specify the location of SSL certfile, works only in combination with keyfile -`"-k path"` allows to specify the location of SSL keyfile, works only in combination with certfile +`"-p path"` allows to specify the location of the settings database +`"-g path"` allows to specify the location of the google-drive database +`"-c path"` allows to specify the location of SSL certfile, works only in combination with keyfile +`"-k path"` allows to specify the location of SSL keyfile, works only in combination with certfile + +## Audiobook support + +Calibre-web has "limited" audiobook support. This feature is new and requires some testing. Any modern browser should be able to support the new feature. Testing from mobile devices is needed. + +Files with .mp3, .m4a, and .m4b can now be uploaded. These files will appear as a downloaded item in the book details page. If an .mp3 file exists, the site will offer a Listen in Browser option. + +While you can load mulitple audiobook formats for a book, the site is configured to work with the entire book in one format. If you have a multi-file collection for your audiobook, convert it to a single file before upload. Otherwise, the system may assume a different book per file! From b75b91606cade5d9dedaa7507c55ac9d3f52953a Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sun, 27 Jan 2019 11:32:16 +0100 Subject: [PATCH 14/96] Fix config oauth-UI --- cps/templates/config_edit.html | 1 + 1 file changed, 1 insertion(+) diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 2d77f038..90176093 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -185,6 +185,7 @@
    +
    {{_('Obtain GitHub OAuth Credentail')}} From 36229076f7584c1b5118deb61f53effb7de02b22 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sun, 3 Feb 2019 18:32:27 +0100 Subject: [PATCH 15/96] Refactor subprocess calls --- cps/converter.py | 9 ++++++--- cps/helper.py | 8 ++------ cps/subproc_wrapper.py | 42 ++++++++++++++++++++++++++++++++++++++++++ cps/web.py | 1 - cps/worker.py | 39 +++++++++++++++++++++------------------ 5 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 cps/subproc_wrapper.py diff --git a/cps/converter.py b/cps/converter.py index bfcf0879..29c371bb 100644 --- a/cps/converter.py +++ b/cps/converter.py @@ -19,17 +19,19 @@ import os -import subprocess +# import subprocess import ub import re from flask_babel import gettext as _ +from subproc_wrapper import process_open def versionKindle(): versions = _(u'not installed') if os.path.exists(ub.config.config_converterpath): try: - p = subprocess.Popen(ub.config.config_converterpath, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p = process_open(ub.config.config_converterpath) + # p = subprocess.Popen(ub.config.config_converterpath, stdout=subprocess.PIPE, stderr=subprocess.PIPE) p.wait() for lines in p.stdout.readlines(): if isinstance(lines, bytes): @@ -45,7 +47,8 @@ def versionCalibre(): versions = _(u'not installed') if os.path.exists(ub.config.config_converterpath): try: - p = subprocess.Popen([ub.config.config_converterpath, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p = process_open([ub.config.config_converterpath, '--version']) + # p = subprocess.Popen([ub.config.config_converterpath, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) p.wait() for lines in p.stdout.readlines(): if isinstance(lines, bytes): diff --git a/cps/helper.py b/cps/helper.py index 31d82e73..0f489942 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -22,7 +22,6 @@ import db import ub from flask import current_app as app -# import logging from tempfile import gettempdir import sys import os @@ -36,18 +35,15 @@ from flask_babel import gettext as _ from flask_login import current_user from babel.dates import format_datetime from datetime import datetime -# import threading import shutil import requests -# import zipfile try: import gdriveutils as gd except ImportError: pass import web -# import server import random -import subprocess +from subproc_wrapper import process_open try: import unidecode @@ -496,7 +492,7 @@ def check_unrar(unrarLocation): try: if sys.version_info < (3, 0): unrarLocation = unrarLocation.encode(sys.getfilesystemencoding()) - p = subprocess.Popen(unrarLocation, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p = process_open(unrarLocation) p.wait() for lines in p.stdout.readlines(): if isinstance(lines, bytes): diff --git a/cps/subproc_wrapper.py b/cps/subproc_wrapper.py new file mode 100644 index 00000000..64204a56 --- /dev/null +++ b/cps/subproc_wrapper.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2019 OzzieIsaacs +# +# This program 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. +# +# This program 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 this program. If not, see . +import subprocess +import os +import sys + + +def process_open(command, quotes=(), env=None, sout=subprocess.PIPE): + # Linux py2.7 encode as list without quotes no empty element for parameters + # linux py3.x no encode and as list without quotes no empty element for parameters + # windows py2.7 encode as string with quotes empty element for parameters is okay + # windows py 3.x no encode and as string with quotes empty element for parameters is okay + # separate handling for windows and linux + if os.name == 'nt': + for key, element in enumerate(command): + if key in quotes: + command[key] = '"' + element + '"' + exc_command = " ".join(command) + if sys.version_info < (3, 0): + exc_command = exc_command.encode(sys.getfilesystemencoding()) + else: + if sys.version_info < (3, 0): + exc_command = [x.encode(sys.getfilesystemencoding()) for x in command] + + # return subprocess.Popen(exc_command, shell=False, stdout=subprocess.PIPE, universal_newlines=True, env=env) + return subprocess.Popen(exc_command, shell=False, stdout=sout, universal_newlines=True, env=env) diff --git a/cps/web.py b/cps/web.py index caa8415c..343d5097 100644 --- a/cps/web.py +++ b/cps/web.py @@ -84,7 +84,6 @@ from flask_dance.consumer import oauth_authorized, oauth_error from sqlalchemy.orm.exc import NoResultFound from oauth import OAuthBackend import hashlib ->>>>>>> master try: from googleapiclient.errors import HttpError diff --git a/cps/worker.py b/cps/worker.py index 65d4a850..80222f21 100644 --- a/cps/worker.py +++ b/cps/worker.py @@ -31,7 +31,9 @@ import web from flask_babel import gettext as _ import re import gdriveutils as gd -import subprocess +# import subprocess +from subproc_wrapper import process_open + try: from StringIO import StringIO @@ -271,36 +273,37 @@ class WorkerThread(threading.Thread): # check which converter to use kindlegen is "1" if format_old_ext == '.epub' and format_new_ext == '.mobi': if web.ub.config.config_ebookconverter == 1: - if os.name == 'nt': + '''if os.name == 'nt': command = web.ub.config.config_converterpath + u' "' + file_path + u'.epub"' if sys.version_info < (3, 0): command = command.encode(sys.getfilesystemencoding()) - else: - command = [web.ub.config.config_converterpath, file_path + u'.epub'] - if sys.version_info < (3, 0): - command = [x.encode(sys.getfilesystemencoding()) for x in command] + else:''' + command = [web.ub.config.config_converterpath, file_path + u'.epub'] + quotes = (1) if web.ub.config.config_ebookconverter == 2: # Linux py2.7 encode as list without quotes no empty element for parameters # linux py3.x no encode and as list without quotes no empty element for parameters # windows py2.7 encode as string with quotes empty element for parameters is okay # windows py 3.x no encode and as string with quotes empty element for parameters is okay # separate handling for windows and linux - if os.name == 'nt': + quotes = (1,2) + '''if os.name == 'nt': command = web.ub.config.config_converterpath + u' "' + file_path + format_old_ext + u'" "' + \ file_path + format_new_ext + u'" ' + web.ub.config.config_calibre if sys.version_info < (3, 0): command = command.encode(sys.getfilesystemencoding()) - else: - command = [web.ub.config.config_converterpath, (file_path + format_old_ext), - (file_path + format_new_ext)] - if web.ub.config.config_calibre: - parameters = web.ub.config.config_calibre.split(" ") - for param in parameters: - command.append(param) - if sys.version_info < (3, 0): - command = [x.encode(sys.getfilesystemencoding()) for x in command] - - p = subprocess.Popen(command, stdout=subprocess.PIPE, universal_newlines=True) + else:''' + command = [web.ub.config.config_converterpath, (file_path + format_old_ext), + (file_path + format_new_ext)] + index = 3 + if web.ub.config.config_calibre: + parameters = web.ub.config.config_calibre.split(" ") + for param in parameters: + command.append(param) + quotes.append(index) + index += 1 + p = process_open(command, quotes) + # p = subprocess.Popen(command, stdout=subprocess.PIPE, universal_newlines=True) except OSError as e: self._handleError(_(u"Ebook-converter failed: %(error)s", error=e)) return From a00d93a2d908ff121967caf8b7eb79dfd0be7e0d Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Wed, 6 Feb 2019 21:52:24 +0100 Subject: [PATCH 16/96] Working again (basically) --- cps.py | 15 +- cps/__init__.py | 83 +++++++ cps/db.py | 2 +- cps/gdriveutils.py | 2 +- cps/helper.py | 35 ++- cps/pagination.py | 75 ++++++ cps/server.py | 72 +++--- cps/templates/admin.html | 10 +- cps/templates/book_edit.html | 12 +- cps/templates/detail.html | 42 ++-- cps/templates/index.html | 16 +- cps/templates/layout.html | 58 ++--- cps/templates/search_form.html | 2 +- cps/templates/tasks.html | 4 +- cps/ub.py | 27 +- cps/updater.py | 3 +- cps/web.py | 442 ++++++++++++++------------------- cps/worker.py | 52 ++-- 18 files changed, 500 insertions(+), 452 deletions(-) create mode 100644 cps/pagination.py diff --git a/cps.py b/cps.py index 055c0ffe..d38a9f33 100755 --- a/cps.py +++ b/cps.py @@ -1,21 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import os import sys - -base_path = os.path.dirname(os.path.abspath(__file__)) -# Insert local directories into path -sys.path.append(base_path) -sys.path.append(os.path.join(base_path, 'cps')) -sys.path.append(os.path.join(base_path, 'vendor')) - -from cps.server import Server +from cps import create_app +from cps.web import web +from cps import Server if __name__ == '__main__': + app = create_app() + app.register_blueprint(web) Server.startServer() - diff --git a/cps/__init__.py b/cps/__init__.py index faa18be5..1170a85a 100755 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -1,2 +1,85 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- + +# import logging +# from logging.handlers import SMTPHandler, RotatingFileHandler +# import os + +from flask import Flask# , request, current_app +from flask_login import LoginManager +from flask_babel import Babel # , lazy_gettext as _l +import cache_buster +from reverseproxy import ReverseProxied +import logging +from logging.handlers import RotatingFileHandler +from flask_principal import Principal +# from flask_sqlalchemy import SQLAlchemy +import os +import ub +from ub import Config, Settings +import cPickle + + +# Normal +babel = Babel() +lm = LoginManager() +lm.login_view = 'web.login' +lm.anonymous_user = ub.Anonymous + + + +ub_session = ub.session +# ub_session.start() +config = Config() + + +import db + +with open(os.path.join(config.get_main_dir, 'cps/translations/iso639.pickle'), 'rb') as f: + language_table = cPickle.load(f) + +searched_ids = {} + + +from worker import WorkerThread + +global_WorkerThread = WorkerThread() + +from server import server +Server = server() + + +def create_app(): + app = Flask(__name__) + app.wsgi_app = ReverseProxied(app.wsgi_app) + cache_buster.init_cache_busting(app) + + formatter = logging.Formatter( + "[%(asctime)s] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s") + try: + file_handler = RotatingFileHandler(config.get_config_logfile(), maxBytes=50000, backupCount=2) + except IOError: + file_handler = RotatingFileHandler(os.path.join(config.get_main_dir, "calibre-web.log"), + maxBytes=50000, backupCount=2) + # ToDo: reset logfile value in config class + file_handler.setFormatter(formatter) + app.logger.addHandler(file_handler) + app.logger.setLevel(config.config_log_level) + + app.logger.info('Starting Calibre Web...') + logging.getLogger("book_formats").addHandler(file_handler) + logging.getLogger("book_formats").setLevel(config.config_log_level) + Principal(app) + lm.init_app(app) + babel.init_app(app) + app.secret_key = os.getenv('SECRET_KEY', 'A0Zr98j/3yX R~XHH!jmN]LWX/,?RT') + Server.init_app(app) + db.setup_db() + global_WorkerThread.start() + + # app.config.from_object(config_class) + # db.init_app(app) + # login.init_app(app) + + + return app diff --git a/cps/db.py b/cps/db.py index 225bcf4e..c9fecd37 100755 --- a/cps/db.py +++ b/cps/db.py @@ -24,7 +24,7 @@ from sqlalchemy.orm import * import os import re import ast -from ub import config +from cps import config import ub import sys diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py index cb36a413..1f0b8b83 100644 --- a/cps/gdriveutils.py +++ b/cps/gdriveutils.py @@ -27,7 +27,7 @@ except ImportError: gdrive_support = False import os -from ub import config +from cps import config import cli import shutil from flask import Response, stream_with_context diff --git a/cps/helper.py b/cps/helper.py index 0f489942..ba323449 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -20,14 +20,13 @@ import db -import ub +from cps import config from flask import current_app as app from tempfile import gettempdir import sys import os import re import unicodedata -# from io import BytesIO import worker import time from flask import send_from_directory, make_response, redirect, abort @@ -41,9 +40,10 @@ try: import gdriveutils as gd except ImportError: pass -import web +# import web import random from subproc_wrapper import process_open +import ub try: import unidecode @@ -51,11 +51,6 @@ try: except ImportError: use_unidecode = False -# Global variables -# updater_thread = None -global_WorkerThread = worker.WorkerThread() -global_WorkerThread.start() - def update_download(book_id, user_id): check = ub.session.query(ub.Downloads).filter(ub.Downloads.user_id == user_id).filter(ub.Downloads.book_id == @@ -73,7 +68,7 @@ def convert_book_format(book_id, calibrepath, old_book_format, new_book_format, error_message = _(u"%(format)s format not found for book id: %(book)d", format=old_book_format, book=book_id) app.logger.error("convert_book_format: " + error_message) return error_message - if ub.config.config_use_google_drive: + if config.config_use_google_drive: df = gd.getFileFromEbooksFolder(book.path, data.name + "." + old_book_format.lower()) if df: datafile = os.path.join(calibrepath, book.path, data.name + u"." + old_book_format.lower()) @@ -133,7 +128,7 @@ def check_send_to_kindle(entry): """ if len(entry.data): bookformats=list() - if ub.config.config_ebookconverter == 0: + if config.config_ebookconverter == 0: # no converter - only for mobi and pdf formats for ele in iter(entry.data): if 'MOBI' in ele.format: @@ -156,11 +151,11 @@ def check_send_to_kindle(entry): bookformats.append({'format': 'Azw3','convert':0,'text':_('Send %(format)s to Kindle',format='Azw3')}) if 'PDF' in formats: bookformats.append({'format': 'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')}) - if ub.config.config_ebookconverter >= 1: + if config.config_ebookconverter >= 1: if 'EPUB' in formats and not 'MOBI' in formats: bookformats.append({'format': 'Mobi','convert':1, 'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Mobi')}) - if ub.config.config_ebookconverter == 2: + if config.config_ebookconverter == 2: if 'EPUB' in formats and not 'AZW3' in formats: bookformats.append({'format': 'Azw3','convert':1, 'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Azw3')}) @@ -407,21 +402,21 @@ def generate_random_password(): ################################## External interface def update_dir_stucture(book_id, calibrepath, first_author = None): - if ub.config.config_use_google_drive: + if config.config_use_google_drive: return update_dir_structure_gdrive(book_id, first_author) else: return update_dir_structure_file(book_id, calibrepath, first_author) def delete_book(book, calibrepath, book_format): - if ub.config.config_use_google_drive: + if config.config_use_google_drive: return delete_book_gdrive(book, book_format) else: return delete_book_file(book, calibrepath, book_format) def get_book_cover(cover_path): - if ub.config.config_use_google_drive: + if config.config_use_google_drive: try: if not web.is_gdrive_ready(): return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg") @@ -437,7 +432,7 @@ def get_book_cover(cover_path): # traceback.print_exc() return send_from_directory(os.path.join(os.path.dirname(__file__), "static"),"generic_cover.jpg") else: - return send_from_directory(os.path.join(ub.config.config_calibre_dir, cover_path), "cover.jpg") + return send_from_directory(os.path.join(config.config_calibre_dir, cover_path), "cover.jpg") # saves book cover to gdrive or locally @@ -447,7 +442,7 @@ def save_cover(url, book_path): web.app.logger.error("Cover is no jpg file, can't save") return False - if ub.config.config_use_google_drive: + if config.config_use_google_drive: tmpDir = gettempdir() f = open(os.path.join(tmpDir, "uploaded_cover.jpg"), "wb") f.write(img.content) @@ -456,7 +451,7 @@ def save_cover(url, book_path): web.app.logger.info("Cover is saved on Google Drive") return True - f = open(os.path.join(ub.config.config_calibre_dir, book_path, "cover.jpg"), "wb") + f = open(os.path.join(config.config_calibre_dir, book_path, "cover.jpg"), "wb") f.write(img.content) f.close() web.app.logger.info("Cover is saved") @@ -464,7 +459,7 @@ def save_cover(url, book_path): def do_download_file(book, book_format, data, headers): - if ub.config.config_use_google_drive: + if config.config_use_google_drive: startTime = time.time() df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format) web.app.logger.debug(time.time() - startTime) @@ -473,7 +468,7 @@ def do_download_file(book, book_format, data, headers): else: abort(404) else: - filename = os.path.join(ub.config.config_calibre_dir, book.path) + filename = os.path.join(config.config_calibre_dir, book.path) if not os.path.isfile(os.path.join(filename, data.name + "." + book_format)): # ToDo: improve error handling web.app.logger.error('File not found: %s' % os.path.join(filename, data.name + "." + book_format)) diff --git a/cps/pagination.py b/cps/pagination.py new file mode 100644 index 00000000..891d616d --- /dev/null +++ b/cps/pagination.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11, +# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh, +# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe, +# ruben-herold, marblepebble, JackED42, SiphonSquirrel, +# apetresc, nanu-c, mutschler +# +# This program 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. +# +# This program 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 this program. If not, see . + +from math import ceil + +# simple pagination for the feed +class Pagination(object): + def __init__(self, page, per_page, total_count): + self.page = int(page) + self.per_page = int(per_page) + self.total_count = int(total_count) + + @property + def next_offset(self): + return int(self.page * self.per_page) + + @property + def previous_offset(self): + return int((self.page - 2) * self.per_page) + + @property + def last_offset(self): + last = int(self.total_count) - int(self.per_page) + if last < 0: + last = 0 + return int(last) + + @property + def pages(self): + return int(ceil(self.total_count / float(self.per_page))) + + @property + def has_prev(self): + return self.page > 1 + + @property + def has_next(self): + return self.page < self.pages + + # right_edge: last right_edges count of all pages are shown as number, means, if 10 pages are paginated -> 9,10 shwn + # left_edge: first left_edges count of all pages are shown as number -> 1,2 shwn + # left_current: left_current count below current page are shown as number, means if current page 5 -> 3,4 shwn + # left_current: right_current count above current page are shown as number, means if current page 5 -> 6,7 shwn + def iter_pages(self, left_edge=2, left_current=2, + right_current=4, right_edge=2): + last = 0 + left_current = self.page - left_current - 1 + right_current = self.page + right_current + 1 + right_edge = self.pages - right_edge + for num in range(1, (self.pages + 1)): + if num <= left_edge or (left_current < num < right_current) or num > right_edge: + if last + 1 != num: + yield None + yield num + last = num diff --git a/cps/server.py b/cps/server.py index 0531a729..a2c122dc 100644 --- a/cps/server.py +++ b/cps/server.py @@ -22,7 +22,7 @@ from socket import error as SocketError import sys import os import signal -import web +from cps import config, global_WorkerThread try: from gevent.pywsgi import WSGIServer @@ -42,82 +42,81 @@ class server: wsgiserver = None restart= False + app = None def __init__(self): signal.signal(signal.SIGINT, self.killServer) signal.signal(signal.SIGTERM, self.killServer) + def init_app(self,application): + self.app = application + def start_gevent(self): try: ssl_args = dict() - certfile_path = web.ub.config.get_config_certfile() - keyfile_path = web.ub.config.get_config_keyfile() + certfile_path = config.get_config_certfile() + keyfile_path = config.get_config_keyfile() if certfile_path and keyfile_path: if os.path.isfile(certfile_path) and os.path.isfile(keyfile_path): ssl_args = {"certfile": certfile_path, "keyfile": keyfile_path} else: - web.app.logger.info('The specified paths for the ssl certificate file and/or key file seem to be broken. Ignoring ssl. Cert path: %s | Key path: %s' % (certfile_path, keyfile_path)) + self.app.logger.info('The specified paths for the ssl certificate file and/or key file seem to be broken. Ignoring ssl. Cert path: %s | Key path: %s' % (certfile_path, keyfile_path)) if os.name == 'nt': - self.wsgiserver= WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) + self.wsgiserver= WSGIServer(('0.0.0.0', config.config_port), self.app, spawn=Pool(), **ssl_args) else: - self.wsgiserver = WSGIServer(('', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) - web.py3_gevent_link = self.wsgiserver + self.wsgiserver = WSGIServer(('', config.config_port), self.app, spawn=Pool(), **ssl_args) self.wsgiserver.serve_forever() except SocketError: try: - web.app.logger.info('Unable to listen on \'\', trying on IPv4 only...') - self.wsgiserver = WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) - web.py3_gevent_link = self.wsgiserver + self.app.logger.info('Unable to listen on \'\', trying on IPv4 only...') + self.wsgiserver = WSGIServer(('0.0.0.0', config.config_port), self.app, spawn=Pool(), **ssl_args) self.wsgiserver.serve_forever() except (OSError, SocketError) as e: - web.app.logger.info("Error starting server: %s" % e.strerror) + self.app.logger.info("Error starting server: %s" % e.strerror) print("Error starting server: %s" % e.strerror) - web.helper.global_WorkerThread.stop() + global_WorkerThread.stop() sys.exit(1) except Exception: - web.app.logger.info("Unknown error while starting gevent") + self.app.logger.info("Unknown error while starting gevent") def startServer(self): if gevent_present: - web.app.logger.info('Starting Gevent server') + self.app.logger.info('Starting Gevent server') # leave subprocess out to allow forking for fetchers and processors self.start_gevent() else: try: ssl = None - web.app.logger.info('Starting Tornado server') - certfile_path = web.ub.config.get_config_certfile() - keyfile_path = web.ub.config.get_config_keyfile() + self.app.logger.info('Starting Tornado server') + certfile_path = config.get_config_certfile() + keyfile_path = config.get_config_keyfile() if certfile_path and keyfile_path: if os.path.isfile(certfile_path) and os.path.isfile(keyfile_path): ssl = {"certfile": certfile_path, "keyfile": keyfile_path} else: - web.app.logger.info('The specified paths for the ssl certificate file and/or key file seem to be broken. Ignoring ssl. Cert path: %s | Key path: %s' % (certfile_path, keyfile_path)) + self.app.logger.info('The specified paths for the ssl certificate file and/or key file seem to be broken. Ignoring ssl. Cert path: %s | Key path: %s' % (certfile_path, keyfile_path)) # Max Buffersize set to 200MB - http_server = HTTPServer(WSGIContainer(web.app), + http_server = HTTPServer(WSGIContainer(self.app), max_buffer_size = 209700000, ssl_options=ssl) - http_server.listen(web.ub.config.config_port) + http_server.listen(config.config_port) self.wsgiserver=IOLoop.instance() self.wsgiserver.start() # wait for stop signal self.wsgiserver.close(True) except SocketError as e: - web.app.logger.info("Error starting server: %s" % e.strerror) + self.app.logger.info("Error starting server: %s" % e.strerror) print("Error starting server: %s" % e.strerror) - web.helper.global_WorkerThread.stop() + global_WorkerThread.stop() sys.exit(1) - # ToDo: Somehow caused by circular import under python3 refactor - if sys.version_info > (3, 0): - self.restart = web.py3_restart_Typ if self.restart == True: - web.app.logger.info("Performing restart of Calibre-Web") - web.helper.global_WorkerThread.stop() + self.app.logger.info("Performing restart of Calibre-Web") + global_WorkerThread.stop() if os.name == 'nt': arguments = ["\"" + sys.executable + "\""] for e in sys.argv: @@ -126,26 +125,17 @@ class server: else: os.execl(sys.executable, sys.executable, *sys.argv) else: - web.app.logger.info("Performing shutdown of Calibre-Web") - web.helper.global_WorkerThread.stop() + self.app.logger.info("Performing shutdown of Calibre-Web") + global_WorkerThread.stop() sys.exit(0) def setRestartTyp(self,starttyp): self.restart = starttyp - # ToDo: Somehow caused by circular import under python3 refactor - web.py3_restart_Typ = starttyp def killServer(self, signum, frame): self.stopServer() def stopServer(self): - # ToDo: Somehow caused by circular import under python3 refactor - if sys.version_info > (3, 0): - if not self.wsgiserver: - if gevent_present: - self.wsgiserver = web.py3_gevent_link - else: - self.wsgiserver = IOLoop.instance() if self.wsgiserver: if gevent_present: self.wsgiserver.close() @@ -155,10 +145,6 @@ class server: @staticmethod def getNameVersion(): if gevent_present: - return {'Gevent':'v'+geventVersion} + return {'Gevent':'v' + geventVersion} else: return {'Tornado':'v'+tornadoVersion} - - -# Start Instance of Server -Server=server() diff --git a/cps/templates/admin.html b/cps/templates/admin.html index e43e3f6b..4063f23b 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -18,7 +18,7 @@ {% for user in content %} {% if not user.role_anonymous() or config.config_anonbrowse %} - {{user.nickname}} + {{user.nickname}} {{user.email}} {{user.kindle_mail}} {{user.downloads.count()}} @@ -30,7 +30,7 @@ {% endif %} {% endfor %} - +
    @@ -53,7 +53,7 @@ {{email.mail_from}} - + @@ -96,8 +96,8 @@
    {% if config.config_remote_login %}{% else %}{% endif %}
    - - + + diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index c9871f1a..449a2d57 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -6,7 +6,7 @@
    {% if book.has_cover %} - {{ book.title }} + {{ book.title }} {% else %} {{ book.title }} {% endif %} @@ -19,7 +19,7 @@

    {{_('Delete formats:')}}

    {% for file in book.data %} {% endfor %}
    @@ -28,7 +28,7 @@ {% if source_formats|length > 0 and conversion_formats|length > 0 %}

    {{_('Convert book format:')}}

    -
    +
    @@ -53,7 +53,7 @@ {% endif %}
    - +
    @@ -175,7 +175,7 @@
    {{_('Get metadata')}} - {{_('Back')}} + {{_('Back')}}
    @@ -196,7 +196,7 @@
    diff --git a/cps/templates/detail.html b/cps/templates/detail.html index 4bb96eb6..e68bcf47 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -5,7 +5,7 @@
    {% if entry.has_cover %} - {{ entry.title }} + {{ entry.title }} {% else %} {{ entry.title }} {% endif %} @@ -22,7 +22,7 @@ {{_('Download')}} : {% for format in entry.data %} - + {{format.format}} ({{ format.uncompressed_size|filesizeformat }}) {% endfor %} @@ -33,7 +33,7 @@ {% endif %} @@ -42,7 +42,7 @@ {% endif %} {% if g.user.kindle_mail and g.user.is_authenticated and kindle_list %} {% if kindle_list.__len__() == 1 %} - {{kindle_list[0]['text']}} + {{kindle_list[0]['text']}} {% else %}
    @@ -66,14 +66,14 @@ @@ -86,7 +86,7 @@

    {{entry.title|shortentitle(40)}}

    {% for author in entry.authors %} - {{author.name.replace('|',',')}} + {{author.name.replace('|',',')}} {% if not loop.last %} & {% endif %} @@ -108,7 +108,7 @@ {% endif %} {% if entry.series|length > 0 %} -

    {{_('Book')}} {{entry.series_index}} {{_('of')}} {{entry.series[0].name}}

    +

    {{_('Book')}} {{entry.series_index}} {{_('of')}} {{entry.series[0].name}}

    {% endif %} {% if entry.languages.__len__() > 0 %} @@ -137,7 +137,7 @@ {% for tag in entry.tags %} - {{tag.name}} + {{tag.name}} {%endfor%}

    @@ -148,7 +148,7 @@ @@ -191,7 +191,7 @@

    -

    +
    diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 3d885c32..6abe9685 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -267,10 +267,10 @@
    {% if not origin %} - {{_('Back')}} + {{_('Back')}} {% endif %} {% if success %} - {{_('Login')}} + {{_('Login')}} {% endif %}
    diff --git a/cps/templates/config_view_edit.html b/cps/templates/config_view_edit.html index 3b8ebf80..86df4451 100644 --- a/cps/templates/config_view_edit.html +++ b/cps/templates/config_view_edit.html @@ -172,7 +172,7 @@
    - {{_('Back')}} + {{_('Back')}}
    diff --git a/cps/templates/email_edit.html b/cps/templates/email_edit.html index 0af404b8..00b60640 100644 --- a/cps/templates/email_edit.html +++ b/cps/templates/email_edit.html @@ -37,7 +37,7 @@
    - {{_('Back')}} + {{_('Back')}} {% if g.allow_registration %}

    {{_('Allowed domains for registering')}}

    diff --git a/cps/templates/feed.xml b/cps/templates/feed.xml index 540f57ed..f848720c 100644 --- a/cps/templates/feed.xml +++ b/cps/templates/feed.xml @@ -6,10 +6,10 @@ href="{{request.script_root + request.full_path}}" type="application/atom+xml;profile=opds-catalog;type=feed;kind=navigation"/> {% if pagination.has_prev %} {% endif %} - + {{instance}} {{instance}} @@ -61,11 +61,11 @@ {% endfor %} {% if entry.comments[0] %}{{entry.comments[0].text|striptags}}{% endif %} {% if entry.has_cover %} - - + + {% endif %} {% for format in entry.data %} - {% endfor %} diff --git a/cps/templates/layout.html b/cps/templates/layout.html index cc55cce5..438e713b 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -71,7 +71,7 @@
  • {% endif %} {% if g.user.role_admin() %} -
  • +
  • {% endif %}
  • {% if not g.user.is_anonymous %} @@ -172,11 +172,11 @@ {% endfor %} {% for shelf in g.user.shelf %} -
  • {{shelf.name|shortentitle(40)}}
  • +
  • {{shelf.name|shortentitle(40)}}
  • {% endfor %} {% if not g.user.is_anonymous %} - - + + {% endif %} {% endif %} diff --git a/cps/templates/search.html b/cps/templates/search.html index d8fde51a..9c15b199 100644 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -35,18 +35,18 @@
    {% if entry.has_cover is defined %} - - {{ entry.title }} + + {{ entry.title }} {% endif %}
    - +

    {{entry.title|shortentitle}}

    {% for author in entry.authors %} - {{author.name.replace('|',',')}} + {{author.name.replace('|',',')}} {% if not loop.last %} & {% endif %} diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index cfeb4eee..d0f4ee67 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -3,13 +3,13 @@

    {{title}}

    {% if g.user.role_download() %} - {{ _('Download') }} + {{ _('Download') }} {% endif %} {% if g.user.is_authenticated %} {% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %}
    {{ _('Delete this Shelf') }}
    - {{ _('Edit Shelf') }} - {{ _('Change order') }} + {{ _('Edit Shelf') }} + {{ _('Change order') }} {% endif %} {% endif %}
    @@ -17,21 +17,21 @@ {% for entry in entries %}
    - +

    {{entry.title|shortentitle}}

    {% for author in entry.authors %} - {{author.name.replace('|',',')}} + {{author.name.replace('|',',')}} {% if not loop.last %} & {% endif %} @@ -63,7 +63,7 @@

    diff --git a/cps/templates/shelf_edit.html b/cps/templates/shelf_edit.html index e9bc1523..d7f32dc4 100644 --- a/cps/templates/shelf_edit.html +++ b/cps/templates/shelf_edit.html @@ -16,7 +16,7 @@ {% endif %} {% if shelf.id != None %} - {{_('Back')}} + {{_('Back')}} {% endif %}
    diff --git a/cps/ub.py b/cps/ub.py index 177f9ce9..ba69c4ec 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -35,6 +35,8 @@ import cli engine = create_engine('sqlite:///{0}'.format(cli.settingspath), echo=False) Base = declarative_base() +session = None + ROLE_USER = 0 ROLE_ADMIN = 1 ROLE_DOWNLOAD = 2 @@ -849,22 +851,23 @@ def create_admin_user(): except Exception: session.rollback() - -# Open session for database connection -Session = sessionmaker() -Session.configure(bind=engine) -session = Session() - - -if not os.path.exists(cli.settingspath): - try: +def init_db(): + # Open session for database connection + global session + Session = sessionmaker() + Session.configure(bind=engine) + session = Session() + + + if not os.path.exists(cli.settingspath): + try: + Base.metadata.create_all(engine) + create_default_config() + create_admin_user() + create_anonymous_user() + except Exception: + raise + else: Base.metadata.create_all(engine) - create_default_config() - create_admin_user() - create_anonymous_user() - except Exception: - raise -else: - Base.metadata.create_all(engine) - migrate_Database() - clean_database() + migrate_Database() + clean_database() diff --git a/cps/updater.py b/cps/updater.py index 91d86660..b01646a0 100644 --- a/cps/updater.py +++ b/cps/updater.py @@ -17,26 +17,25 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . + +from cps import config, get_locale import threading import zipfile import requests -import re import logging -import server import time from io import BytesIO import os import sys import shutil -from cps import config from ub import UPDATE_STABLE from tempfile import gettempdir import datetime import json from flask_babel import gettext as _ from babel.dates import format_datetime -import web +import server def is_sha1(sha1): if len(sha1) != 40: @@ -288,7 +287,7 @@ class Updater(threading.Thread): update_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz parents.append( [ - format_datetime(new_commit_date, format='short', locale=web.get_locale()), + format_datetime(new_commit_date, format='short', locale=get_locale()), update_data['message'], update_data['sha'] ] @@ -318,7 +317,7 @@ class Updater(threading.Thread): parent_commit_date = datetime.datetime.strptime( parent_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz parent_commit_date = format_datetime( - parent_commit_date, format='short', locale=web.get_locale()) + parent_commit_date, format='short', locale=get_locale()) parents.append([parent_commit_date, parent_data['message'].replace('\r\n','

    ').replace('\n','

    ')]) @@ -346,7 +345,7 @@ class Updater(threading.Thread): commit['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz parents.append( [ - format_datetime(new_commit_date, format='short', locale=web.get_locale()), + format_datetime(new_commit_date, format='short', locale=get_locale()), commit['message'], commit['sha'] ] @@ -376,7 +375,7 @@ class Updater(threading.Thread): parent_commit_date = datetime.datetime.strptime( parent_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz parent_commit_date = format_datetime( - parent_commit_date, format='short', locale=web.get_locale()) + parent_commit_date, format='short', locale=get_locale()) parents.append([parent_commit_date, parent_data['message'], parent_data['sha']]) parent_commit = parent_data['parents'][0] @@ -510,6 +509,3 @@ class Updater(threading.Thread): status['message'] = _(u'General error') return status, commit - - -updater_thread = Updater() diff --git a/cps/uploader.py b/cps/uploader.py index 8d9b74a4..df516d24 100644 --- a/cps/uploader.py +++ b/cps/uploader.py @@ -17,11 +17,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os +# import os from tempfile import gettempdir import hashlib from collections import namedtuple -import book_formats +import logging +import os +from flask_babel import gettext as _ +import comic BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, series_id, languages') @@ -29,6 +32,158 @@ BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, d :rtype: BookMeta """ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2016-2019 lemmsh cervinko Kennyl matthazinski OzzieIsaacs +# +# This program 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. +# +# This program 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 this program. If not, see . + + + +try: + from lxml.etree import LXML_VERSION as lxmlversion +except ImportError: + lxmlversion = None + +__author__ = 'lemmsh' + +logger = logging.getLogger("book_formats") + +try: + from wand.image import Image + from wand import version as ImageVersion + use_generic_pdf_cover = False +except (ImportError, RuntimeError) as e: + logger.warning('cannot import Image, generating pdf covers for pdf uploads will not work: %s', e) + use_generic_pdf_cover = True +try: + from PyPDF2 import PdfFileReader + from PyPDF2 import __version__ as PyPdfVersion + use_pdf_meta = True +except ImportError as e: + logger.warning('cannot import PyPDF2, extracting pdf metadata will not work: %s', e) + use_pdf_meta = False + +try: + import epub + use_epub_meta = True +except ImportError as e: + logger.warning('cannot import epub, extracting epub metadata will not work: %s', e) + use_epub_meta = False + +try: + import fb2 + use_fb2_meta = True +except ImportError as e: + logger.warning('cannot import fb2, extracting fb2 metadata will not work: %s', e) + use_fb2_meta = False + + +def process(tmp_file_path, original_file_name, original_file_extension): + meta = None + try: + if ".PDF" == original_file_extension.upper(): + meta = pdf_meta(tmp_file_path, original_file_name, original_file_extension) + if ".EPUB" == original_file_extension.upper() and use_epub_meta is True: + meta = epub.get_epub_info(tmp_file_path, original_file_name, original_file_extension) + if ".FB2" == original_file_extension.upper() and use_fb2_meta is True: + meta = fb2.get_fb2_info(tmp_file_path, original_file_extension) + if original_file_extension.upper() in ['.CBZ', '.CBT']: + meta = comic.get_comic_info(tmp_file_path, original_file_name, original_file_extension) + + except Exception as ex: + logger.warning('cannot parse metadata, using default: %s', ex) + + if meta and meta.title.strip() and meta.author.strip(): + return meta + else: + return default_meta(tmp_file_path, original_file_name, original_file_extension) + + +def default_meta(tmp_file_path, original_file_name, original_file_extension): + return BookMeta( + file_path=tmp_file_path, + extension=original_file_extension, + title=original_file_name, + author=u"Unknown", + cover=None, + description="", + tags="", + series="", + series_id="", + languages="") + + +def pdf_meta(tmp_file_path, original_file_name, original_file_extension): + + if use_pdf_meta: + pdf = PdfFileReader(open(tmp_file_path, 'rb')) + doc_info = pdf.getDocumentInfo() + else: + doc_info = None + + if doc_info is not None: + author = doc_info.author if doc_info.author else u"Unknown" + title = doc_info.title if doc_info.title else original_file_name + subject = doc_info.subject + else: + author = u"Unknown" + title = original_file_name + subject = "" + return BookMeta( + file_path=tmp_file_path, + extension=original_file_extension, + title=title, + author=author, + cover=pdf_preview(tmp_file_path, original_file_name), + description=subject, + tags="", + series="", + series_id="", + languages="") + + +def pdf_preview(tmp_file_path, tmp_dir): + if use_generic_pdf_cover: + return None + else: + cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg" + with Image(filename=tmp_file_path + "[0]", resolution=150) as img: + img.compression_quality = 88 + img.save(filename=os.path.join(tmp_dir, cover_file_name)) + return cover_file_name + + +def get_versions(): + if not use_generic_pdf_cover: + IVersion = ImageVersion.MAGICK_VERSION + WVersion = ImageVersion.VERSION + else: + IVersion = _(u'not installed') + WVersion = _(u'not installed') + if use_pdf_meta: + PVersion='v'+PyPdfVersion + else: + PVersion=_(u'not installed') + if lxmlversion: + XVersion = 'v'+'.'.join(map(str, lxmlversion)) + else: + XVersion = _(u'not installed') + return {'Image Magick': IVersion, 'PyPdf': PVersion, 'lxml':XVersion, 'Wand Version': WVersion} + def upload(uploadfile): tmp_dir = os.path.join(gettempdir(), 'calibre_web') diff --git a/cps/web.py b/cps/web.py index 36728b7c..346cb005 100644 --- a/cps/web.py +++ b/cps/web.py @@ -21,33 +21,21 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import mimetypes -import logging -from flask import (Flask, session, render_template, request, Response, redirect, - url_for, send_from_directory, make_response, g, flash, - abort, Markup) -from flask import __version__ as flaskVersion -from werkzeug import __version__ as werkzeugVersion +from cps import mimetypes, global_WorkerThread, searched_ids +from flask import render_template, request, redirect, url_for, send_from_directory, make_response, g, flash, abort # from werkzeug.exceptions import default_exceptions - -from jinja2 import __version__ as jinja2Version import helper import os # from sqlalchemy.sql.expression import func # from sqlalchemy.sql.expression import false from sqlalchemy.exc import IntegrityError -from sqlalchemy import __version__ as sqlalchemyVersion -from flask_login import (login_user, logout_user, - login_required, current_user) -from flask_principal import __version__ as flask_principalVersion +from flask_login import login_user, logout_user, login_required, current_user from flask_babel import gettext as _ -import requests + from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.datastructures import Headers from babel import Locale as LC -from babel import negotiate_locale -from babel import __version__ as babelVersion -from babel.dates import format_date, format_datetime +from babel.dates import format_date from babel.core import UnknownLocaleError from functools import wraps import base64 @@ -55,30 +43,14 @@ from sqlalchemy.sql import * import json import datetime from iso639 import languages as isoLanguages -from iso639 import __version__ as iso639Version -from pytz import __version__ as pytzVersion -from uuid import uuid4 import os.path -import sys import re import db -from shutil import move, copyfile import gdriveutils -import converter -import tempfile from redirect import redirect_back -import time -import server -from updater import updater_thread -#from flask_dance.contrib.github import make_github_blueprint, github -#from flask_dance.contrib.google import make_google_blueprint, google -#from flask_dance.consumer import oauth_authorized, oauth_error -#from sqlalchemy.orm.exc import NoResultFound -# from oauth import OAuthBackend -import hashlib -from cps import lm, babel, ub_session, config, Server -import ub +from cps import lm, babel, ub, config, get_locale, language_table, app from pagination import Pagination +# from oauth_bb import oauth_check, register_user_with_oauth try: from googleapiclient.errors import HttpError @@ -111,12 +83,7 @@ except ImportError: try: from natsort import natsorted as sort except ImportError: - sort=sorted # Just use regular sort then - # may cause issues with badly named pages in cbz/cbr files -try: - import cPickle -except ImportError: - import pickle as cPickle + sort = sorted # Just use regular sort then, may cause issues with badly named pages in cbz/cbr files try: from urllib.parse import quote @@ -124,48 +91,15 @@ try: except ImportError: from urllib import quote -try: - from flask_login import __version__ as flask_loginVersion -except ImportError: - from flask_login.__about__ import __version__ as flask_loginVersion +from flask import Blueprint # Global variables -current_milli_time = lambda: int(round(time.time() * 1000)) -gdrive_watch_callback_token = 'target=calibreweb-watch_files' -# ToDo: Somehow caused by circular import under python3 refactor -EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx', - 'fb2', 'html', 'rtf', 'odt', 'mp3', 'm4a', 'm4b'} -EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'html', 'rtf', 'odt'} + EXTENSIONS_AUDIO = {'mp3', 'm4a', 'm4b'} # EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] + (['rar','cbr'] if rar_support else [])) -oauth_check = {} - - - -# Main code -mimetypes.init() -mimetypes.add_type('application/xhtml+xml', '.xhtml') -mimetypes.add_type('application/epub+zip', '.epub') -mimetypes.add_type('application/fb2+zip', '.fb2') -mimetypes.add_type('application/x-mobipocket-ebook', '.mobi') -mimetypes.add_type('application/x-mobipocket-ebook', '.prc') -mimetypes.add_type('application/vnd.amazon.ebook', '.azw') -mimetypes.add_type('application/x-cbr', '.cbr') -mimetypes.add_type('application/x-cbz', '.cbz') -mimetypes.add_type('application/x-cbt', '.cbt') -mimetypes.add_type('image/vnd.djvu', '.djvu') -mimetypes.add_type('application/mpeg', '.mpeg') -mimetypes.add_type('application/mpeg', '.mp3') -mimetypes.add_type('application/mp4', '.m4a') -mimetypes.add_type('application/mp4', '.m4b') -mimetypes.add_type('application/ogg', '.ogg') -mimetypes.add_type('application/ogg', '.oga') - - -app = (Flask(__name__)) ''''# custom error page def error_http(error): @@ -181,55 +115,19 @@ for ex in default_exceptions: if ex < 500: app.register_error_handler(ex, error_http) - - -# import uploader - ''' -from flask import Blueprint - web = Blueprint('web', __name__) -def is_gdrive_ready(): - return os.path.exists(os.path.join(config.get_main_dir, 'settings.yaml')) and \ - os.path.exists(os.path.join(config.get_main_dir, 'gdrive_credentials')) - - - -@babel.localeselector -def get_locale(): - # if a user is logged in, use the locale from the user settings - user = getattr(g, 'user', None) - if user is not None and hasattr(user, "locale"): - if user.nickname != 'Guest': # if the account is the guest account bypass the config lang settings - return user.locale - translations = [str(item) for item in babel.list_translations()] + ['en'] - preferred = list() - for x in request.accept_languages.values(): - try: - preferred.append(str(LC.parse(x.replace('-', '_')))) - except (UnknownLocaleError, ValueError) as e: - app.logger.debug("Could not parse locale: %s", e) - preferred.append('en') - return negotiate_locale(preferred, translations) - - -@babel.timezoneselector -def get_timezone(): - user = getattr(g, 'user', None) - if user is not None: - return user.timezone - - @lm.user_loader def load_user(user_id): try: - return ub_session.query(ub.User).filter(ub.User.id == int(user_id)).first() + return ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() except Exception as e: print(e) + @lm.header_loader def load_user_from_header(header_val): if header_val.startswith('Basic '): @@ -247,30 +145,6 @@ def load_user_from_header(header_val): return -def check_auth(username, password): - user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == username.lower()).first() - return bool(user and check_password_hash(user.password, password)) - - -def authenticate(): - return Response( - 'Could not verify your access level for that URL.\n' - 'You have to login with proper credentials', 401, - {'WWW-Authenticate': 'Basic realm="Login Required"'}) - - -def requires_basic_auth_if_no_ano(f): - @wraps(f) - def decorated(*args, **kwargs): - auth = request.authorization - if config.config_anonbrowse != 1: - if not auth or not check_auth(auth.username, auth.password): - return authenticate() - return f(*args, **kwargs) - - return decorated - - def login_required_if_no_ano(func): @wraps(func) def decorated_view(*args, **kwargs): @@ -324,85 +198,6 @@ def google_oauth_required(f): return inner -# custom jinja filters - -# pagination links in jinja -@web.app_template_filter('url_for_other_page') -def url_for_other_page(page): - args = request.view_args.copy() - args['page'] = page - return url_for(request.endpoint, **args) - - -# shortentitles to at longest nchar, shorten longer words if necessary -@web.app_template_filter('shortentitle') -def shortentitle_filter(s, nchar=20): - text = s.split() - res = "" # result - suml = 0 # overall length - for line in text: - if suml >= 60: - res += '...' - break - # if word longer than 20 chars truncate line and append '...', otherwise add whole word to result - # string, and summarize total length to stop at chars given by nchar - if len(line) > nchar: - res += line[:(nchar-3)] + '[..] ' - suml += nchar+3 - else: - res += line + ' ' - suml += len(line) + 1 - return res.strip() - - -@web.app_template_filter('mimetype') -def mimetype_filter(val): - try: - s = mimetypes.types_map['.' + val] - except Exception: - s = 'application/octet-stream' - return s - - -@web.app_template_filter('formatdate') -def formatdate_filter(val): - conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val) - formatdate = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S") - return format_date(formatdate, format='medium', locale=get_locale()) - - -@web.app_template_filter('formatdateinput') -def format_date_input(val): - conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val) - date_obj = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S") - input_date = date_obj.isoformat().split('T', 1)[0] # Hack to support dates <1900 - return '' if input_date == "0101-01-01" else input_date - - -@web.app_template_filter('strftime') -def timestamptodate(date, fmt=None): - date = datetime.datetime.fromtimestamp( - int(date)/1000 - ) - native = date.replace(tzinfo=None) - if fmt: - time_format = fmt - else: - time_format = '%d %m %Y - %H:%S' - return native.strftime(time_format) - - -@web.app_template_filter('yesno') -def yesno(value, yes, no): - return yes if value else no - - -'''@web.app_template_filter('canread') -def canread(ext): - if isinstance(ext, db.Data): - ext = ext.format - return ext.lower() in EXTENSIONS_READER''' - def admin_required(f): """ @@ -485,6 +280,7 @@ def speaking_language(languages=None): lang.name = _(isoLanguages.get(part3=lang.lang_code).name) return languages + # Orders all Authors in the list according to authors sort def order_authors(entry): sort_authors = entry.author_sort.split('&') @@ -501,6 +297,7 @@ def order_authors(entry): entry.authors = authors_ordered return entry + # Fill indexpage with all requested data from database def fill_indexpage(page, database, db_filter, order, *join): if current_user.show_detail_random(): @@ -510,121 +307,14 @@ def fill_indexpage(page, database, db_filter, order, *join): randm = false() off = int(int(config.config_books_per_page) * (page - 1)) pagination = Pagination(page, config.config_books_per_page, - len(db.session.query(database) - .filter(db_filter).filter(common_filters()).all())) - entries = db.session.query(database).join(*join,isouter=True).filter(db_filter)\ - .filter(common_filters()).order_by(*order).offset(off).limit(config.config_books_per_page).all() + len(db.session.query(database).filter(db_filter).filter(common_filters()).all())) + entries = db.session.query(database).join(*join, isouter=True).filter(db_filter).filter(common_filters()).\ + order_by(*order).offset(off).limit(config.config_books_per_page).all() for book in entries: book = order_authors(book) return entries, randm, pagination -# Modifies different Database objects, first check if elements have to be added to database, than check -# if elements have to be deleted, because they are no longer used -def modify_database_object(input_elements, db_book_object, db_object, db_session, db_type): - # passing input_elements not as a list may lead to undesired results - if not isinstance(input_elements, list): - raise TypeError(str(input_elements) + " should be passed as a list") - - input_elements = [x for x in input_elements if x != ''] - # we have all input element (authors, series, tags) names now - # 1. search for elements to remove - del_elements = [] - for c_elements in db_book_object: - found = False - if db_type == 'languages': - type_elements = c_elements.lang_code - elif db_type == 'custom': - type_elements = c_elements.value - else: - type_elements = c_elements.name - for inp_element in input_elements: - if inp_element.lower() == type_elements.lower(): - # if inp_element == type_elements: - found = True - break - # if the element was not found in the new list, add it to remove list - if not found: - del_elements.append(c_elements) - # 2. search for elements that need to be added - add_elements = [] - for inp_element in input_elements: - found = False - for c_elements in db_book_object: - if db_type == 'languages': - type_elements = c_elements.lang_code - elif db_type == 'custom': - type_elements = c_elements.value - else: - type_elements = c_elements.name - if inp_element == type_elements: - found = True - break - if not found: - add_elements.append(inp_element) - # if there are elements to remove, we remove them now - if len(del_elements) > 0: - for del_element in del_elements: - db_book_object.remove(del_element) - if len(del_element.books) == 0: - db_session.delete(del_element) - # if there are elements to add, we add them now! - if len(add_elements) > 0: - if db_type == 'languages': - db_filter = db_object.lang_code - elif db_type == 'custom': - db_filter = db_object.value - else: - db_filter = db_object.name - for add_element in add_elements: - # check if a element with that name exists - db_element = db_session.query(db_object).filter(db_filter == add_element).first() - # if no element is found add it - # if new_element is None: - if db_type == 'author': - new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')), "") - elif db_type == 'series': - new_element = db_object(add_element, add_element) - elif db_type == 'custom': - new_element = db_object(value=add_element) - elif db_type == 'publisher': - new_element = db_object(add_element, None) - else: # db_type should be tag or language - new_element = db_object(add_element) - if db_element is None: - db_session.add(new_element) - db_book_object.append(new_element) - else: - if db_type == 'custom': - if db_element.value != add_element: - new_element.value = add_element - # new_element = db_element - elif db_type == 'languages': - if db_element.lang_code != add_element: - db_element.lang_code = add_element - # new_element = db_element - elif db_type == 'series': - if db_element.name != add_element: - db_element.name = add_element # = add_element # new_element = db_object(add_element, add_element) - db_element.sort = add_element - # new_element = db_element - elif db_type == 'author': - if db_element.name != add_element: - db_element.name = add_element - db_element.sort = add_element.replace('|', ',') - # new_element = db_element - elif db_type == 'publisher': - if db_element.name != add_element: - db_element.name = add_element - db_element.sort = None - # new_element = db_element - elif db_element.name != add_element: - db_element.name = add_element - # new_element = db_element - # add element to book - db_book_object.append(db_element) - - # read search results from calibre-database and return it (function is used for feed and simple search def get_search_results(term): q = list() @@ -642,32 +332,12 @@ def get_search_results(term): db.Books.title.ilike("%" + term + "%"))).all() -def feed_search(term): - if term: - term = term.strip().lower() - entries = get_search_results( term) - entriescount = len(entries) if len(entries) > 0 else 1 - pagination = Pagination(1, entriescount, entriescount) - return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination) - else: - return render_xml_template('feed.xml', searchterm="") - - -def render_xml_template(*args, **kwargs): - #ToDo: return time in current timezone similar to %z - currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00") - xml = render_template(current_time=currtime, *args, **kwargs) - response = make_response(xml) - response.headers["Content-Type"] = "application/atom+xml; charset=utf-8" - return response - - -# Returns the template for redering and includes the instance name +# Returns the template for rendering and includes the instance name def render_title_template(*args, **kwargs): return render_template(instance=config.config_calibre_web_title, *args, **kwargs) -@web.before_request +@web.before_app_request def before_request(): g.user = current_user g.allow_registration = config.config_public_reg @@ -678,247 +348,12 @@ def before_request(): return redirect(url_for('web.basic_configuration')) -# Routing functions - -@web.route("/opds") -@requires_basic_auth_if_no_ano -def feed_index(): - return render_xml_template('index.xml') - - -@web.route("/opds/osd") -@requires_basic_auth_if_no_ano -def feed_osd(): - return render_xml_template('osd.xml', lang='en-EN') - - -@web.route("/opds/search/") -@requires_basic_auth_if_no_ano -def feed_cc_search(query): - return feed_search(query.strip()) - - -@web.route("/opds/search", methods=["GET"]) -@requires_basic_auth_if_no_ano -def feed_normal_search(): - return feed_search(request.args.get("query").strip()) - - -@web.route("/opds/new") -@requires_basic_auth_if_no_ano -def feed_new(): - off = request.args.get("offset") or 0 - entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), - db.Books, True, [db.Books.timestamp.desc()]) - return render_xml_template('feed.xml', entries=entries, pagination=pagination) - - -@web.route("/opds/discover") -@requires_basic_auth_if_no_ano -def feed_discover(): - entries = db.session.query(db.Books).filter(common_filters()).order_by(func.random())\ - .limit(config.config_books_per_page) - pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page)) - return render_xml_template('feed.xml', entries=entries, pagination=pagination) - - -@web.route("/opds/rated") -@requires_basic_auth_if_no_ano -def feed_best_rated(): - off = request.args.get("offset") or 0 - entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), - db.Books, db.Books.ratings.any(db.Ratings.rating > 9), [db.Books.timestamp.desc()]) - return render_xml_template('feed.xml', entries=entries, pagination=pagination) - - -@web.route("/opds/hot") -@requires_basic_auth_if_no_ano -def feed_hot(): - off = request.args.get("offset") or 0 - all_books = ub.session.query(ub.Downloads, ub.func.count(ub.Downloads.book_id)).order_by( - ub.func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id) - hot_books = all_books.offset(off).limit(config.config_books_per_page) - entries = list() - for book in hot_books: - downloadBook = db.session.query(db.Books).filter(db.Books.id == book.Downloads.book_id).first() - if downloadBook: - entries.append( - db.session.query(db.Books).filter(common_filters()) - .filter(db.Books.id == book.Downloads.book_id).first() - ) - else: - ub.delete_download(book.Downloads.book_id) - # ub.session.query(ub.Downloads).filter(book.Downloads.book_id == ub.Downloads.book_id).delete() - # ub.session.commit() - numBooks = entries.__len__() - pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), - config.config_books_per_page, numBooks) - return render_xml_template('feed.xml', entries=entries, pagination=pagination) - - -@web.route("/opds/author") -@requires_basic_auth_if_no_ano -def feed_authorindex(): - off = request.args.get("offset") or 0 - entries = db.session.query(db.Authors).join(db.books_authors_link).join(db.Books).filter(common_filters())\ - .group_by('books_authors_link.author').order_by(db.Authors.sort).limit(config.config_books_per_page).offset(off) - pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - len(db.session.query(db.Authors).all())) - return render_xml_template('feed.xml', listelements=entries, folder='feed_author', pagination=pagination) - - -@web.route("/opds/author/") -@requires_basic_auth_if_no_ano -def feed_author(book_id): - off = request.args.get("offset") or 0 - entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), - db.Books, db.Books.authors.any(db.Authors.id == book_id), [db.Books.timestamp.desc()]) - return render_xml_template('feed.xml', entries=entries, pagination=pagination) - - -@web.route("/opds/publisher") -@requires_basic_auth_if_no_ano -def feed_publisherindex(): - off = request.args.get("offset") or 0 - entries = db.session.query(db.Publishers).join(db.books_publishers_link).join(db.Books).filter(common_filters())\ - .group_by('books_publishers_link.publisher').order_by(db.Publishers.sort).limit(config.config_books_per_page).offset(off) - pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - len(db.session.query(db.Publishers).all())) - return render_xml_template('feed.xml', listelements=entries, folder='feed_publisher', pagination=pagination) - - -@web.route("/opds/publisher/") -@requires_basic_auth_if_no_ano -def feed_publisher(book_id): - off = request.args.get("offset") or 0 - entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), - db.Books, db.Books.publishers.any(db.Publishers.id == book_id), - [db.Books.timestamp.desc()]) - return render_xml_template('feed.xml', entries=entries, pagination=pagination) - - -@web.route("/opds/category") -@requires_basic_auth_if_no_ano -def feed_categoryindex(): - off = request.args.get("offset") or 0 - entries = db.session.query(db.Tags).join(db.books_tags_link).join(db.Books).filter(common_filters())\ - .group_by('books_tags_link.tag').order_by(db.Tags.name).offset(off).limit(config.config_books_per_page) - pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - len(db.session.query(db.Tags).all())) - return render_xml_template('feed.xml', listelements=entries, folder='feed_category', pagination=pagination) - - -@web.route("/opds/category/") -@requires_basic_auth_if_no_ano -def feed_category(book_id): - off = request.args.get("offset") or 0 - entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), - db.Books, db.Books.tags.any(db.Tags.id == book_id), [db.Books.timestamp.desc()]) - return render_xml_template('feed.xml', entries=entries, pagination=pagination) - - -@web.route("/opds/series") -@requires_basic_auth_if_no_ano -def feed_seriesindex(): - off = request.args.get("offset") or 0 - entries = db.session.query(db.Series).join(db.books_series_link).join(db.Books).filter(common_filters())\ - .group_by('books_series_link.series').order_by(db.Series.sort).offset(off).all() - pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - len(db.session.query(db.Series).all())) - return render_xml_template('feed.xml', listelements=entries, folder='feed_series', pagination=pagination) - - -@web.route("/opds/series/") -@requires_basic_auth_if_no_ano -def feed_series(book_id): - off = request.args.get("offset") or 0 - entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), - db.Books, db.Books.series.any(db.Series.id == book_id), [db.Books.series_index]) - return render_xml_template('feed.xml', entries=entries, pagination=pagination) - - -@web.route("/opds/shelfindex/", defaults={'public': 0}) -@web.route("/opds/shelfindex/") -@requires_basic_auth_if_no_ano -def feed_shelfindex(public): - off = request.args.get("offset") or 0 - if public is not 0: - shelf = g.public_shelfes - number = len(shelf) - else: - shelf = g.user.shelf - number = shelf.count() - pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - number) - return render_xml_template('feed.xml', listelements=shelf, folder='feed_shelf', pagination=pagination) - - -@web.route("/opds/shelf/") -@requires_basic_auth_if_no_ano -def feed_shelf(book_id): - off = request.args.get("offset") or 0 - if current_user.is_anonymous: - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == book_id).first() - else: - shelf = ub.session.query(ub.Shelf).filter(ub.or_(ub.and_(ub.Shelf.user_id == int(current_user.id), - ub.Shelf.id == book_id), - ub.and_(ub.Shelf.is_public == 1, - ub.Shelf.id == book_id))).first() - result = list() - # user is allowed to access shelf - if shelf: - books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == book_id).order_by( - ub.BookShelf.order.asc()).all() - for book in books_in_shelf: - cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() - result.append(cur_book) - pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - len(result)) - return render_xml_template('feed.xml', entries=result, pagination=pagination) - - -@web.route("/opds/download///") -@requires_basic_auth_if_no_ano -@download_required -def get_opds_download_link(book_id, book_format): - book_format = book_format.split(".")[0] - book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == book_format.upper()).first() - app.logger.info(data.name) - if current_user.is_authenticated: - ub.update_download(book_id, int(current_user.id)) - 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) - headers = Headers() - headers["Content-Disposition"] = "attachment; filename*=UTF-8''%s.%s" % (quote(file_name.encode('utf8')), - book_format) - try: - headers["Content-Type"] = mimetypes.types_map['.' + book_format] - except KeyError: - headers["Content-Type"] = "application/octet-stream" - return helper.do_download_file(book, book_format, data, headers) - - -@web.route("/ajax/book/") -@requires_basic_auth_if_no_ano -def get_metadata_calibre_companion(uuid): - entry = db.session.query(db.Books).filter(db.Books.uuid.like("%" + uuid + "%")).first() - if entry is not None: - js = render_template('json.txt', entry=entry) - response = make_response(js) - response.headers["Content-Type"] = "application/json; charset=utf-8" - return response - else: - return "" - @web.route("/ajax/emailstat") @login_required def get_email_status_json(): - tasks=helper.global_WorkerThread.get_taskstatus() + tasks = global_WorkerThread.get_taskstatus() answer = helper.render_task_status(tasks) - js=json.dumps(answer, default=helper.json_serial) + js = json.dumps(answer, default=helper.json_serial) response = make_response(js) response.headers["Content-Type"] = "application/json; charset=utf-8" return response @@ -928,7 +363,7 @@ def get_email_status_json(): # example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name; # from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/ def check_valid_domain(domain_text): - domain_text = domain_text.split('@',1)[-1].lower() + domain_text = domain_text.split('@', 1)[-1].lower() sql = "SELECT * FROM registration WHERE :domain LIKE domain;" result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all() return len(result) @@ -938,14 +373,14 @@ def check_valid_domain(domain_text): @login_required @admin_required def edit_domain(): - ''' POST /post - name: 'username', //name of field (column in db) - pk: 1 //primary key (record id) - value: 'superuser!' //new value''' + # POST /post + # name: 'username', //name of field (column in db) + # pk: 1 //primary key (record id) + # value: 'superuser!' //new value vals = request.form.to_dict() answer = ub.session.query(ub.Registration).filter(ub.Registration.id == vals['pk']).first() # domain_name = request.args.get('domain') - answer.domain = vals['value'].replace('*','%').replace('?','_').lower() + answer.domain = vals['value'].replace('*', '%').replace('?', '_').lower() ub.session.commit() return "" @@ -954,7 +389,7 @@ def edit_domain(): @login_required @admin_required def add_domain(): - domain_name = request.form.to_dict()['domainname'].replace('*','%').replace('?','_').lower() + domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower() check = ub.session.query(ub.Registration).filter(ub.Registration.domain == domain_name).first() if not check: new_domain = ub.Registration(domain=domain_name) @@ -967,7 +402,7 @@ def add_domain(): @login_required @admin_required def delete_domain(): - domain_id = request.form.to_dict()['domainid'].replace('*','%').replace('?','_').lower() + domain_id = request.form.to_dict()['domainid'].replace('*', '%').replace('?', '_').lower() ub.session.query(ub.Registration).filter(ub.Registration.id == domain_id).delete() ub.session.commit() # If last domain was deleted, add all domains by default @@ -983,9 +418,9 @@ def delete_domain(): @admin_required def list_domain(): answer = ub.session.query(ub.Registration).all() - json_dumps = json.dumps([{"domain":r.domain.replace('%','*').replace('_','?'),"id":r.id} for r in answer]) - js=json.dumps(json_dumps.replace('"', "'")).lstrip('"').strip('"') - response = make_response(js.replace("'",'"')) + json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer]) + js = json.dumps(json_dumps.replace('"', "'")).lstrip('"').strip('"') + response = make_response(js.replace("'", '"')) response.headers["Content-Type"] = "application/json; charset=utf-8" return response @@ -1048,7 +483,7 @@ def get_authors_json(): if request.method == "GET": query = request.args.get('q') entries = db.session.query(db.Authors).filter(db.Authors.name.ilike("%" + query + "%")).all() - json_dumps = json.dumps([dict(name=r.name.replace('|',',')) for r in entries]) + json_dumps = json.dumps([dict(name=r.name.replace('|', ',')) for r in entries]) return json_dumps @@ -1058,7 +493,7 @@ def get_publishers_json(): if request.method == "GET": query = request.args.get('q') entries = db.session.query(db.Publishers).filter(db.Publishers.name.ilike("%" + query + "%")).all() - json_dumps = json.dumps([dict(name=r.name.replace('|',',')) for r in entries]) + json_dumps = json.dumps([dict(name=r.name.replace('|', ',')) for r in entries]) return json_dumps @@ -1081,7 +516,7 @@ def get_languages_json(): languages = language_table[get_locale()] entries_start = [s for key, s in languages.items() if s.lower().startswith(query.lower())] if len(entries_start) < 5: - entries = [s for key,s in languages.items() if query in s.lower()] + entries = [s for key, s in languages.items() if query in s.lower()] entries_start.extend(entries[0:(5-len(entries_start))]) entries_start = list(set(entries_start)) json_dumps = json.dumps([dict(name=r) for r in entries_start[0:5]]) @@ -1125,48 +560,6 @@ def get_matching_tags(): return json_dumps -@web.route("/get_update_status", methods=['GET']) -@login_required_if_no_ano -def get_update_status(): - return updater_thread.get_available_updates(request.method) - - -@web.route("/get_updater_status", methods=['GET', 'POST']) -@login_required -@admin_required -def get_updater_status(): - status = {} - if request.method == "POST": - commit = request.form.to_dict() - if "start" in commit and commit['start'] == 'True': - text = { - "1": _(u'Requesting update package'), - "2": _(u'Downloading update package'), - "3": _(u'Unzipping update package'), - "4": _(u'Replacing files'), - "5": _(u'Database connections are closed'), - "6": _(u'Stopping server'), - "7": _(u'Update finished, please press okay and reload page'), - "8": _(u'Update failed:') + u' ' + _(u'HTTP Error'), - "9": _(u'Update failed:') + u' ' + _(u'Connection error'), - "10": _(u'Update failed:') + u' ' + _(u'Timeout while establishing connection'), - "11": _(u'Update failed:') + u' ' + _(u'General error') - } - status['text'] = text - # helper.updater_thread = helper.Updater() - updater_thread.start() - status['status'] = updater_thread.get_update_status() - elif request.method == "GET": - try: - status['status'] = updater_thread.get_update_status() - except AttributeError: - # thread is not active, occours after restart on update - status['status'] = 7 - except Exception: - status['status'] = 11 - return json.dumps(status) - - @web.route("/", defaults={'page': 1}) @web.route('/page/') @login_required_if_no_ano @@ -1249,7 +642,7 @@ def hot_books(page): return render_title_template('index.html', random=random, entries=entries, pagination=pagination, title=_(u"Hot Books (most downloaded)"), page="hot") else: - abort(404) + abort(404) @web.route("/rated", defaults={'page': 1}) @@ -1287,7 +680,7 @@ def author_list(): .group_by('books_authors_link.author').order_by(db.Authors.sort).all() for entry in entries: entry.Authors.name = entry.Authors.name.replace('|', ',') - return render_title_template('list.html', entries=entries, folder='author', + return render_title_template('list.html', entries=entries, folder='web.author', title=u"Author list", page="authorlist") else: abort(404) @@ -1298,12 +691,12 @@ def author_list(): @login_required_if_no_ano def author(book_id, page): entries, __, pagination = fill_indexpage(page, db.Books, db.Books.authors.any(db.Authors.id == book_id), - [db.Series.name, db.Books.series_index],db.books_series_link, db.Series) + [db.Series.name, db.Books.series_index], db.books_series_link, db.Series) if entries is None: flash(_(u"Error opening eBook. File does not exist or file is not accessible:"), category="error") return redirect(url_for("web.index")) - name = (db.session.query(db.Authors).filter(db.Authors.id == book_id).first().name).replace('|', ',') + name = db.session.query(db.Authors).filter(db.Authors.id == book_id).first().name.replace('|', ',') author_info = None other_books = [] @@ -1327,7 +720,7 @@ def publisher_list(): entries = db.session.query(db.Publishers, func.count('books_publishers_link.book').label('count'))\ .join(db.books_publishers_link).join(db.Books).filter(common_filters())\ .group_by('books_publishers_link.publisher').order_by(db.Publishers.sort).all() - return render_title_template('list.html', entries=entries, folder='publisher', + return render_title_template('list.html', entries=entries, folder='web.publisher', title=_(u"Publisher list"), page="publisherlist") else: abort(404) @@ -1340,10 +733,11 @@ def publisher(book_id, page): publisher = db.session.query(db.Publishers).filter(db.Publishers.id == book_id).first() if publisher: entries, random, pagination = fill_indexpage(page, db.Books, - db.Books.publishers.any(db.Publishers.id == book_id), - (db.Series.name, db.Books.series_index), db.books_series_link, db.Series) + db.Books.publishers.any(db.Publishers.id == book_id), + (db.Series.name, db.Books.series_index), db.books_series_link, + db.Series) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher") + title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher") else: abort(404) @@ -1352,15 +746,18 @@ def get_unique_other_books(library_books, author_books): # Get all identifiers (ISBN, Goodreads, etc) and filter author's books by that list so we show fewer duplicates # Note: Not all images will be shown, even though they're available on Goodreads.com. # See https://www.goodreads.com/topic/show/18213769-goodreads-book-images - identifiers = reduce(lambda acc, book: acc + map(lambda identifier: identifier.val, book.identifiers), library_books, []) - other_books = filter(lambda book: book.isbn not in identifiers and book.gid["#text"] not in identifiers, author_books) + identifiers = reduce(lambda acc, book: acc + map(lambda identifier: identifier.val, book.identifiers), + library_books, []) + other_books = filter(lambda book: book.isbn not in identifiers and book.gid["#text"] not in identifiers, + author_books) # Fuzzy match book titles if levenshtein_support: library_titles = reduce(lambda acc, book: acc + [book.title], library_books, []) other_books = filter(lambda author_book: not filter( lambda library_book: - Levenshtein.ratio(re.sub(r"\(.*\)", "", author_book.title), library_book) > 0.7, # Remove items in parentheses before comparing + # Remove items in parentheses before comparing + Levenshtein.ratio(re.sub(r"\(.*\)", "", author_book.title), library_book) > 0.7, library_titles ), other_books) @@ -1374,7 +771,7 @@ def series_list(): entries = db.session.query(db.Series, func.count('books_series_link.book').label('count'))\ .join(db.books_series_link).join(db.Books).filter(common_filters())\ .group_by('books_series_link.series').order_by(db.Series.sort).all() - return render_title_template('list.html', entries=entries, folder='series', + return render_title_template('list.html', entries=entries, folder='web.series', title=_(u"Series list"), page="serieslist") else: abort(404) @@ -1387,7 +784,7 @@ def series(book_id, page): name = db.session.query(db.Series).filter(db.Series.id == book_id).first() if name: entries, random, pagination = fill_indexpage(page, db.Books, db.Books.series.any(db.Series.id == book_id), - [db.Books.series_index]) + [db.Books.series_index]) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, title=_(u"Series: %(serie)s", serie=name.name), page="series") else: @@ -1445,7 +842,7 @@ def category_list(): entries = db.session.query(db.Tags, func.count('books_tags_link.book').label('count'))\ .join(db.books_tags_link).join(db.Books).order_by(db.Tags.name).filter(common_filters())\ .group_by('books_tags_link.tag').all() - return render_title_template('list.html', entries=entries, folder='category', + return render_title_template('list.html', entries=entries, folder='web.category', title=_(u"Category list"), page="catlist") else: abort(404) @@ -1458,7 +855,8 @@ def category(book_id, page): name = db.session.query(db.Tags).filter(db.Tags.id == book_id).first() if name: entries, random, pagination = fill_indexpage(page, db.Books, db.Books.tags.any(db.Tags.id == book_id), - (db.Series.name, db.Books.series_index),db.books_series_link,db.Series) + (db.Series.name, db.Books.series_index),db.books_series_link, + db.Series) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, title=_(u"Category: %(name)s", name=name.name), page="category") else: @@ -1499,6 +897,7 @@ def toggle_read(book_id): u"Custom Column No.%d is not exisiting in calibre database" % config.config_read_column) return "" + @web.route("/book/") @login_required_if_no_ano def show_book(book_id): @@ -1528,13 +927,12 @@ def show_book(book_id): if not current_user.is_anonymous: if not config.config_read_column: - matching_have_read_book = ub.session.query(ub.ReadBook)\ - .filter(ub.and_(ub.ReadBook.user_id == int(current_user.id), - ub.ReadBook.book_id == book_id)).all() + matching_have_read_book = ub.session.query(ub.ReadBook).\ + filter(ub.and_(ub.ReadBook.user_id == int(current_user.id), ub.ReadBook.book_id == book_id)).all() have_read = len(matching_have_read_book) > 0 and matching_have_read_book[0].is_read else: try: - matching_have_read_book = getattr(entries,'custom_column_'+str(config.config_read_column)) + matching_have_read_book = getattr(entries, 'custom_column_'+str(config.config_read_column)) have_read = len(matching_have_read_book) > 0 and matching_have_read_book[0].value except KeyError: app.logger.error( @@ -1544,7 +942,7 @@ def show_book(book_id): else: have_read = None - entries.tags = sort(entries.tags, key = lambda tag: tag.name) + entries.tags = sort(entries.tags, key=lambda tag: tag.name) entries = order_authors(entries) @@ -1556,8 +954,8 @@ def show_book(book_id): if media_format.format.lower() in EXTENSIONS_AUDIO: audioentries.append(media_format.format.lower()) - return render_title_template('detail.html', entry=entries, audioentries=audioentries, cc=cc, is_xhr=request.is_xhr, - title=entries.title, books_shelfs=book_in_shelfs, + return render_title_template('detail.html', entry=entries, audioentries=audioentries, cc=cc, + is_xhr=request.is_xhr, title=entries.title, books_shelfs=book_in_shelfs, have_read=have_read, kindle_list=kindle_list, reader_list=reader_list, page="book") else: flash(_(u"Error opening eBook. File does not exist or file is not accessible:"), category="error") @@ -1576,9 +974,9 @@ def bookmark(book_id, book_format): return "", 204 lbookmark = ub.Bookmark(user_id=current_user.id, - book_id=book_id, - format=book_format, - bookmark_key=bookmark_key) + book_id=book_id, + format=book_format, + bookmark_key=bookmark_key) ub.session.merge(lbookmark) ub.session.commit() return "", 201 @@ -1588,254 +986,13 @@ def bookmark(book_id, book_format): @login_required def get_tasks_status(): # if current user admin, show all email, otherwise only own emails - answer=list() - # UIanswer=list() - tasks=helper.global_WorkerThread.get_taskstatus() - # answer = tasks - + tasks = global_WorkerThread.get_taskstatus() # UIanswer = copy.deepcopy(answer) answer = helper.render_task_status(tasks) # foreach row format row return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks") -@web.route("/admin") -@login_required -def admin_forbidden(): - abort(403) - - -@web.route("/stats") -@login_required -def stats(): - counter = db.session.query(db.Books).count() - authors = db.session.query(db.Authors).count() - categorys = db.session.query(db.Tags).count() - series = db.session.query(db.Series).count() - versions = uploader.book_formats.get_versions() - versions['Babel'] = 'v' + babelVersion - versions['Sqlalchemy'] = 'v' + sqlalchemyVersion - versions['Werkzeug'] = 'v' + werkzeugVersion - versions['Jinja2'] = 'v' + jinja2Version - versions['Flask'] = 'v' + flaskVersion - versions['Flask Login'] = 'v' + flask_loginVersion - versions['Flask Principal'] = 'v' + flask_principalVersion - versions['Iso 639'] = 'v' + iso639Version - versions['pytz'] = 'v' + pytzVersion - - versions['Requests'] = 'v' + requests.__version__ - versions['pySqlite'] = 'v' + db.engine.dialect.dbapi.version - versions['Sqlite'] = 'v' + db.engine.dialect.dbapi.sqlite_version - versions.update(converter.versioncheck()) - versions.update(server.Server.getNameVersion()) - versions['Python'] = sys.version - return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=versions, - categorycounter=categorys, seriecounter=series, title=_(u"Statistics"), page="stat") - - -@web.route("/delete//", defaults={'book_format': ""}) -@web.route("/delete///") -@login_required -def delete_book(book_id, book_format): - if current_user.role_delete_books(): - book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - if book: - helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper()) - if not book_format: - # delete book from Shelfs, Downloads, Read list - ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book_id).delete() - ub.session.query(ub.ReadBook).filter(ub.ReadBook.book_id == book_id).delete() - ub.delete_download(book_id) - ub.session.commit() - - # check if only this book links to: - # author, language, series, tags, custom columns - modify_database_object([u''], book.authors, db.Authors, db.session, 'author') - modify_database_object([u''], book.tags, db.Tags, db.session, 'tags') - modify_database_object([u''], book.series, db.Series, db.session, 'series') - modify_database_object([u''], book.languages, db.Languages, db.session, 'languages') - modify_database_object([u''], book.publishers, db.Publishers, db.session, 'publishers') - - cc = db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all() - for c in cc: - cc_string = "custom_column_" + str(c.id) - if not c.is_multiple: - if len(getattr(book, cc_string)) > 0: - if c.datatype == 'bool' or c.datatype == 'integer': - del_cc = getattr(book, cc_string)[0] - getattr(book, cc_string).remove(del_cc) - db.session.delete(del_cc) - elif c.datatype == 'rating': - del_cc = getattr(book, cc_string)[0] - getattr(book, cc_string).remove(del_cc) - if len(del_cc.books) == 0: - db.session.delete(del_cc) - else: - del_cc = getattr(book, cc_string)[0] - getattr(book, cc_string).remove(del_cc) - db.session.delete(del_cc) - else: - modify_database_object([u''], getattr(book, cc_string), db.cc_classes[c.id], - db.session, 'custom') - db.session.query(db.Books).filter(db.Books.id == book_id).delete() - else: - db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == book_format).delete() - db.session.commit() - else: - # book not found - app.logger.info('Book with id "'+str(book_id)+'" could not be deleted') - if book_format: - return redirect(url_for('edit_book', book_id=book_id)) - else: - return redirect(url_for('index')) - - - -@web.route("/gdrive/authenticate") -@login_required -@admin_required -def authenticate_google_drive(): - try: - authUrl = gdriveutils.Gauth.Instance().auth.GetAuthUrl() - except gdriveutils.InvalidConfigError: - flash(_(u'Google Drive setup not completed, try to deactivate and activate Google Drive again'), - category="error") - return redirect(url_for('index')) - return redirect(authUrl) - - -@web.route("/gdrive/callback") -def google_drive_callback(): - auth_code = request.args.get('code') - if not auth_code: - abort(403) - try: - credentials = gdriveutils.Gauth.Instance().auth.flow.step2_exchange(auth_code) - with open(os.path.join(config.get_main_dir,'gdrive_credentials'), 'w') as f: - f.write(credentials.to_json()) - except ValueError as error: - app.logger.error(error) - return redirect(url_for('configuration')) - - -@web.route("/gdrive/watch/subscribe") -@login_required -@admin_required -def watch_gdrive(): - if not config.config_google_drive_watch_changes_response: - with open(os.path.join(config.get_main_dir,'client_secrets.json'), 'r') as settings: - filedata = json.load(settings) - if filedata['web']['redirect_uris'][0].endswith('/'): - filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-((len('/gdrive/callback')+1))] - else: - filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-(len('/gdrive/callback'))] - address = '%s/gdrive/watch/callback' % filedata['web']['redirect_uris'][0] - notification_id = str(uuid4()) - try: - result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id, - 'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000) - 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() - except HttpError as e: - reason=json.loads(e.content)['error']['errors'][0] - if reason['reason'] == u'push.webhookUrlUnauthorized': - flash(_(u'Callback domain is not verified, please follow steps to verify domain in google developer console'), category="error") - else: - flash(reason['message'], category="error") - - return redirect(url_for('configuration')) - - -@web.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: - try: - gdriveutils.stopChannel(gdriveutils.Gdrive.Instance().drive, last_watch_response['id'], - last_watch_response['resourceId']) - except HttpError: - pass - 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')) - - -@web.route("/gdrive/watch/callback", methods=['GET', 'POST']) -def on_received_watch_confirmation(): - app.logger.debug(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.debug(data) - try: - j = json.loads(data) - app.logger.info('Getting change details') - response = gdriveutils.getChangeById(gdriveutils.Gdrive.Instance().drive, j['id']) - app.logger.debug(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'] != hashlib.md5(dbpath): - tmpDir = tempfile.gettempdir() - app.logger.info('Database file updated') - copyfile(dbpath, os.path.join(tmpDir, "metadata.db_" + str(current_milli_time()))) - app.logger.info('Backing up existing and downloading updated metadata.db') - gdriveutils.downloadFile(None, "metadata.db", os.path.join(tmpDir, "tmp_metadata.db")) - app.logger.info('Setting up new DB') - # prevent error on windows, as os.rename does on exisiting files - move(os.path.join(tmpDir, "tmp_metadata.db"), dbpath) - db.setup_db() - except Exception as e: - app.logger.info(e.message) - app.logger.exception(e) - updateMetaData() - return '' - - -@web.route("/shutdown") -@login_required -@admin_required -def shutdown(): - task = int(request.args.get("parameter").strip()) - if task == 1 or task == 0: # valid commandos received - # close all database connections - db.session.close() - db.engine.dispose() - ub.session.close() - ub.engine.dispose() - - showtext = {} - if task == 0: - showtext['text'] = _(u'Server restarted, please reload page') - Server.setRestartTyp(True) - else: - showtext['text'] = _(u'Performing shutdown of server, please close window') - Server.setRestartTyp(False) - # stop gevent/tornado server - Server.stopServer() - return json.dumps(showtext) - else: - if task == 2: - db.session.close() - db.engine.dispose() - db.setup_db() - return json.dumps({}) - abort(404) - - @web.route("/search", methods=["GET"]) @login_required_if_no_ano def search(): @@ -1845,7 +1002,7 @@ def search(): ids = list() for element in entries: ids.append(element.id) - ub.searched_ids[current_user.id] = ids + searched_ids[current_user.id] = ids return render_title_template('search.html', searchterm=term, entries=entries, page="search") else: return render_title_template('search.html', searchterm="", page="search") @@ -1884,9 +1041,12 @@ def advanced_search(): rating_low = request.args.get("ratinghigh") rating_high = request.args.get("ratinglow") description = request.args.get("comment") - if author_name: author_name = author_name.strip().lower().replace(',','|') - if book_title: book_title = book_title.strip().lower() - if publisher: publisher = publisher.strip().lower() + if author_name: + author_name = author_name.strip().lower().replace(',','|') + if book_title: + book_title = book_title.strip().lower() + if publisher: + publisher = publisher.strip().lower() searchterm = [] cc_present = False @@ -1899,19 +1059,19 @@ def advanced_search(): include_languages_inputs or exclude_languages_inputs or author_name or book_title or \ publisher or pub_start or pub_end or rating_low or rating_high or description or cc_present: searchterm = [] - searchterm.extend((author_name.replace('|',','), book_title, publisher)) + searchterm.extend((author_name.replace('|', ','), book_title, publisher)) if pub_start: try: searchterm.extend([_(u"Published after ") + - format_date(datetime.datetime.strptime(pub_start,"%Y-%m-%d"), - format='medium', locale=get_locale())]) + format_date(datetime.datetime.strptime(pub_start,"%Y-%m-%d"), + format='medium', locale=get_locale())]) except ValueError: pub_start = u"" if pub_end: try: searchterm.extend([_(u"Published before ") + - format_date(datetime.datetime.strptime(pub_end,"%Y-%m-%d"), - format='medium', locale=get_locale())]) + format_date(datetime.datetime.strptime(pub_end,"%Y-%m-%d"), + format='medium', locale=get_locale())]) except ValueError: pub_start = u"" tag_names = db.session.query(db.Tags).filter(db.Tags.id.in_(include_tag_inputs)).all() @@ -1961,7 +1121,7 @@ def advanced_search(): rating_high = int(rating_high) * 2 q = q.filter(db.Books.ratings.any(db.Ratings.rating <= rating_high)) if rating_low: - rating_low = int(rating_low) *2 + rating_low = int(rating_low) * 2 q = q.filter(db.Books.ratings.any(db.Ratings.rating >= rating_low)) if description: q = q.filter(db.Books.comments.any(db.Comments.text.ilike("%" + description + "%"))) @@ -1973,10 +1133,10 @@ def advanced_search(): if c.datatype == 'bool': getattr(db.Books, 'custom_column_1') q = q.filter(getattr(db.Books, 'custom_column_'+str(c.id)).any( - db.cc_classes[c.id].value == (custom_query== "True") )) + db.cc_classes[c.id].value == (custom_query == "True"))) elif c.datatype == 'int': q = q.filter(getattr(db.Books, 'custom_column_'+str(c.id)).any( - db.cc_classes[c.id].value == custom_query )) + db.cc_classes[c.id].value == custom_query)) else: q = q.filter(getattr(db.Books, 'custom_column_'+str(c.id)).any( db.cc_classes[c.id].value.ilike("%" + custom_query + "%"))) @@ -1984,7 +1144,7 @@ def advanced_search(): ids = list() for element in q: ids.append(element.id) - ub.searched_ids[current_user.id] = ids + searched_ids[current_user.id] = ids return render_title_template('search.html', searchterm=searchterm, entries=q, title=_(u"search"), page="search") # prepare data for search-form @@ -2010,7 +1170,8 @@ def get_cover(book_id): def serve_book(book_id, book_format): book_format = book_format.split(".")[0] book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == book_format.upper()).first() + data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == book_format.upper())\ + .first() app.logger.info('Serving book: %s', data.name) if config.config_use_google_drive: headers = Headers() @@ -2024,25 +1185,29 @@ def serve_book(book_id, book_format): return send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + book_format) -@web.route("/opds/thumb_240_240/") -@web.route("/opds/cover_240_240/") -@web.route("/opds/cover_90_90/") -@web.route("/opds/cover/") -@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 helper.get_book_cover(book.path) +@web.route("/unreadbooks/", defaults={'page': 1}) +@web.route("/unreadbooks/'") +@login_required_if_no_ano +def unread_books(page): + return render_read_books(page, False) + + +@web.route("/readbooks/", defaults={'page': 1}) +@web.route("/readbooks/'") +@login_required_if_no_ano +def read_books(page): + return render_read_books(page, True) def render_read_books(page, are_read, as_xml=False): if not config.config_read_column: readBooks = ub.session.query(ub.ReadBook).filter(ub.ReadBook.user_id == int(current_user.id))\ - .filter(ub.ReadBook.is_read == True).all() + .filter(ub.ReadBook.is_read is True).all() readBookIds = [x.book_id for x in readBooks] else: try: readBooks = db.session.query(db.cc_classes[config.config_read_column])\ - .filter(db.cc_classes[config.config_read_column].value==True).all() + .filter(db.cc_classes[config.config_read_column].value is True).all() readBookIds = [x.book for x in readBooks] except KeyError: app.logger.error(u"Custom Column No.%d is not existing in calibre database" % config.config_read_column) @@ -2053,8 +1218,7 @@ def render_read_books(page, are_read, as_xml=False): else: db_filter = ~db.Books.id.in_(readBookIds) - entries, random, pagination = fill_indexpage(page, db.Books, - db_filter, [db.Books.timestamp.desc()]) + entries, random, pagination = fill_indexpage(page, db.Books, db_filter, [db.Books.timestamp.desc()]) if as_xml: xml = render_title_template('feed.xml', entries=entries, pagination=pagination) @@ -2068,35 +1232,7 @@ def render_read_books(page, are_read, as_xml=False): total_books = db.session.query(func.count(db.Books.id)).scalar() name = _(u'Unread Books') + ' (' + str(total_books - len(readBookIds)) + ')' return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=_(name, name=name), page="read") - - -@web.route("/opds/readbooks/") -@login_required_if_no_ano -def feed_read_books(): - off = request.args.get("offset") or 0 - return render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True) - - -@web.route("/readbooks/", defaults={'page': 1}) -@web.route("/readbooks/'") -@login_required_if_no_ano -def read_books(page): - return render_read_books(page, True) - - -@web.route("/opds/unreadbooks/") -@login_required_if_no_ano -def feed_unread_books(): - off = request.args.get("offset") or 0 - return render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True) - - -@web.route("/unreadbooks/", defaults={'page': 1}) -@web.route("/unreadbooks/'") -@login_required_if_no_ano -def unread_books(page): - return render_read_books(page, False) + title=_(name, name=name), page="read") @web.route("/read//") @@ -2111,8 +1247,8 @@ def read_book(book_id, book_format): bookmark = None if current_user.is_authenticated: bookmark = ub.session.query(ub.Bookmark).filter(ub.and_(ub.Bookmark.user_id == int(current_user.id), - ub.Bookmark.book_id == book_id, - ub.Bookmark.format == book_format.upper())).first() + ub.Bookmark.book_id == book_id, + ub.Bookmark.format == book_format.upper())).first() if book_format.lower() == "epub": return render_title_template('read.html', bookid=book_id, title=_(u"Read a Book"), bookmark=bookmark) elif book_format.lower() == "pdf": @@ -2122,24 +1258,24 @@ def read_book(book_id, book_format): elif book_format.lower() == "mp3": entries = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first() return render_title_template('listenmp3.html', mp3file=book_id, audioformat=book_format.lower(), - title=_(u"Read a Book"), entry=entries, bookmark=bookmark) + title=_(u"Read a Book"), entry=entries, bookmark=bookmark) elif book_format.lower() == "m4b": entries = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first() return render_title_template('listenmp3.html', mp3file=book_id, audioformat=book_format.lower(), - title=_(u"Read a Book"), entry=entries, bookmark=bookmark) + title=_(u"Read a Book"), entry=entries, bookmark=bookmark) elif book_format.lower() == "m4a": entries = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first() return render_title_template('listenmp3.html', mp3file=book_id, audioformat=book_format.lower(), - title=_(u"Read a Book"), entry=entries, bookmark=bookmark) + title=_(u"Read a Book"), entry=entries, bookmark=bookmark) else: book_dir = os.path.join(config.get_main_dir, "cps", "static", str(book_id)) if not os.path.exists(book_dir): os.mkdir(book_dir) for fileext in ["cbr", "cbt", "cbz"]: if book_format.lower() == fileext: - all_name = str(book_id) # + "/" + book.data[0].name + "." + fileext - #tmp_file = os.path.join(book_dir, book.data[0].name) + "." + fileext - #if not os.path.exists(all_name): + all_name = str(book_id) # + "/" + book.data[0].name + "." + fileext + # tmp_file = os.path.join(book_dir, book.data[0].name) + "." + fileext + # if not os.path.exists(all_name): # cbr_file = os.path.join(config.config_calibre_dir, book.path, book.data[0].name) + "." + fileext # copyfile(cbr_file, tmp_file) return render_title_template('readcbr.html', comicfile=all_name, title=_(u"Read a Book"), @@ -2204,7 +1340,8 @@ def register(): flash(_(u"Please fill out all fields!"), category="error") return render_title_template('register.html', title=_(u"register"), page="register") - existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"].lower()).first() + existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"] + .lower()).first() existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()).first() if not existing_user and not existing_email: content = ub.User() @@ -2220,15 +1357,16 @@ def register(): try: ub.session.add(content) ub.session.commit() - register_user_with_oauth(content) - helper.send_registration_mail(to_save["email"],to_save["nickname"], password) + # register_user_with_oauth(content) + helper.send_registration_mail(to_save["email"], to_save["nickname"], password) except Exception: ub.session.rollback() flash(_(u"An unknown error occurred. Please try again later."), category="error") return render_title_template('register.html', title=_(u"register"), page="register") else: flash(_(u"Your e-mail is not allowed to register"), category="error") - app.logger.info('Registering failed for user "' + to_save['nickname'] + '" e-mail adress: ' + to_save["email"]) + app.logger.info('Registering failed for user "' + to_save['nickname'] + '" e-mail adress: ' + + to_save["email"]) return render_title_template('register.html', title=_(u"register"), page="register") flash(_(u"Confirmation e-mail was send to your e-mail account."), category="success") return redirect(url_for('login')) @@ -2236,7 +1374,7 @@ def register(): flash(_(u"This username or e-mail address is already in use."), category="error") return render_title_template('register.html', title=_(u"register"), page="register") - register_user_with_oauth() + # register_user_with_oauth() return render_title_template('register.html', config=config, title=_(u"register"), page="register") @@ -2245,17 +1383,18 @@ def login(): if not config.db_configured: return redirect(url_for('web.basic_configuration')) if current_user is not None and current_user.is_authenticated: - return redirect(url_for('index')) + return redirect(url_for('web.index')) if request.method == "POST": form = request.form.to_dict() - user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower()).first() + user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower())\ + .first() if config.config_use_ldap and user: import ldap try: ub.User.try_login(form['username'], form['password']) login_user(user, remember=True) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") - return redirect_back(url_for("index")) + return redirect_back(url_for("web.index")) except ldap.INVALID_CREDENTIALS: ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) app.logger.info('LDAP Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) @@ -2387,341 +1526,22 @@ def send_to_kindle(book_id, book_format, convert): return redirect(request.environ["HTTP_REFERER"]) -@web.route("/shelf/add//") -@login_required -def add_to_shelf(shelf_id, book_id): - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() - if shelf is None: - app.logger.info("Invalid shelf specified") - if not request.is_xhr: - flash(_(u"Invalid shelf specified"), category="error") - return redirect(url_for('index')) - return "Invalid shelf specified", 400 - - if not shelf.is_public and not shelf.user_id == int(current_user.id): - app.logger.info("Sorry you are not allowed to add a book to the the shelf: %s" % shelf.name) - if not request.is_xhr: - flash(_(u"Sorry you are not allowed to add a book to the the shelf: %(shelfname)s", shelfname=shelf.name), - category="error") - return redirect(url_for('index')) - return "Sorry you are not allowed to add a book to the the shelf: %s" % shelf.name, 403 - - if shelf.is_public and not current_user.role_edit_shelfs(): - app.logger.info("User is not allowed to edit public shelves") - if not request.is_xhr: - flash(_(u"You are not allowed to edit public shelves"), category="error") - return redirect(url_for('index')) - return "User is not allowed to edit public shelves", 403 - - book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id, - ub.BookShelf.book_id == book_id).first() - if book_in_shelf: - app.logger.info("Book is already part of the shelf: %s" % shelf.name) - if not request.is_xhr: - flash(_(u"Book is already part of the shelf: %(shelfname)s", shelfname=shelf.name), category="error") - return redirect(url_for('index')) - return "Book is already part of the shelf: %s" % shelf.name, 400 - - maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first() - if maxOrder[0] is None: - maxOrder = 0 - else: - maxOrder = maxOrder[0] - - ins = ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1) - ub.session.add(ins) - ub.session.commit() - if not request.is_xhr: - flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success") - if "HTTP_REFERER" in request.environ: - return redirect(request.environ["HTTP_REFERER"]) - else: - return redirect(url_for('index')) - return "", 204 - - -@web.route("/shelf/massadd/") -@login_required -def search_to_shelf(shelf_id): - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() - if shelf is None: - app.logger.info("Invalid shelf specified") - flash(_(u"Invalid shelf specified"), category="error") - return redirect(url_for('index')) - - if not shelf.is_public and not shelf.user_id == int(current_user.id): - app.logger.info("You are not allowed to add a book to the the shelf: %s" % shelf.name) - flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error") - return redirect(url_for('index')) - - if shelf.is_public and not current_user.role_edit_shelfs(): - app.logger.info("User is not allowed to edit public shelves") - flash(_(u"User is not allowed to edit public shelves"), category="error") - return redirect(url_for('index')) - - if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]: - books_for_shelf = list() - books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all() - if books_in_shelf: - book_ids = list() - for book_id in books_in_shelf: - book_ids.append(book_id.book_id) - for id in ub.searched_ids[current_user.id]: - if id not in book_ids: - books_for_shelf.append(id) - else: - books_for_shelf = ub.searched_ids[current_user.id] - - if not books_for_shelf: - app.logger.info("Books are already part of the shelf: %s" % shelf.name) - flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error") - return redirect(url_for('index')) - - maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first() - if maxOrder[0] is None: - maxOrder = 0 - else: - maxOrder = maxOrder[0] - - for book in books_for_shelf: - maxOrder = maxOrder + 1 - ins = ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder) - ub.session.add(ins) - ub.session.commit() - flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success") - else: - flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error") - return redirect(url_for('index')) - - -@web.route("/shelf/remove//") -@login_required -def remove_from_shelf(shelf_id, book_id): - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() - if shelf is None: - app.logger.info("Invalid shelf specified") - if not request.is_xhr: - return redirect(url_for('index')) - return "Invalid shelf specified", 400 - - # if shelf is public and use is allowed to edit shelfs, or if shelf is private and user is owner - # allow editing shelfs - # result shelf public user allowed user owner - # false 1 0 x - # true 1 1 x - # true 0 x 1 - # false 0 x 0 - - if (not shelf.is_public and shelf.user_id == int(current_user.id)) \ - or (shelf.is_public and current_user.role_edit_shelfs()): - book_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id, - ub.BookShelf.book_id == book_id).first() - - if book_shelf is None: - app.logger.info("Book already removed from shelf") - if not request.is_xhr: - return redirect(url_for('index')) - return "Book already removed from shelf", 410 - - ub.session.delete(book_shelf) - ub.session.commit() - - if not request.is_xhr: - flash(_(u"Book has been removed from shelf: %(sname)s", sname=shelf.name), category="success") - return redirect(request.environ["HTTP_REFERER"]) - return "", 204 - else: - app.logger.info("Sorry you are not allowed to remove a book from this shelf: %s" % shelf.name) - if not request.is_xhr: - flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name), - category="error") - return redirect(url_for('index')) - return "Sorry you are not allowed to remove a book from this shelf: %s" % shelf.name, 403 - - - -@web.route("/shelf/create", methods=["GET", "POST"]) +@web.route("/me", methods=["GET", "POST"]) @login_required -def create_shelf(): - shelf = ub.Shelf() - if request.method == "POST": - to_save = request.form.to_dict() - if "is_public" in to_save: - shelf.is_public = 1 - shelf.name = to_save["title"] - shelf.user_id = int(current_user.id) - existing_shelf = ub.session.query(ub.Shelf).filter( - or_((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1), - (ub.Shelf.name == to_save["title"]) & (ub.Shelf.user_id == int(current_user.id)))).first() - if existing_shelf: - flash(_(u"A shelf with the name '%(title)s' already exists.", title=to_save["title"]), category="error") +def profile(): + content = ub.session.query(ub.User).filter(ub.User.id == int(current_user.id)).first() + downloads = list() + languages = speaking_language() + translations = babel.list_translations() + [LC('en')] + oauth_status = None # oauth_status = get_oauth_status() + for book in content.downloads: + downloadBook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() + if downloadBook: + downloads.append(db.session.query(db.Books).filter(db.Books.id == book.book_id).first()) else: - try: - ub.session.add(shelf) - ub.session.commit() - flash(_(u"Shelf %(title)s created", title=to_save["title"]), category="success") - except Exception: - flash(_(u"There was an error"), category="error") - return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"create a shelf"), page="shelfcreate") - else: - return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"create a shelf"), page="shelfcreate") - - -@web.route("/shelf/edit/", methods=["GET", "POST"]) -@login_required -def edit_shelf(shelf_id): - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() - if request.method == "POST": - to_save = request.form.to_dict() - existing_shelf = ub.session.query(ub.Shelf).filter( - or_((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1), - (ub.Shelf.name == to_save["title"]) & (ub.Shelf.user_id == int(current_user.id)))).filter( - ub.Shelf.id != shelf_id).first() - if existing_shelf: - flash(_(u"A shelf with the name '%(title)s' already exists.", title=to_save["title"]), category="error") - else: - shelf.name = to_save["title"] - if "is_public" in to_save: - shelf.is_public = 1 - else: - shelf.is_public = 0 - try: - ub.session.commit() - flash(_(u"Shelf %(title)s changed", title=to_save["title"]), category="success") - except Exception: - flash(_(u"There was an error"), category="error") - return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit") - else: - return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit") - - -@web.route("/shelf/delete/") -@login_required -def delete_shelf(shelf_id): - cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() - deleted = None - if current_user.role_admin(): - deleted = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).delete() - else: - if (not cur_shelf.is_public and cur_shelf.user_id == int(current_user.id)) \ - or (cur_shelf.is_public and current_user.role_edit_shelfs()): - deleted = ub.session.query(ub.Shelf).filter(ub.or_(ub.and_(ub.Shelf.user_id == int(current_user.id), - ub.Shelf.id == shelf_id), - ub.and_(ub.Shelf.is_public == 1, - ub.Shelf.id == shelf_id))).delete() - - if deleted: - ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete() - ub.session.commit() - app.logger.info(_(u"successfully deleted shelf %(name)s", name=cur_shelf.name, category="success")) - return redirect(url_for('index')) - - -@web.route("/shelf/") -@login_required_if_no_ano -def show_shelf(shelf_id): - if current_user.is_anonymous: - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first() - else: - shelf = ub.session.query(ub.Shelf).filter(ub.or_(ub.and_(ub.Shelf.user_id == int(current_user.id), - ub.Shelf.id == shelf_id), - ub.and_(ub.Shelf.is_public == 1, - ub.Shelf.id == shelf_id))).first() - result = list() - # user is allowed to access shelf - if shelf: - books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by( - ub.BookShelf.order.asc()).all() - for book in books_in_shelf: - cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() - if cur_book: - result.append(cur_book) - else: - app.logger.info('Not existing book %s in shelf %s deleted' % (book.book_id, shelf.id)) - ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete() - ub.session.commit() - return render_title_template('shelf.html', entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name), - shelf=shelf, page="shelf") - else: - flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error") - return redirect(url_for("web.index")) - - -@web.route("/shelfdown/") -def show_shelf_down(shelf_id): - if current_user.is_anonymous: - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first() - else: - shelf = ub.session.query(ub.Shelf).filter(ub.or_(ub.and_(ub.Shelf.user_id == int(current_user.id), - ub.Shelf.id == shelf_id), - ub.and_(ub.Shelf.is_public == 1, - ub.Shelf.id == shelf_id))).first() - result = list() - # user is allowed to access shelf - if shelf: - books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by( - ub.BookShelf.order.asc()).all() - for book in books_in_shelf: - cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() - if cur_book: - result.append(cur_book) - else: - app.logger.info('Not existing book %s in shelf %s deleted' % (book.book_id, shelf.id)) - ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete() - ub.session.commit() - return render_title_template('shelfdown.html', entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name), - shelf=shelf, page="shelf") - else: - flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error") - return redirect(url_for("web.index")) - -@web.route("/shelf/order/", methods=["GET", "POST"]) -@login_required -def order_shelf(shelf_id): - if request.method == "POST": - to_save = request.form.to_dict() - books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by( - ub.BookShelf.order.asc()).all() - counter = 0 - for book in books_in_shelf: - setattr(book, 'order', to_save[str(book.book_id)]) - counter += 1 - ub.session.commit() - if current_user.is_anonymous: - shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first() - else: - shelf = ub.session.query(ub.Shelf).filter(ub.or_(ub.and_(ub.Shelf.user_id == int(current_user.id), - ub.Shelf.id == shelf_id), - ub.and_(ub.Shelf.is_public == 1, - ub.Shelf.id == shelf_id))).first() - result = list() - if shelf: - books_in_shelf2 = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \ - .order_by(ub.BookShelf.order.asc()).all() - for book in books_in_shelf2: - cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() - result.append(cur_book) - return render_title_template('shelf_order.html', entries=result, - title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name), - shelf=shelf, page="shelforder") - - -@web.route("/me", methods=["GET", "POST"]) -@login_required -def profile(): - content = ub.session.query(ub.User).filter(ub.User.id == int(current_user.id)).first() - downloads = list() - languages = speaking_language() - translations = babel.list_translations() + [LC('en')] - oauth_status = None # oauth_status = get_oauth_status() - for book in content.downloads: - downloadBook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() - if downloadBook: - downloads.append(db.session.query(db.Books).filter(db.Books.id == book.book_id).first()) - else: - ub.delete_download(book.book_id) - # ub.session.query(ub.Downloads).filter(book.book_id == ub.Downloads.book_id).delete() - # ub.session.commit() + ub.delete_download(book.book_id) + # ub.session.query(ub.Downloads).filter(book.book_id == ub.Downloads.book_id).delete() + # ub.session.commit() if request.method == "POST": to_save = request.form.to_dict() content.random_books = 0 @@ -2734,7 +1554,7 @@ def profile(): if config.config_public_reg and not check_valid_domain(to_save["email"]): flash(_(u"E-mail is not from valid domain"), category="error") return render_title_template("user_edit.html", content=content, downloads=downloads, - title=_(u"%(name)s's profile", name=current_user.nickname)) + title=_(u"%(name)s's profile", name=current_user.nickname)) content.email = to_save["email"] if "show_random" in to_save and to_save["show_random"] == "on": content.random_books = 1 @@ -2782,1451 +1602,3 @@ def profile(): content=content, downloads=downloads, title=_(u"%(name)s's profile", name=current_user.nickname), page="me", registered_oauth=oauth_check, oauth_status=oauth_status) - -@web.route("/admin/view") -@login_required -@admin_required -def admin(): - version = updater_thread.get_current_version_info() - if version is False: - commit = _(u'Unknown') - else: - if 'datetime' in version: - commit = version['datetime'] - - tz = datetime.timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone) - form_date = datetime.datetime.strptime(commit[:19], "%Y-%m-%dT%H:%M:%S") - if len(commit) > 19: # check if string has timezone - if commit[19] == '+': - form_date -= datetime.timedelta(hours=int(commit[20:22]), minutes=int(commit[23:])) - elif commit[19] == '-': - form_date += datetime.timedelta(hours=int(commit[20:22]), minutes=int(commit[23:])) - commit = format_datetime(form_date - tz, format='short', locale=get_locale()) - else: - commit = version['version'] - - content = ub.session.query(ub.User).all() - settings = ub.session.query(ub.Settings).first() - return render_title_template("admin.html", content=content, email=settings, config=config, commit=commit, - title=_(u"Admin page"), page="admin") - - -@web.route("/admin/config", methods=["GET", "POST"]) -@login_required -@admin_required -def configuration(): - return configuration_helper(0) - - -@web.route("/admin/viewconfig", methods=["GET", "POST"]) -@login_required -@admin_required -def view_configuration(): - reboot_required = False - if request.method == "POST": - to_save = request.form.to_dict() - content = ub.session.query(ub.Settings).first() - if "config_calibre_web_title" in to_save: - content.config_calibre_web_title = to_save["config_calibre_web_title"] - if "config_columns_to_ignore" in to_save: - content.config_columns_to_ignore = to_save["config_columns_to_ignore"] - if "config_read_column" in to_save: - content.config_read_column = int(to_save["config_read_column"]) - if "config_theme" in to_save: - content.config_theme = int(to_save["config_theme"]) - if "config_title_regex" in to_save: - if content.config_title_regex != to_save["config_title_regex"]: - content.config_title_regex = to_save["config_title_regex"] - reboot_required = True - if "config_random_books" in to_save: - content.config_random_books = int(to_save["config_random_books"]) - if "config_books_per_page" in to_save: - content.config_books_per_page = int(to_save["config_books_per_page"]) - # Mature Content configuration - if "config_mature_content_tags" in to_save: - content.config_mature_content_tags = to_save["config_mature_content_tags"].strip() - - # Default user configuration - content.config_default_role = 0 - if "admin_role" in to_save: - content.config_default_role = content.config_default_role + ub.ROLE_ADMIN - if "download_role" in to_save: - content.config_default_role = content.config_default_role + ub.ROLE_DOWNLOAD - if "upload_role" in to_save: - content.config_default_role = content.config_default_role + ub.ROLE_UPLOAD - if "edit_role" in to_save: - content.config_default_role = content.config_default_role + ub.ROLE_EDIT - if "delete_role" in to_save: - content.config_default_role = content.config_default_role + ub.ROLE_DELETE_BOOKS - if "passwd_role" in to_save: - content.config_default_role = content.config_default_role + ub.ROLE_PASSWD - if "passwd_role" in to_save: - content.config_default_role = content.config_default_role + ub.ROLE_EDIT_SHELFS - content.config_default_show = 0 - if "show_detail_random" in to_save: - content.config_default_show = content.config_default_show + ub.DETAIL_RANDOM - if "show_language" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_LANGUAGE - if "show_series" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_SERIES - if "show_category" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_CATEGORY - if "show_hot" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_HOT - if "show_random" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_RANDOM - if "show_author" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_AUTHOR - if "show_publisher" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_PUBLISHER - if "show_best_rated" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_BEST_RATED - if "show_read_and_unread" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_READ_AND_UNREAD - if "show_recent" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_RECENT - if "show_sorted" in to_save: - content.config_default_show = content.config_default_show + ub.SIDEBAR_SORTED - if "show_mature_content" in to_save: - content.config_default_show = content.config_default_show + ub.MATURE_CONTENT - ub.session.commit() - flash(_(u"Calibre-Web configuration updated"), category="success") - config.loadSettings() - before_request() - if reboot_required: - # db.engine.dispose() # ToDo verify correct - # ub.session.close() - # ub.engine.dispose() - # stop Server - server.Server.setRestartTyp(True) - server.Server.stopServer() - app.logger.info('Reboot required, restarting') - readColumn = db.session.query(db.Custom_Columns)\ - .filter(db.and_(db.Custom_Columns.datatype == 'bool',db.Custom_Columns.mark_for_delete == 0)).all() - return render_title_template("config_view_edit.html", content=config, readColumns=readColumn, - title=_(u"UI Configuration"), page="uiconfig") - - - -@web.route("/config", methods=["GET", "POST"]) -@unconfigured -def basic_configuration(): - logout_user() - return configuration_helper(1) - - -def configuration_helper(origin): - reboot_required = False - gdriveError=None - db_change = False - success = False - filedata = None - if gdriveutils.gdrive_support == False: - gdriveError = _('Import of optional Google Drive requirements missing') - else: - if not os.path.isfile(os.path.join(config.get_main_dir,'client_secrets.json')): - gdriveError = _('client_secrets.json is missing or not readable') - else: - with open(os.path.join(config.get_main_dir,'client_secrets.json'), 'r') as settings: - filedata=json.load(settings) - if not 'web' in filedata: - gdriveError = _('client_secrets.json is not configured for web application') - if request.method == "POST": - to_save = request.form.to_dict() - content = ub.session.query(ub.Settings).first() # type: ub.Settings - if "config_calibre_dir" in to_save: - 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 - if not os.path.isfile(os.path.join(config.get_main_dir, 'settings.yaml')): - content.config_use_google_drive = False - if "config_use_google_drive" in to_save and not content.config_use_google_drive and not gdriveError: - if filedata: - if filedata['web']['redirect_uris'][0].endswith('/'): - filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-1] - with open(os.path.join(config.get_main_dir,'settings.yaml'), 'w') as f: - yaml = "client_config_backend: settings\nclient_config_file: %(client_file)s\n" \ - "client_config:\n" \ - " client_id: %(client_id)s\n client_secret: %(client_secret)s\n" \ - " redirect_uri: %(redirect_uri)s\n\nsave_credentials: True\n" \ - "save_credentials_backend: file\nsave_credentials_file: %(credential)s\n\n" \ - "get_refresh_token: True\n\noauth_scope:\n" \ - " - https://www.googleapis.com/auth/drive\n" - f.write(yaml % {'client_file': os.path.join(config.get_main_dir,'client_secrets.json'), - 'client_id': filedata['web']['client_id'], - 'client_secret': filedata['web']['client_secret'], - 'redirect_uri': filedata['web']['redirect_uris'][0], - 'credential': os.path.join(config.get_main_dir,'gdrive_credentials')}) - else: - flash(_(u'client_secrets.json is not configured for web application'), category="error") - return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdriveutils.gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, title=_(u"Basic Configuration"), - page="config") - # always show google drive settings, but in case of error deny support - if "config_use_google_drive" in to_save and not gdriveError: - content.config_use_google_drive = "config_use_google_drive" in to_save - else: - content.config_use_google_drive = 0 - 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"] - gdriveutils.deleteDatabaseOnChange() - - if "config_port" in to_save: - if content.config_port != int(to_save["config_port"]): - content.config_port = int(to_save["config_port"]) - reboot_required = True - if "config_keyfile" in to_save: - if content.config_keyfile != to_save["config_keyfile"]: - if os.path.isfile(to_save["config_keyfile"]) or to_save["config_keyfile"] is u"": - content.config_keyfile = to_save["config_keyfile"] - reboot_required = True - else: - ub.session.commit() - flash(_(u'Keyfile location is not valid, please enter correct path'), category="error") - return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdriveutils.gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, title=_(u"Basic Configuration"), - page="config") - if "config_certfile" in to_save: - if content.config_certfile != to_save["config_certfile"]: - if os.path.isfile(to_save["config_certfile"]) or to_save["config_certfile"] is u"": - content.config_certfile = to_save["config_certfile"] - reboot_required = True - else: - ub.session.commit() - flash(_(u'Certfile location is not valid, please enter correct path'), category="error") - return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdriveutils.gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, title=_(u"Basic Configuration"), - page="config") - content.config_uploading = 0 - content.config_anonbrowse = 0 - content.config_public_reg = 0 - if "config_uploading" in to_save and to_save["config_uploading"] == "on": - content.config_uploading = 1 - if "config_anonbrowse" in to_save and to_save["config_anonbrowse"] == "on": - content.config_anonbrowse = 1 - if "config_public_reg" in to_save and to_save["config_public_reg"] == "on": - content.config_public_reg = 1 - - if "config_converterpath" in to_save: - content.config_converterpath = to_save["config_converterpath"].strip() - if "config_calibre" in to_save: - content.config_calibre = to_save["config_calibre"].strip() - if "config_ebookconverter" in to_save: - content.config_ebookconverter = int(to_save["config_ebookconverter"]) - - #LDAP configuratop, - if "config_use_ldap" in to_save and to_save["config_use_ldap"] == "on": - if not "config_ldap_provider_url" in to_save or not "config_ldap_dn" in to_save: - ub.session.commit() - flash(_(u'Please enter a LDAP provider and a DN'), category="error") - return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdriveutils.gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, title=_(u"Basic Configuration"), - page="config") - else: - content.config_use_ldap = 1 - content.config_ldap_provider_url = to_save["config_ldap_provider_url"] - content.config_ldap_dn = to_save["config_ldap_dn"] - db_change = True - - # Remote login configuration - content.config_remote_login = ("config_remote_login" in to_save and to_save["config_remote_login"] == "on") - if not content.config_remote_login: - ub.session.query(ub.RemoteAuthToken).delete() - - # Goodreads configuration - content.config_use_goodreads = ("config_use_goodreads" in to_save and to_save["config_use_goodreads"] == "on") - if "config_goodreads_api_key" in to_save: - content.config_goodreads_api_key = to_save["config_goodreads_api_key"] - if "config_goodreads_api_secret" in to_save: - content.config_goodreads_api_secret = to_save["config_goodreads_api_secret"] - if "config_updater" in to_save: - content.config_updatechannel = int(to_save["config_updater"]) - - # GitHub OAuth configuration - content.config_use_github_oauth = ("config_use_github_oauth" in to_save and to_save["config_use_github_oauth"] == "on") - if "config_github_oauth_client_id" in to_save: - content.config_github_oauth_client_id = to_save["config_github_oauth_client_id"] - if "config_github_oauth_client_secret" in to_save: - content.config_github_oauth_client_secret = to_save["config_github_oauth_client_secret"] - - if content.config_github_oauth_client_id != config.config_github_oauth_client_id or \ - content.config_github_oauth_client_secret != config.config_github_oauth_client_secret: - reboot_required = True - - # Google OAuth configuration - content.config_use_google_oauth = ("config_use_google_oauth" in to_save and to_save["config_use_google_oauth"] == "on") - if "config_google_oauth_client_id" in to_save: - content.config_google_oauth_client_id = to_save["config_google_oauth_client_id"] - if "config_google_oauth_client_secret" in to_save: - content.config_google_oauth_client_secret = to_save["config_google_oauth_client_secret"] - - if content.config_google_oauth_client_id != config.config_google_oauth_client_id or \ - content.config_google_oauth_client_secret != config.config_google_oauth_client_secret: - reboot_required = True - - if "config_log_level" in to_save: - content.config_log_level = int(to_save["config_log_level"]) - if content.config_logfile != to_save["config_logfile"]: - # check valid path, only path or file - if os.path.dirname(to_save["config_logfile"]): - if os.path.exists(os.path.dirname(to_save["config_logfile"])) and \ - os.path.basename(to_save["config_logfile"]) and not os.path.isdir(to_save["config_logfile"]): - content.config_logfile = to_save["config_logfile"] - else: - ub.session.commit() - flash(_(u'Logfile location is not valid, please enter correct path'), category="error") - return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdriveutils.gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, title=_(u"Basic Configuration"), - page="config") - else: - content.config_logfile = to_save["config_logfile"] - reboot_required = True - - # Rarfile Content configuration - if "config_rarfile_location" in to_save and to_save['config_rarfile_location'] is not u"": - check = helper.check_unrar(to_save["config_rarfile_location"].strip()) - if not check[0] : - content.config_rarfile_location = to_save["config_rarfile_location"].strip() - else: - flash(check[1], category="error") - return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdriveutils.gdrive_support, goodreads=goodreads_support, - rarfile_support=rar_support, title=_(u"Basic Configuration")) - try: - if content.config_use_google_drive and is_gdrive_ready() and not \ - os.path.exists(os.path.join(content.config_calibre_dir, "metadata.db")): - gdriveutils.downloadFile(None, "metadata.db", config.config_calibre_dir + "/metadata.db") - if db_change: - if config.db_configured: - db.session.close() - db.engine.dispose() - ub.session.commit() - flash(_(u"Calibre-Web configuration updated"), category="success") - config.loadSettings() - app.logger.setLevel(config.config_log_level) - logging.getLogger("book_formats").setLevel(config.config_log_level) - except Exception as e: - flash(e, category="error") - return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdriveutils.gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, rarfile_support=rar_support, - title=_(u"Basic Configuration"), page="config") - if db_change: - reload(db) - if not db.setup_db(): - flash(_(u'DB location is not valid, please enter correct path'), category="error") - return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdriveutils.gdrive_support,gdriveError=gdriveError, - goodreads=goodreads_support, rarfile_support=rar_support, - title=_(u"Basic Configuration"), page="config") - if reboot_required: - # stop Server - server.Server.setRestartTyp(True) - server.Server.stopServer() - app.logger.info('Reboot required, restarting') - if origin: - success = True - if is_gdrive_ready() and gdriveutils.gdrive_support == True: # and config.config_use_google_drive == True: - gdrivefolders=gdriveutils.listRootFolders() - else: - gdrivefolders=list() - return render_title_template("config_edit.html", origin=origin, success=success, content=config, - show_authenticate_google_drive=not is_gdrive_ready(), - gdrive=gdriveutils.gdrive_support, gdriveError=gdriveError, - gdrivefolders=gdrivefolders, rarfile_support=rar_support, - goodreads=goodreads_support, title=_(u"Basic Configuration"), page="config") - - -@web.route("/admin/user/new", methods=["GET", "POST"]) -@login_required -@admin_required -def new_user(): - content = ub.User() - languages = speaking_language() - translations = [LC('en')] + babel.list_translations() - if request.method == "POST": - to_save = request.form.to_dict() - content.default_language = to_save["default_language"] - content.mature_content = "show_mature_content" in to_save - if "locale" in to_save: - content.locale = to_save["locale"] - content.sidebar_view = 0 - if "show_random" in to_save: - content.sidebar_view += ub.SIDEBAR_RANDOM - if "show_language" in to_save: - content.sidebar_view += ub.SIDEBAR_LANGUAGE - if "show_series" in to_save: - content.sidebar_view += ub.SIDEBAR_SERIES - if "show_category" in to_save: - content.sidebar_view += ub.SIDEBAR_CATEGORY - if "show_hot" in to_save: - content.sidebar_view += ub.SIDEBAR_HOT - if "show_read_and_unread" in to_save: - content.sidebar_view += ub.SIDEBAR_READ_AND_UNREAD - if "show_best_rated" in to_save: - content.sidebar_view += ub.SIDEBAR_BEST_RATED - if "show_author" in to_save: - content.sidebar_view += ub.SIDEBAR_AUTHOR - if "show_publisher" in to_save: - content.sidebar_view += ub.SIDEBAR_PUBLISHER - if "show_detail_random" in to_save: - content.sidebar_view += ub.DETAIL_RANDOM - if "show_sorted" in to_save: - content.sidebar_view += ub.SIDEBAR_SORTED - if "show_recent" in to_save: - content.sidebar_view += ub.SIDEBAR_RECENT - - content.role = 0 - if "admin_role" in to_save: - content.role = content.role + ub.ROLE_ADMIN - if "download_role" in to_save: - content.role = content.role + ub.ROLE_DOWNLOAD - if "upload_role" in to_save: - content.role = content.role + ub.ROLE_UPLOAD - if "edit_role" in to_save: - content.role = content.role + ub.ROLE_EDIT - if "delete_role" in to_save: - content.role = content.role + ub.ROLE_DELETE_BOOKS - if "passwd_role" in to_save: - content.role = content.role + ub.ROLE_PASSWD - if "edit_shelf_role" in to_save: - content.role = content.role + ub.ROLE_EDIT_SHELFS - if not to_save["nickname"] or not to_save["email"] or not to_save["password"]: - flash(_(u"Please fill out all fields!"), category="error") - return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - title=_(u"Add new user")) - content.password = generate_password_hash(to_save["password"]) - content.nickname = to_save["nickname"] - if config.config_public_reg and not check_valid_domain(to_save["email"]): - flash(_(u"E-mail is not from valid domain"), category="error") - return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - title=_(u"Add new user")) - else: - content.email = to_save["email"] - try: - ub.session.add(content) - ub.session.commit() - flash(_(u"User '%(user)s' created", user=content.nickname), category="success") - return redirect(url_for('admin')) - except IntegrityError: - ub.session.rollback() - flash(_(u"Found an existing account for this e-mail address or nickname."), category="error") - else: - content.role = config.config_default_role - content.sidebar_view = config.config_default_show - content.mature_content = bool(config.config_default_show & ub.MATURE_CONTENT) - return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - languages=languages, title=_(u"Add new user"), page="newuser") - - -@web.route("/admin/mailsettings", methods=["GET", "POST"]) -@login_required -@admin_required -def edit_mailsettings(): - content = ub.session.query(ub.Settings).first() - if request.method == "POST": - to_save = request.form.to_dict() - content.mail_server = to_save["mail_server"] - content.mail_port = int(to_save["mail_port"]) - content.mail_login = to_save["mail_login"] - content.mail_password = to_save["mail_password"] - content.mail_from = to_save["mail_from"] - content.mail_use_ssl = int(to_save["mail_use_ssl"]) - try: - ub.session.commit() - except Exception as e: - flash(e, category="error") - if "test" in to_save and to_save["test"]: - if current_user.kindle_mail: - result = helper.send_test_mail(current_user.kindle_mail, current_user.nickname) - if result is None: - flash(_(u"Test e-mail successfully send to %(kindlemail)s", kindlemail=current_user.kindle_mail), - category="success") - else: - flash(_(u"There was an error sending the Test e-mail: %(res)s", res=result), category="error") - else: - flash(_(u"Please configure your kindle e-mail address first..."), category="error") - else: - flash(_(u"E-mail server settings updated"), category="success") - return render_title_template("email_edit.html", content=content, title=_(u"Edit e-mail server settings"), - page="mailset") - - -@web.route("/admin/user/", methods=["GET", "POST"]) -@login_required -@admin_required -def edit_user(user_id): - content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User - downloads = list() - languages = speaking_language() - translations = babel.list_translations() + [LC('en')] - for book in content.downloads: - downloadbook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() - if downloadbook: - downloads.append(downloadbook) - else: - ub.delete_download(book.book_id) - # ub.session.query(ub.Downloads).filter(book.book_id == ub.Downloads.book_id).delete() - # ub.session.commit() - if request.method == "POST": - to_save = request.form.to_dict() - if "delete" in to_save: - ub.session.query(ub.User).filter(ub.User.id == content.id).delete() - ub.session.commit() - flash(_(u"User '%(nick)s' deleted", nick=content.nickname), category="success") - return redirect(url_for('admin')) - else: - if "password" in to_save and to_save["password"]: - content.password = generate_password_hash(to_save["password"]) - - if "admin_role" in to_save and not content.role_admin(): - content.role = content.role + ub.ROLE_ADMIN - elif "admin_role" not in to_save and content.role_admin(): - content.role = content.role - ub.ROLE_ADMIN - - if "download_role" in to_save and not content.role_download(): - content.role = content.role + ub.ROLE_DOWNLOAD - elif "download_role" not in to_save and content.role_download(): - content.role = content.role - ub.ROLE_DOWNLOAD - - if "upload_role" in to_save and not content.role_upload(): - content.role = content.role + ub.ROLE_UPLOAD - elif "upload_role" not in to_save and content.role_upload(): - content.role = content.role - ub.ROLE_UPLOAD - - if "edit_role" in to_save and not content.role_edit(): - content.role = content.role + ub.ROLE_EDIT - elif "edit_role" not in to_save and content.role_edit(): - content.role = content.role - ub.ROLE_EDIT - - if "delete_role" in to_save and not content.role_delete_books(): - content.role = content.role + ub.ROLE_DELETE_BOOKS - elif "delete_role" not in to_save and content.role_delete_books(): - content.role = content.role - ub.ROLE_DELETE_BOOKS - - if "passwd_role" in to_save and not content.role_passwd(): - content.role = content.role + ub.ROLE_PASSWD - elif "passwd_role" not in to_save and content.role_passwd(): - content.role = content.role - ub.ROLE_PASSWD - - if "edit_shelf_role" in to_save and not content.role_edit_shelfs(): - content.role = content.role + ub.ROLE_EDIT_SHELFS - elif "edit_shelf_role" not in to_save and content.role_edit_shelfs(): - content.role = content.role - ub.ROLE_EDIT_SHELFS - - if "show_random" in to_save and not content.show_random_books(): - content.sidebar_view += ub.SIDEBAR_RANDOM - elif "show_random" not in to_save and content.show_random_books(): - content.sidebar_view -= ub.SIDEBAR_RANDOM - - if "show_language" in to_save and not content.show_language(): - content.sidebar_view += ub.SIDEBAR_LANGUAGE - elif "show_language" not in to_save and content.show_language(): - content.sidebar_view -= ub.SIDEBAR_LANGUAGE - - if "show_series" in to_save and not content.show_series(): - content.sidebar_view += ub.SIDEBAR_SERIES - elif "show_series" not in to_save and content.show_series(): - content.sidebar_view -= ub.SIDEBAR_SERIES - - if "show_category" in to_save and not content.show_category(): - content.sidebar_view += ub.SIDEBAR_CATEGORY - elif "show_category" not in to_save and content.show_category(): - content.sidebar_view -= ub.SIDEBAR_CATEGORY - - if "show_recent" in to_save and not content.show_recent(): - content.sidebar_view += ub.SIDEBAR_RECENT - elif "show_recent" not in to_save and content.show_recent(): - content.sidebar_view -= ub.SIDEBAR_RECENT - - if "show_sorted" in to_save and not content.show_sorted(): - content.sidebar_view += ub.SIDEBAR_SORTED - elif "show_sorted" not in to_save and content.show_sorted(): - content.sidebar_view -= ub.SIDEBAR_SORTED - - if "show_publisher" in to_save and not content.show_publisher(): - content.sidebar_view += ub.SIDEBAR_PUBLISHER - elif "show_publisher" not in to_save and content.show_publisher(): - content.sidebar_view -= ub.SIDEBAR_PUBLISHER - - if "show_hot" in to_save and not content.show_hot_books(): - content.sidebar_view += ub.SIDEBAR_HOT - elif "show_hot" not in to_save and content.show_hot_books(): - content.sidebar_view -= ub.SIDEBAR_HOT - - if "show_best_rated" in to_save and not content.show_best_rated_books(): - content.sidebar_view += ub.SIDEBAR_BEST_RATED - elif "show_best_rated" not in to_save and content.show_best_rated_books(): - content.sidebar_view -= ub.SIDEBAR_BEST_RATED - - if "show_read_and_unread" in to_save and not content.show_read_and_unread(): - content.sidebar_view += ub.SIDEBAR_READ_AND_UNREAD - elif "show_read_and_unread" not in to_save and content.show_read_and_unread(): - content.sidebar_view -= ub.SIDEBAR_READ_AND_UNREAD - - if "show_author" in to_save and not content.show_author(): - content.sidebar_view += ub.SIDEBAR_AUTHOR - elif "show_author" not in to_save and content.show_author(): - content.sidebar_view -= ub.SIDEBAR_AUTHOR - - if "show_detail_random" in to_save and not content.show_detail_random(): - content.sidebar_view += ub.DETAIL_RANDOM - elif "show_detail_random" not in to_save and content.show_detail_random(): - content.sidebar_view -= ub.DETAIL_RANDOM - - content.mature_content = "show_mature_content" in to_save - - if "default_language" in to_save: - content.default_language = to_save["default_language"] - if "locale" in to_save and to_save["locale"]: - content.locale = to_save["locale"] - if to_save["email"] and to_save["email"] != content.email: - content.email = to_save["email"] - if "kindle_mail" in to_save and to_save["kindle_mail"] != content.kindle_mail: - content.kindle_mail = to_save["kindle_mail"] - try: - ub.session.commit() - flash(_(u"User '%(nick)s' updated", nick=content.nickname), category="success") - except IntegrityError: - ub.session.rollback() - flash(_(u"An unknown error occured."), category="error") - return render_title_template("user_edit.html", translations=translations, languages=languages, new_user=0, - content=content, downloads=downloads, title=_(u"Edit User %(nick)s", - nick=content.nickname), page="edituser") - - -@web.route("/admin/resetpassword/") -@login_required -@admin_required -def reset_password(user_id): - if not config.config_public_reg: - abort(404) - if current_user is not None and current_user.is_authenticated: - existing_user = ub.session.query(ub.User).filter(ub.User.id == user_id).first() - password = helper.generate_random_password() - existing_user.password = generate_password_hash(password) - try: - ub.session.commit() - helper.send_registration_mail(existing_user.email, existing_user.nickname, password, True) - flash(_(u"Password for user %(user)s reset", user=existing_user.nickname), category="success") - except Exception: - ub.session.rollback() - flash(_(u"An unknown error occurred. Please try again later."), category="error") - return redirect(url_for('admin')) - - -def render_edit_book(book_id): - db.session.connection().connection.connection.create_function("title_sort", 1, db.title_sort) - cc = db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all() - book = db.session.query(db.Books)\ - .filter(db.Books.id == book_id).filter(common_filters()).first() - - if not book: - flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") - return redirect(url_for("web.index")) - - for indx in range(0, len(book.languages)): - book.languages[indx].language_name = language_table[get_locale()][book.languages[indx].lang_code] - - book = order_authors(book) - - author_names = [] - for authr in book.authors: - author_names.append(authr.name.replace('|', ',')) - - # Option for showing convertbook button - valid_source_formats=list() - if config.config_ebookconverter == 2: - for file in book.data: - if file.format.lower() in EXTENSIONS_CONVERT: - valid_source_formats.append(file.format.lower()) - - # Determine what formats don't already exist - allowed_conversion_formats = EXTENSIONS_CONVERT.copy() - for file in book.data: - try: - allowed_conversion_formats.remove(file.format.lower()) - except Exception: - app.logger.warning(file.format.lower() + ' already removed from list.') - - return render_title_template('book_edit.html', book=book, authors=author_names, cc=cc, - title=_(u"edit metadata"), page="editbook", - conversion_formats=allowed_conversion_formats, - source_formats=valid_source_formats) - - -def edit_cc_data(book_id, book, to_save): - cc = db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all() - for c in cc: - cc_string = "custom_column_" + str(c.id) - if not c.is_multiple: - if len(getattr(book, cc_string)) > 0: - cc_db_value = getattr(book, cc_string)[0].value - else: - cc_db_value = None - if to_save[cc_string].strip(): - if c.datatype == 'bool': - if to_save[cc_string] == 'None': - to_save[cc_string] = None - else: - to_save[cc_string] = 1 if to_save[cc_string] == 'True' else 0 - if to_save[cc_string] != cc_db_value: - if cc_db_value is not None: - if to_save[cc_string] is not None: - setattr(getattr(book, cc_string)[0], 'value', to_save[cc_string]) - else: - del_cc = getattr(book, cc_string)[0] - getattr(book, cc_string).remove(del_cc) - db.session.delete(del_cc) - else: - cc_class = db.cc_classes[c.id] - new_cc = cc_class(value=to_save[cc_string], book=book_id) - db.session.add(new_cc) - elif c.datatype == 'int': - if to_save[cc_string] == 'None': - to_save[cc_string] = None - if to_save[cc_string] != cc_db_value: - if cc_db_value is not None: - if to_save[cc_string] is not None: - setattr(getattr(book, cc_string)[0], 'value', to_save[cc_string]) - else: - del_cc = getattr(book, cc_string)[0] - getattr(book, cc_string).remove(del_cc) - db.session.delete(del_cc) - else: - cc_class = db.cc_classes[c.id] - new_cc = cc_class(value=to_save[cc_string], book=book_id) - db.session.add(new_cc) - - else: - if c.datatype == 'rating': - to_save[cc_string] = str(int(float(to_save[cc_string]) * 2)) - if to_save[cc_string].strip() != cc_db_value: - if cc_db_value is not None: - # remove old cc_val - del_cc = getattr(book, cc_string)[0] - getattr(book, cc_string).remove(del_cc) - if len(del_cc.books) == 0: - db.session.delete(del_cc) - cc_class = db.cc_classes[c.id] - new_cc = db.session.query(cc_class).filter( - cc_class.value == to_save[cc_string].strip()).first() - # if no cc val is found add it - if new_cc is None: - new_cc = cc_class(value=to_save[cc_string].strip()) - db.session.add(new_cc) - db.session.flush() - new_cc = db.session.query(cc_class).filter( - cc_class.value == to_save[cc_string].strip()).first() - # add cc value to book - getattr(book, cc_string).append(new_cc) - else: - if cc_db_value is not None: - # remove old cc_val - del_cc = getattr(book, cc_string)[0] - getattr(book, cc_string).remove(del_cc) - if len(del_cc.books) == 0: - db.session.delete(del_cc) - else: - input_tags = to_save[cc_string].split(',') - input_tags = list(map(lambda it: it.strip(), input_tags)) - modify_database_object(input_tags, getattr(book, cc_string), db.cc_classes[c.id], db.session, - 'custom') - return cc - -def upload_single_file(request, book, book_id): - # Check and handle Uploaded file - if 'btn-upload-format' in request.files: - requested_file = request.files['btn-upload-format'] - # check for empty request - if requested_file.filename != '': - if '.' in requested_file.filename: - file_ext = requested_file.filename.rsplit('.', 1)[-1].lower() - if file_ext not in EXTENSIONS_UPLOAD: - flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext), - category="error") - return redirect(url_for('show_book', book_id=book.id)) - else: - flash(_('File to be uploaded must have an extension'), category="error") - return redirect(url_for('show_book', book_id=book.id)) - - file_name = book.path.rsplit('/', 1)[-1] - filepath = os.path.normpath(os.path.join(config.config_calibre_dir, book.path)) - saved_filename = os.path.join(filepath, file_name + '.' + file_ext) - - # check if file path exists, otherwise create it, copy file to calibre path and delete temp file - if not os.path.exists(filepath): - try: - os.makedirs(filepath) - except OSError: - flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error") - return redirect(url_for('show_book', book_id=book.id)) - try: - requested_file.save(saved_filename) - except OSError: - flash(_(u"Failed to store file %(file)s.", file=saved_filename), category="error") - return redirect(url_for('show_book', book_id=book.id)) - - file_size = os.path.getsize(saved_filename) - is_format = db.session.query(db.Data).filter(db.Data.book == book_id).\ - filter(db.Data.format == file_ext.upper()).first() - - # Format entry already exists, no need to update the database - if is_format: - app.logger.info('Book format already existing') - else: - db_format = db.Data(book_id, file_ext.upper(), file_size, file_name) - db.session.add(db_format) - db.session.commit() - db.session.connection().connection.connection.create_function("title_sort", 1, db.title_sort) - - # Queue uploader info - uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=book.title) - helper.global_WorkerThread.add_upload(current_user.nickname, - "" + uploadText + "") - -def upload_cover(request, book): - if 'btn-upload-cover' in request.files: - requested_file = request.files['btn-upload-cover'] - # check for empty request - if requested_file.filename != '': - file_ext = requested_file.filename.rsplit('.', 1)[-1].lower() - filepath = os.path.normpath(os.path.join(config.config_calibre_dir, book.path)) - saved_filename = os.path.join(filepath, 'cover.' + file_ext) - - # check if file path exists, otherwise create it, copy file to calibre path and delete temp file - if not os.path.exists(filepath): - try: - os.makedirs(filepath) - except OSError: - flash(_(u"Failed to create path for cover %(path)s (Permission denied).", cover=filepath), - category="error") - return redirect(url_for('show_book', book_id=book.id)) - try: - requested_file.save(saved_filename) - # im=Image.open(saved_filename) - book.has_cover = 1 - except OSError: - flash(_(u"Failed to store cover-file %(cover)s.", cover=saved_filename), category="error") - return redirect(url_for('show_book', book_id=book.id)) - except IOError: - flash(_(u"Cover-file is not a valid image file" % saved_filename), category="error") - return redirect(url_for('show_book', book_id=book.id)) - -@web.route("/admin/book/", methods=['GET', 'POST']) -@login_required_if_no_ano -@edit_required -def edit_book(book_id): - # Show form - if request.method != 'POST': - return render_edit_book(book_id) - - # create the function for sorting... - db.session.connection().connection.connection.create_function("title_sort", 1, db.title_sort) - book = db.session.query(db.Books)\ - .filter(db.Books.id == book_id).filter(common_filters()).first() - - # Book not found - if not book: - flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") - return redirect(url_for("web.index")) - - upload_single_file(request, book, book_id) - upload_cover(request, book) - try: - to_save = request.form.to_dict() - # Update book - edited_books_id = None - #handle book title - if book.title != to_save["book_title"].rstrip().strip(): - if to_save["book_title"] == '': - to_save["book_title"] = _(u'unknown') - book.title = to_save["book_title"].rstrip().strip() - edited_books_id = book.id - - # handle author(s) - input_authors = to_save["author_name"].split('&') - input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors)) - # we have all author names now - if input_authors == ['']: - input_authors = [_(u'unknown')] # prevent empty Author - - modify_database_object(input_authors, book.authors, db.Authors, db.session, 'author') - - # Search for each author if author is in database, if not, authorname and sorted authorname is generated new - # everything then is assembled for sorted author field in database - sort_authors_list = list() - for inp in input_authors: - stored_author = db.session.query(db.Authors).filter(db.Authors.name == inp).first() - if not stored_author: - stored_author = helper.get_sorted_author(inp) - else: - stored_author = stored_author.sort - sort_authors_list.append(helper.get_sorted_author(stored_author)) - sort_authors = ' & '.join(sort_authors_list) - if book.author_sort != sort_authors: - edited_books_id = book.id - book.author_sort = sort_authors - - - if config.config_use_google_drive: - gdriveutils.updateGdriveCalibreFromLocal() - - error = False - if edited_books_id: - error = helper.update_dir_stucture(edited_books_id, config.config_calibre_dir, input_authors[0]) - - if not error: - if to_save["cover_url"]: - if helper.save_cover(to_save["cover_url"], book.path) is True: - book.has_cover = 1 - else: - flash(_(u"Cover is not a jpg file, can't save"), category="error") - - if book.series_index != to_save["series_index"]: - book.series_index = to_save["series_index"] - - # Handle book comments/description - if len(book.comments): - book.comments[0].text = to_save["description"] - else: - book.comments.append(db.Comments(text=to_save["description"], book=book.id)) - - # Handle book tags - input_tags = to_save["tags"].split(',') - input_tags = list(map(lambda it: it.strip(), input_tags)) - modify_database_object(input_tags, book.tags, db.Tags, db.session, 'tags') - - # Handle book series - input_series = [to_save["series"].strip()] - input_series = [x for x in input_series if x != ''] - modify_database_object(input_series, book.series, db.Series, db.session, 'series') - - if to_save["pubdate"]: - try: - book.pubdate = datetime.datetime.strptime(to_save["pubdate"], "%Y-%m-%d") - except ValueError: - book.pubdate = db.Books.DEFAULT_PUBDATE - else: - book.pubdate = db.Books.DEFAULT_PUBDATE - - if to_save["publisher"]: - publisher = to_save["publisher"].rstrip().strip() - if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name): - modify_database_object([publisher], book.publishers, db.Publishers, db.session, 'publisher') - elif len(book.publishers): - modify_database_object([], book.publishers, db.Publishers, db.session, 'publisher') - - - # handle book languages - input_languages = to_save["languages"].split(',') - input_languages = [x.strip().lower() for x in input_languages if x != ''] - input_l = [] - invers_lang_table = [x.lower() for x in language_table[get_locale()].values()] - for lang in input_languages: - try: - res = list(language_table[get_locale()].keys())[invers_lang_table.index(lang)] - input_l.append(res) - except ValueError: - app.logger.error('%s is not a valid language' % lang) - flash(_(u"%(langname)s is not a valid language", langname=lang), category="error") - modify_database_object(input_l, book.languages, db.Languages, db.session, 'languages') - - # handle book ratings - if to_save["rating"].strip(): - old_rating = False - if len(book.ratings) > 0: - old_rating = book.ratings[0].rating - ratingx2 = int(float(to_save["rating"]) * 2) - if ratingx2 != old_rating: - is_rating = db.session.query(db.Ratings).filter(db.Ratings.rating == ratingx2).first() - if is_rating: - book.ratings.append(is_rating) - else: - new_rating = db.Ratings(rating=ratingx2) - book.ratings.append(new_rating) - if old_rating: - book.ratings.remove(book.ratings[0]) - else: - if len(book.ratings) > 0: - book.ratings.remove(book.ratings[0]) - - # handle cc data - edit_cc_data(book_id, book, to_save) - - db.session.commit() - if config.config_use_google_drive: - gdriveutils.updateGdriveCalibreFromLocal() - if "detail_view" in to_save: - return redirect(url_for('show_book', book_id=book.id)) - else: - flash(_("Metadata successfully updated"), category="success") - return render_edit_book(book_id) - else: - db.session.rollback() - flash(error, category="error") - return render_edit_book(book_id) - except Exception as e: - app.logger.exception(e) - db.session.rollback() - flash(_("Error editing book, please check logfile for details"), category="error") - return redirect(url_for('show_book', book_id=book.id)) - - -@web.route("/upload", methods=["GET", "POST"]) -@login_required_if_no_ano -@upload_required -def upload(): - if not config.config_uploading: - abort(404) - if request.method == 'POST' and 'btn-upload' in request.files: - for requested_file in request.files.getlist("btn-upload"): - # create the function for sorting... - db.session.connection().connection.connection.create_function("title_sort", 1, db.title_sort) - db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4())) - - # check if file extension is correct - if '.' in requested_file.filename: - file_ext = requested_file.filename.rsplit('.', 1)[-1].lower() - if file_ext not in EXTENSIONS_UPLOAD: - flash( - _("File extension '%(ext)s' is not allowed to be uploaded to this server", - ext=file_ext), category="error") - return redirect(url_for('index')) - else: - flash(_('File to be uploaded must have an extension'), category="error") - return redirect(url_for('index')) - - # extract metadata from file - meta = uploader.upload(requested_file) - title = meta.title - authr = meta.author - tags = meta.tags - series = meta.series - series_index = meta.series_id - title_dir = helper.get_valid_filename(title) - author_dir = helper.get_valid_filename(authr) - filepath = os.path.join(config.config_calibre_dir, author_dir, title_dir) - saved_filename = os.path.join(filepath, title_dir + meta.extension.lower()) - - # check if file path exists, otherwise create it, copy file to calibre path and delete temp file - if not os.path.exists(filepath): - try: - os.makedirs(filepath) - except OSError: - flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error") - return redirect(url_for('index')) - try: - copyfile(meta.file_path, saved_filename) - except OSError: - flash(_(u"Failed to store file %(file)s (Permission denied).", file=saved_filename), category="error") - return redirect(url_for('index')) - try: - os.unlink(meta.file_path) - except OSError: - flash(_(u"Failed to delete file %(file)s (Permission denied).", file= meta.file_path), - category="warning") - - if meta.cover is None: - has_cover = 0 - copyfile(os.path.join(config.get_main_dir, "cps/static/generic_cover.jpg"), - os.path.join(filepath, "cover.jpg")) - else: - has_cover = 1 - move(meta.cover, os.path.join(filepath, "cover.jpg")) - - # handle authors - is_author = db.session.query(db.Authors).filter(db.Authors.name == authr).first() - if is_author: - db_author = is_author - else: - db_author = db.Authors(authr, helper.get_sorted_author(authr), "") - db.session.add(db_author) - - # handle series - db_series = None - is_series = db.session.query(db.Series).filter(db.Series.name == series).first() - if is_series: - db_series = is_series - elif series != '': - db_series = db.Series(series, "") - db.session.add(db_series) - - # add language actually one value in list - input_language = meta.languages - db_language = None - if input_language != "": - input_language = isoLanguages.get(name=input_language).part3 - hasLanguage = db.session.query(db.Languages).filter(db.Languages.lang_code == input_language).first() - if hasLanguage: - db_language = hasLanguage - else: - db_language = db.Languages(input_language) - db.session.add(db_language) - - # combine path and normalize path from windows systems - path = os.path.join(author_dir, title_dir).replace('\\', '/') - db_book = db.Books(title, "", db_author.sort, datetime.datetime.now(), datetime.datetime(101, 1, 1), - series_index, datetime.datetime.now(), path, has_cover, db_author, [], db_language) - db_book.authors.append(db_author) - if db_series: - db_book.series.append(db_series) - if db_language is not None: - db_book.languages.append(db_language) - file_size = os.path.getsize(saved_filename) - db_data = db.Data(db_book, meta.extension.upper()[1:], file_size, title_dir) - - # handle tags - input_tags = tags.split(',') - input_tags = list(map(lambda it: it.strip(), input_tags)) - if input_tags[0] !="": - modify_database_object(input_tags, db_book.tags, db.Tags, db.session, 'tags') - - # flush content, get db_book.id available - db_book.data.append(db_data) - db.session.add(db_book) - db.session.flush() - - # add comment - book_id = db_book.id - upload_comment = Markup(meta.description).unescape() - if upload_comment != "": - db.session.add(db.Comments(upload_comment, book_id)) - - # save data to database, reread data - db.session.commit() - db.session.connection().connection.connection.create_function("title_sort", 1, db.title_sort) - book = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first() - - # upload book to gdrive if nesseccary and add "(bookid)" to folder name - if config.config_use_google_drive: - gdriveutils.updateGdriveCalibreFromLocal() - error = helper.update_dir_stucture(book.id, config.config_calibre_dir) - db.session.commit() - if config.config_use_google_drive: - gdriveutils.updateGdriveCalibreFromLocal() - if error: - flash(error, category="error") - uploadText=_(u"File %(file)s uploaded", file=book.title) - helper.global_WorkerThread.add_upload(current_user.nickname, - "" + uploadText + "") - - # create data for displaying display Full language name instead of iso639.part3language - if db_language is not None: - book.languages[0].language_name = _(meta.languages) - author_names = [] - for author in db_book.authors: - author_names.append(author.name) - if len(request.files.getlist("btn-upload")) < 2: - 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=book, authors=author_names, - cc=cc, title=_(u"edit metadata"), page="upload") - book_in_shelfs = [] - kindle_list = helper.check_send_to_kindle(book) - reader_list = helper.check_read_formats(book) - - return render_title_template('detail.html', entry=book, cc=cc, - title=book.title, books_shelfs=book_in_shelfs, kindle_list=kindle_list, - reader_list=reader_list, page="upload") - return redirect(url_for("web.index")) - - -@web.route("/admin/book/convert/", methods=['POST']) -@login_required_if_no_ano -@edit_required -def convert_bookformat(book_id): - # check to see if we have form fields to work with - if not send user back - book_format_from = request.form.get('book_format_from', None) - book_format_to = request.form.get('book_format_to', None) - - if (book_format_from is None) or (book_format_to is None): - flash(_(u"Source or destination format for conversion missing"), category="error") - return redirect(request.environ["HTTP_REFERER"]) - - app.logger.debug('converting: book id: ' + str(book_id) + - ' from: ' + request.form['book_format_from'] + - ' to: ' + request.form['book_format_to']) - rtn = helper.convert_book_format(book_id, config.config_calibre_dir, book_format_from.upper(), - book_format_to.upper(), current_user.nickname) - - if rtn is None: - flash(_(u"Book successfully queued for converting to %(book_format)s", - book_format=book_format_to), - category="success") - else: - flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error") - return redirect(request.environ["HTTP_REFERER"]) - -''' -def register_oauth_blueprint(blueprint, show_name): - if blueprint.name != "": - oauth_check[blueprint.name] = show_name - - -def register_user_with_oauth(user=None): - all_oauth = {} - for oauth in oauth_check.keys(): - if oauth + '_oauth_user_id' in session and session[oauth + '_oauth_user_id'] != '': - all_oauth[oauth] = oauth_check[oauth] - if len(all_oauth.keys()) == 0: - return - if user is None: - flash(_(u"Register with %s" % ", ".join(list(all_oauth.values()))), category="success") - else: - for oauth in all_oauth.keys(): - # Find this OAuth token in the database, or create it - query = ub.session.query(ub.OAuth).filter_by( - provider=oauth, - provider_user_id=session[oauth + "_oauth_user_id"], - ) - try: - oauth = query.one() - oauth.user_id = user.id - except NoResultFound: - # no found, return error - return - try: - ub.session.commit() - except Exception as e: - app.logger.exception(e) - ub.session.rollback() - - -def logout_oauth_user(): - for oauth in oauth_check.keys(): - if oauth + '_oauth_user_id' in session: - session.pop(oauth + '_oauth_user_id') - - -github_blueprint = make_github_blueprint( - client_id=config.config_github_oauth_client_id, - client_secret=config.config_github_oauth_client_secret, - redirect_to="github_login",) - -google_blueprint = make_google_blueprint( - client_id=config.config_google_oauth_client_id, - client_secret=config.config_google_oauth_client_secret, - redirect_to="google_login", - scope=[ - "https://www.googleapis.com/auth/plus.me", - "https://www.googleapis.com/auth/userinfo.email", - ] -) - -app.register_blueprint(google_blueprint, url_prefix="/login") -app.register_blueprint(github_blueprint, url_prefix='/login') - -github_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) -google_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) - - -if config.config_use_github_oauth: - register_oauth_blueprint(github_blueprint, 'GitHub') -if config.config_use_google_oauth: - register_oauth_blueprint(google_blueprint, 'Google') - - -@oauth_authorized.connect_via(github_blueprint) -def github_logged_in(blueprint, token): - if not token: - flash(_("Failed to log in with GitHub."), category="error") - return False - - resp = blueprint.session.get("/user") - if not resp.ok: - flash(_("Failed to fetch user info from GitHub."), category="error") - return False - - github_info = resp.json() - github_user_id = str(github_info["id"]) - return oauth_update_token(blueprint, token, github_user_id) - - -@oauth_authorized.connect_via(google_blueprint) -def google_logged_in(blueprint, token): - if not token: - flash(_("Failed to log in with Google."), category="error") - return False - - resp = blueprint.session.get("/oauth2/v2/userinfo") - if not resp.ok: - flash(_("Failed to fetch user info from Google."), category="error") - return False - - google_info = resp.json() - google_user_id = str(google_info["id"]) - - return oauth_update_token(blueprint, token, google_user_id) - - -def oauth_update_token(blueprint, token, provider_user_id): - session[blueprint.name + "_oauth_user_id"] = provider_user_id - session[blueprint.name + "_oauth_token"] = token - - # Find this OAuth token in the database, or create it - query = ub.session.query(ub.OAuth).filter_by( - provider=blueprint.name, - provider_user_id=provider_user_id, - ) - try: - oauth = query.one() - # update token - oauth.token = token - except NoResultFound: - oauth = ub.OAuth( - provider=blueprint.name, - provider_user_id=provider_user_id, - token=token, - ) - try: - ub.session.add(oauth) - ub.session.commit() - except Exception as e: - app.logger.exception(e) - ub.session.rollback() - - # Disable Flask-Dance's default behavior for saving the OAuth token - return False - - -def bind_oauth_or_register(provider, provider_user_id, redirect_url): - query = ub.session.query(ub.OAuth).filter_by( - provider=provider, - provider_user_id=provider_user_id, - ) - try: - oauth = query.one() - # already bind with user, just login - if oauth.user: - login_user(oauth.user) - return redirect(url_for('index')) - else: - # bind to current user - if current_user and current_user.is_authenticated: - oauth.user = current_user - try: - ub.session.add(oauth) - ub.session.commit() - except Exception as e: - app.logger.exception(e) - ub.session.rollback() - return redirect(url_for('register')) - except NoResultFound: - return redirect(url_for(redirect_url)) - - -def get_oauth_status(): - status = [] - query = ub.session.query(ub.OAuth).filter_by( - user_id=current_user.id, - ) - try: - oauths = query.all() - for oauth in oauths: - status.append(oauth.provider) - return status - except NoResultFound: - return None - - -def unlink_oauth(provider): - if request.host_url + 'me' != request.referrer: - pass - query = ub.session.query(ub.OAuth).filter_by( - provider=provider, - user_id=current_user.id, - ) - try: - oauth = query.one() - if current_user and current_user.is_authenticated: - oauth.user = current_user - try: - ub.session.delete(oauth) - ub.session.commit() - logout_oauth_user() - flash(_("Unlink to %(oauth)s success.", oauth=oauth_check[provider]), category="success") - except Exception as e: - app.logger.exception(e) - ub.session.rollback() - flash(_("Unlink to %(oauth)s failed.", oauth=oauth_check[provider]), category="error") - except NoResultFound: - app.logger.warning("oauth %s for user %d not fount" % (provider, current_user.id)) - flash(_("Not linked to %(oauth)s.", oauth=oauth_check[provider]), category="error") - return redirect(url_for('profile')) - - -# notify on OAuth provider error -@oauth_error.connect_via(github_blueprint) -def github_error(blueprint, error, error_description=None, error_uri=None): - msg = ( - "OAuth error from {name}! " - "error={error} description={description} uri={uri}" - ).format( - name=blueprint.name, - error=error, - description=error_description, - uri=error_uri, - ) - flash(msg, category="error") - - -@web.route('/github') -@github_oauth_required -def github_login(): - if not github.authorized: - return redirect(url_for('github.login')) - account_info = github.get('/user') - if account_info.ok: - account_info_json = account_info.json() - return bind_oauth_or_register(github_blueprint.name, account_info_json['id'], 'github.login') - flash(_(u"GitHub Oauth error, please retry later."), category="error") - return redirect(url_for('login')) - - -@web.route('/unlink/github', methods=["GET"]) -@login_required -def github_login_unlink(): - return unlink_oauth(github_blueprint.name) - - -@web.route('/google') -@google_oauth_required -def google_login(): - if not google.authorized: - return redirect(url_for("google.login")) - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - account_info_json = resp.json() - return bind_oauth_or_register(google_blueprint.name, account_info_json['id'], 'google.login') - flash(_(u"Google Oauth error, please retry later."), category="error") - return redirect(url_for('login')) - - -@oauth_error.connect_via(google_blueprint) -def google_error(blueprint, error, error_description=None, error_uri=None): - msg = ( - "OAuth error from {name}! " - "error={error} description={description} uri={uri}" - ).format( - name=blueprint.name, - error=error, - description=error_description, - uri=error_uri, - ) - flash(msg, category="error") - - -@web.route('/unlink/google', methods=["GET"]) -@login_required -def google_login_unlink(): - return unlink_oauth(google_blueprint.name) -''' From f5235b1d4c9ee55c43fb124ba3cecb56931c6767 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sat, 9 Feb 2019 18:46:36 +0100 Subject: [PATCH 18/96] Uploader progress bar Fixes imports and routes Merge branch 'master' into Develop Finished routing --- cps/admin.py | 68 ++++++++++- cps/editbooks.py | 51 ++++---- cps/oauth_bb.py | 10 +- cps/static/css/upload.css | 8 ++ cps/static/js/uploadprogress.js | 198 ++++++++++++++++++++++++++++++++ cps/templates/author.html | 8 +- cps/templates/book_edit.html | 4 +- cps/templates/config_edit.html | 6 +- cps/templates/detail.html | 18 +-- cps/templates/discover.html | 8 +- cps/templates/email_edit.html | 7 +- cps/templates/http_error.html | 2 +- cps/templates/languages.html | 2 +- cps/templates/layout.html | 24 ++-- cps/templates/listenmp3.html | 8 +- cps/templates/login.html | 6 +- cps/templates/read.html | 4 +- cps/templates/readcbr.html | 2 +- cps/templates/readpdf.html | 2 +- cps/templates/readtxt.html | 6 +- cps/templates/register.html | 4 +- cps/templates/remote_login.html | 4 +- cps/templates/search.html | 4 +- cps/templates/shelf_order.html | 4 +- cps/templates/shelfdown.html | 14 +-- cps/templates/user_edit.html | 8 +- cps/ub.py | 2 + cps/uploader.py | 21 ++-- cps/web.py | 77 ++----------- optional-requirements-ldap.txt | 1 - optional-requirements.txt | 2 + 31 files changed, 393 insertions(+), 190 deletions(-) create mode 100644 cps/static/css/upload.css create mode 100644 cps/static/js/uploadprogress.js delete mode 100644 optional-requirements-ldap.txt diff --git a/cps/admin.py b/cps/admin.py index cd6ff60c..f0e90e5c 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -22,11 +22,11 @@ # along with this program. If not, see . import os -from flask import Blueprint -from flask import abort, request -from flask_login import login_required, current_user -from web import admin_required, render_title_template, flash, redirect, url_for, before_request, logout_user, \ - speaking_language, unconfigured +from flask import Blueprint, flash, redirect, url_for +from flask import abort, request, make_response +from flask_login import login_required, current_user, logout_user +from web import admin_required, render_title_template, before_request, speaking_language, unconfigured, \ + login_required_if_no_ano, check_valid_domain from cps import db, ub, Server, get_locale, config, app, updater_thread, babel import json from datetime import datetime, timedelta @@ -36,9 +36,9 @@ from flask_babel import gettext as _ from babel import Locale as LC from sqlalchemy.exc import IntegrityError from gdriveutils import is_gdrive_ready, gdrive_support, downloadFile, deleteDatabaseOnChange, listRootFolders -from web import login_required_if_no_ano, check_valid_domain import helper from werkzeug.security import generate_password_hash +from sqlalchemy.sql.expression import text try: from goodreads.client import GoodreadsClient @@ -218,6 +218,62 @@ def view_configuration(): title=_(u"UI Configuration"), page="uiconfig") +@admi.route("/ajax/editdomain", methods=['POST']) +@login_required +@admin_required +def edit_domain(): + # POST /post + # name: 'username', //name of field (column in db) + # pk: 1 //primary key (record id) + # value: 'superuser!' //new value + vals = request.form.to_dict() + answer = ub.session.query(ub.Registration).filter(ub.Registration.id == vals['pk']).first() + # domain_name = request.args.get('domain') + answer.domain = vals['value'].replace('*', '%').replace('?', '_').lower() + ub.session.commit() + return "" + + +@admi.route("/ajax/adddomain", methods=['POST']) +@login_required +@admin_required +def add_domain(): + domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower() + check = ub.session.query(ub.Registration).filter(ub.Registration.domain == domain_name).first() + if not check: + new_domain = ub.Registration(domain=domain_name) + ub.session.add(new_domain) + ub.session.commit() + return "" + + +@admi.route("/ajax/deletedomain", methods=['POST']) +@login_required +@admin_required +def delete_domain(): + domain_id = request.form.to_dict()['domainid'].replace('*', '%').replace('?', '_').lower() + ub.session.query(ub.Registration).filter(ub.Registration.id == domain_id).delete() + ub.session.commit() + # If last domain was deleted, add all domains by default + if not ub.session.query(ub.Registration).count(): + new_domain = ub.Registration(domain="%.%") + ub.session.add(new_domain) + ub.session.commit() + return "" + + +@admi.route("/ajax/domainlist") +@login_required +@admin_required +def list_domain(): + answer = ub.session.query(ub.Registration).all() + json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer]) + js = json.dumps(json_dumps.replace('"', "'")).lstrip('"').strip('"') + response = make_response(js.replace("'", '"')) + response.headers["Content-Type"] = "application/json; charset=utf-8" + return response + + @admi.route("/config", methods=["GET", "POST"]) @unconfigured def basic_configuration(): diff --git a/cps/editbooks.py b/cps/editbooks.py index 4ca5af1f..2c20b8a9 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -22,12 +22,13 @@ # along with this program. If not, see . # opds routing functions -from cps import config, language_table, get_locale, app, ub -from flask import request, flash, redirect, url_for, abort, Markup +from cps import config, language_table, get_locale, app, ub, global_WorkerThread +from flask import request, flash, redirect, url_for, abort, Markup, Response from flask import Blueprint import datetime import db import os +import json from flask_babel import gettext as _ from uuid import uuid4 import helper @@ -206,9 +207,9 @@ def delete_book(book_id, book_format): # book not found app.logger.info('Book with id "'+str(book_id)+'" could not be deleted') if book_format: - return redirect(url_for('edit_book', book_id=book_id)) + return redirect(url_for('editbook.edit_book', book_id=book_id)) else: - return redirect(url_for('index')) + return redirect(url_for('web.index')) def render_edit_book(book_id): @@ -341,10 +342,10 @@ def upload_single_file(request, book, book_id): if file_ext not in EXTENSIONS_UPLOAD: flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext), category="error") - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) else: flash(_('File to be uploaded must have an extension'), category="error") - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) file_name = book.path.rsplit('/', 1)[-1] filepath = os.path.normpath(os.path.join(config.config_calibre_dir, book.path)) @@ -356,12 +357,12 @@ def upload_single_file(request, book, book_id): os.makedirs(filepath) except OSError: flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error") - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) try: requested_file.save(saved_filename) except OSError: flash(_(u"Failed to store file %(file)s.", file=saved_filename), category="error") - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) file_size = os.path.getsize(saved_filename) is_format = db.session.query(db.Data).filter(db.Data.book == book_id).\ @@ -378,7 +379,7 @@ def upload_single_file(request, book, book_id): # Queue uploader info uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=book.title) - helper.global_WorkerThread.add_upload(current_user.nickname, + global_WorkerThread.add_upload(current_user.nickname, "" + uploadText + "") def upload_cover(request, book): @@ -397,17 +398,17 @@ def upload_cover(request, book): except OSError: flash(_(u"Failed to create path for cover %(path)s (Permission denied).", cover=filepath), category="error") - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) try: requested_file.save(saved_filename) # im=Image.open(saved_filename) book.has_cover = 1 except OSError: flash(_(u"Failed to store cover-file %(cover)s.", cover=saved_filename), category="error") - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) except IOError: flash(_(u"Cover-file is not a valid image file" % saved_filename), category="error") - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) @editbook.route("/admin/book/", methods=['GET', 'POST']) @login_required_if_no_ano @@ -554,7 +555,7 @@ def edit_book(book_id): if config.config_use_google_drive: gdriveutils.updateGdriveCalibreFromLocal() if "detail_view" in to_save: - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) else: flash(_("Metadata successfully updated"), category="success") return render_edit_book(book_id) @@ -566,7 +567,7 @@ def edit_book(book_id): app.logger.exception(e) db.session.rollback() flash(_("Error editing book, please check logfile for details"), category="error") - return redirect(url_for('show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book.id)) @editbook.route("/upload", methods=["GET", "POST"]) @@ -704,8 +705,8 @@ def upload(): if error: flash(error, category="error") uploadText=_(u"File %(file)s uploaded", file=book.title) - helper.global_WorkerThread.add_upload(current_user.nickname, - "" + uploadText + "") + global_WorkerThread.add_upload(current_user.nickname, + "" + uploadText + "") # create data for displaying display Full language name instead of iso639.part3language if db_language is not None: @@ -714,19 +715,13 @@ def upload(): for author in db_book.authors: author_names.append(author.name) if len(request.files.getlist("btn-upload")) < 2: - 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=book, authors=author_names, - cc=cc, title=_(u"edit metadata"), page="upload") - book_in_shelfs = [] - kindle_list = helper.check_send_to_kindle(book) - reader_list = helper.check_read_formats(book) - - return render_title_template('detail.html', entry=book, cc=cc, - title=book.title, books_shelfs=book_in_shelfs, kindle_list=kindle_list, - reader_list=reader_list, page="upload") - return redirect(url_for("web.index")) + resp = {"location": url_for('editbook.edit_book', book_id=db_book.id)} + return Response(json.dumps(resp), mimetype='application/json') + else: + resp = {"location": url_for('web.show_book', book_id=db_book.id)} + return Response(json.dumps(resp), mimetype='application/json') + return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') @editbook.route("/admin/book/convert/", methods=['POST']) diff --git a/cps/oauth_bb.py b/cps/oauth_bb.py index 34e71fde..0be03617 100644 --- a/cps/oauth_bb.py +++ b/cps/oauth_bb.py @@ -187,7 +187,7 @@ def bind_oauth_or_register(provider, provider_user_id, redirect_url): except Exception as e: app.logger.exception(e) ub.session.rollback() - return redirect(url_for('register')) + return redirect(url_for('web.register')) except NoResultFound: return redirect(url_for(redirect_url)) @@ -247,7 +247,7 @@ def github_error(blueprint, error, error_description=None, error_uri=None): flash(msg, category="error") -@web.route('/github') +@oauth.route('/github') @github_oauth_required def github_login(): if not github.authorized: @@ -260,13 +260,13 @@ def github_login(): return redirect(url_for('login')) -@web.route('/unlink/github', methods=["GET"]) +@oauth.route('/unlink/github', methods=["GET"]) @login_required def github_login_unlink(): return unlink_oauth(github_blueprint.name) -@web.route('/google') +@oauth.route('/google') @google_oauth_required def google_login(): if not google.authorized: @@ -293,7 +293,7 @@ def google_error(blueprint, error, error_description=None, error_uri=None): flash(msg, category="error") -@web.route('/unlink/google', methods=["GET"]) +@oauth.route('/unlink/google', methods=["GET"]) @login_required def google_login_unlink(): return unlink_oauth(google_blueprint.name) diff --git a/cps/static/css/upload.css b/cps/static/css/upload.css new file mode 100644 index 00000000..8192bd39 --- /dev/null +++ b/cps/static/css/upload.css @@ -0,0 +1,8 @@ +@media (min-device-width: 768px) { + .modal-dialog { + position: absolute; + top: 45%; + left: 50%; + transform: translate(-50%, -50%) !important; + } +} diff --git a/cps/static/js/uploadprogress.js b/cps/static/js/uploadprogress.js new file mode 100644 index 00000000..b28d17f3 --- /dev/null +++ b/cps/static/js/uploadprogress.js @@ -0,0 +1,198 @@ +/* + * bootstrap-uploadprogress + * github: https://github.com/jakobadam/bootstrap-uploadprogress + * + * Copyright (c) 2015 Jakob Aarøe Dam + * Version 1.0.0 + * Licensed under the MIT license. + */ +(function($){ + "use strict"; + + $.support.xhrFileUpload = !!(window.FileReader && window.ProgressEvent); + $.support.xhrFormData = !!window.FormData; + + if(!$.support.xhrFileUpload || !$.support.xhrFormData){ + // skip decorating form + return; + } + + var template = '

    '; + + var UploadProgress = function(element, options){ + this.options = options; + this.$element = $(element); + }; + + UploadProgress.prototype = { + + constructor: function() { + this.$form = this.$element; + this.$form.on('submit', $.proxy(this.submit, this)); + this.$modal = $(this.options.template); + this.$modal_message = this.$modal.find('.modal-message'); + this.$modal_title = this.$modal.find('.modal-title'); + this.$modal_footer = this.$modal.find('.modal-footer'); + this.$modal_bar = this.$modal.find('.progress-bar'); + + this.$modal.on('hidden.bs.modal', $.proxy(this.reset, this)); + }, + + reset: function(){ + this.$modal_title = this.$modal_title.text('Uploading'); + this.$modal_footer.hide(); + this.$modal_bar.addClass('progress-bar-success'); + this.$modal_bar.removeClass('progress-bar-danger'); + if(this.xhr){ + this.xhr.abort(); + } + }, + + submit: function(e) { + e.preventDefault(); + + this.$modal.modal({ + backdrop: 'static', + keyboard: false + }); + + // We need the native XMLHttpRequest for the progress event + var xhr = new XMLHttpRequest(); + this.xhr = xhr; + + xhr.addEventListener('load', $.proxy(this.success, this, xhr)); + xhr.addEventListener('error', $.proxy(this.error, this, xhr)); + //xhr.addEventListener('abort', function(){}); + + xhr.upload.addEventListener('progress', $.proxy(this.progress, this)); + + var form = this.$form; + + xhr.open(form.attr('method'), form.attr("action")); + xhr.setRequestHeader('X-REQUESTED-WITH', 'XMLHttpRequest'); + + var data = new FormData(form.get(0)); + xhr.send(data); + }, + + success: function(xhr) { + if(xhr.status == 0 || xhr.status >= 400){ + // HTTP 500 ends up here!?! + return this.error(xhr); + } + this.set_progress(100); + var url; + var content_type = xhr.getResponseHeader('Content-Type'); + + // make it possible to return the redirect URL in + // a JSON response + if(content_type.indexOf('application/json') !== -1){ + var response = $.parseJSON(xhr.responseText); + console.log(response); + url = response.location; + } + else{ + url = this.options.redirect_url; + } + window.location.href = url; + }, + + // handle form error + // we replace the form with the returned one + error: function(xhr){ + this.$modal_title.text('Upload failed'); + + this.$modal_bar.removeClass('progress-bar-success'); + this.$modal_bar.addClass('progress-bar-danger'); + this.$modal_footer.show(); + + var content_type = xhr.getResponseHeader('Content-Type'); + + // Replace the contents of the form, with the returned html + if(xhr.status === 422){ + var new_html = $.parseHTML(xhr.responseText); + this.replace_form(new_html); + this.$modal.modal('hide'); + } + // Write the error response to the document. + else{ + var response_text = xhr.responseText; + if(content_type.indexOf('text/plain') !== -1){ + response_text = '
    ' + response_text + '
    '; + } + document.write(xhr.responseText); + } + }, + + set_progress: function(percent){ + var txt = percent + '%'; + if (percent == 100) { + txt = this.options.uploaded_msg; + } + this.$modal_bar.attr('aria-valuenow', percent); + this.$modal_bar.text(txt); + this.$modal_bar.css('width', percent + '%'); + }, + + progress: function(/*ProgressEvent*/e){ + var percent = Math.round((e.loaded / e.total) * 100); + this.set_progress(percent); + }, + + // replace_form replaces the contents of the current form + // with the form in the html argument. + // We use the id of the current form to find the new form in the html + replace_form: function(html){ + var new_form; + var form_id = this.$form.attr('id'); + if(form_id !== undefined){ + new_form = $(html).find('#' + form_id); + } + else{ + new_form = $(html).find('form'); + } + + // add the filestyle again + new_form.find(':file').filestyle({buttonBefore: true}); + this.$form.html(new_form.children()); + } + }; + + $.fn.uploadprogress = function(options, value){ + return this.each(function(){ + var _options = $.extend({}, $.fn.uploadprogress.defaults, options); + var file_progress = new UploadProgress(this, _options); + file_progress.constructor(); + }); + }; + + $.fn.uploadprogress.defaults = { + template: template, + uploaded_msg: "Upload done, processing, please wait..." + //redirect_url: ... + + // need to customize stuff? Add here, and change code accordingly. + }; + +})(window.jQuery); diff --git a/cps/templates/author.html b/cps/templates/author.html index 2ebe8c4e..6d888845 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -27,21 +27,21 @@ {% for entry in entries %}
    - +

    {{entry.title|shortentitle}}

    {% for author in entry.authors %} - {{author.name.replace('|',',')}} + {{author.name.replace('|',',')}} {% if not loop.last %} & {% endif %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index 449a2d57..0a3f4bb9 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -53,7 +53,7 @@ {% endif %}

    -
    +
    @@ -196,7 +196,7 @@
    diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 6abe9685..a7d0bcb9 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -33,7 +33,7 @@ {% else %} {% if show_authenticate_google_drive and g.user.is_authenticated and content.config_use_google_drive %} {% else %} {% if show_authenticate_google_drive and g.user.is_authenticated and not content.config_use_google_drive %} @@ -56,10 +56,10 @@ {% else %} - Enable watch of metadata.db + Enable watch of metadata.db {% endif %} {% endif %} {% endif %} diff --git a/cps/templates/detail.html b/cps/templates/detail.html index e68bcf47..a459b89e 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -224,8 +224,8 @@ {% for shelf in g.user.shelf %} {% if not shelf.id in books_shelfs and shelf.is_public != 1 %}
  • - {{shelf.name}} @@ -236,8 +236,8 @@ {% for shelf in g.public_shelfes %} {% if not shelf.id in books_shelfs %}
  • - {{shelf.name}} @@ -251,8 +251,8 @@ {% if books_shelfs %} {% for shelf in g.user.shelf %} {% if shelf.id in books_shelfs and shelf.is_public != 1 %} - {{shelf.name}} @@ -261,8 +261,8 @@ {%endfor%} {% for shelf in g.public_shelfes %} {% if shelf.id in books_shelfs %} - {{shelf.name}} @@ -279,7 +279,7 @@ {% if g.user.role_edit() %} {% endif %} diff --git a/cps/templates/discover.html b/cps/templates/discover.html index e8e848e7..927fb717 100644 --- a/cps/templates/discover.html +++ b/cps/templates/discover.html @@ -8,18 +8,18 @@
    {% if entry.has_cover is defined %} - - {{ entry.title }} + + {{ entry.title }} {% endif %}
    - +

    {{entry.title|shortentitle}}

    {% for author in entry.authors %} - {{author.name.replace('|',',')|shortentitle(30)}} + {{author.name.replace('|',',')|shortentitle(30)}} {% if not loop.last %} & {% endif %} diff --git a/cps/templates/email_edit.html b/cps/templates/email_edit.html index 00b60640..bb5c60a0 100644 --- a/cps/templates/email_edit.html +++ b/cps/templates/email_edit.html @@ -41,16 +41,16 @@

    {% if g.allow_registration %}

    {{_('Allowed domains for registering')}}

    - +
    - +
    -
    +
    @@ -71,7 +71,6 @@
    diff --git a/cps/templates/http_error.html b/cps/templates/http_error.html index 3f7f73f7..98763cdc 100644 --- a/cps/templates/http_error.html +++ b/cps/templates/http_error.html @@ -20,7 +20,7 @@

    {{ error_code }}

    {{ error_name }}

    - {{_('Back to home')}} + {{_('Back to home')}}
    diff --git a/cps/templates/languages.html b/cps/templates/languages.html index 4c582d27..548abaec 100644 --- a/cps/templates/languages.html +++ b/cps/templates/languages.html @@ -10,7 +10,7 @@ {% endif %}
    {{lang_counter[loop.index0].bookcount}}
    - +
    {% endfor %}
    diff --git a/cps/templates/layout.html b/cps/templates/layout.html index 438e713b..ef83e842 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -12,6 +12,7 @@ + {% if g.current_theme == 1 %} {% endif %} @@ -59,7 +60,7 @@ {% if g.user.role_upload() or g.user.role_admin()%} {% if g.allow_upload %}
  • - +
    {{_('Upload')}}
    @@ -103,14 +104,6 @@
  • {%endif%} {% endfor %} -
    {% if g.user.is_authenticated or g.user.is_anonymous %} @@ -240,6 +233,7 @@ + {% if g.current_theme == 1 %} @@ -247,12 +241,12 @@ {% endif %} {% block js %}{% endblock %} diff --git a/cps/templates/listenmp3.html b/cps/templates/listenmp3.html index e7ec1da6..3ecf3e27 100644 --- a/cps/templates/listenmp3.html +++ b/cps/templates/listenmp3.html @@ -36,7 +36,7 @@
    {% if entry.has_cover %} - {{ entry.title }} {% else %} + {{ entry.title }} {% else %} {{ entry.title }} {% endif %}
    @@ -117,7 +117,7 @@
    • - +
    @@ -129,7 +129,7 @@ filePath: "{{ url_for('static', filename='js/libs/') }}", cssPath: "{{ url_for('static', filename='css/') }}", bookUrl: "{{ url_for('static', filename=mp3file) }}/", - bookmarkUrl: "{{ url_for('bookmark', book_id=mp3file, book_format=audioformat.upper()) }}", + bookmarkUrl: "{{ url_for('web.bookmark', book_id=mp3file, book_format=audioformat.upper()) }}", bookmark: "{{ bookmark.bookmark_key if bookmark != None }}", useBookmarks: "{{ g.user.is_authenticated | tojson }}" }; @@ -137,4 +137,4 @@
    - \ No newline at end of file + diff --git a/cps/templates/login.html b/cps/templates/login.html index 8e622079..fcb6f269 100644 --- a/cps/templates/login.html +++ b/cps/templates/login.html @@ -19,17 +19,17 @@
    {% if config.config_remote_login %} - {{_('Log in with magic link')}} + {{_('Log in with magic link')}} {% endif %} {% if config.config_use_github_oauth %} - + {% endif %} {% if config.config_use_google_oauth %} - + document.onreadystatechange = function () { if (document.readyState == "complete") { - init("{{ url_for('serve_book', book_id=comicfile, book_format=extension) }}"); + init("{{ url_for('web.serve_book', book_id=comicfile, book_format=extension) }}"); } }; diff --git a/cps/templates/readpdf.html b/cps/templates/readpdf.html index 44955bf4..79a8c816 100644 --- a/cps/templates/readpdf.html +++ b/cps/templates/readpdf.html @@ -44,7 +44,7 @@ See https://github.com/adobe-type-tools/cmap-resources - \ No newline at end of file + diff --git a/cps/templates/register.html b/cps/templates/register.html index 24edc3a2..90ba8f8e 100644 --- a/cps/templates/register.html +++ b/cps/templates/register.html @@ -13,14 +13,14 @@
    {% if config.config_use_github_oauth %} - + {% endif %} {% if config.config_use_google_oauth %} - + {% for shelf in g.user.shelf %} {% if shelf.is_public != 1 %} -
  • {{shelf.name}}
  • +
  • {{shelf.name}}
  • {% endif %} {% endfor %} {% for shelf in g.public_shelfes %} -
  • {{shelf.name}}
  • +
  • {{shelf.name}}
  • {% endfor %}
    diff --git a/cps/templates/shelf_order.html b/cps/templates/shelf_order.html index d4d44a62..e3340993 100644 --- a/cps/templates/shelf_order.html +++ b/cps/templates/shelf_order.html @@ -8,8 +8,8 @@
    {{entry.title}}
    {% endfor %}
    - - {{_('Back')}} + + {{_('Back')}}
    {% endblock %} diff --git a/cps/templates/shelfdown.html b/cps/templates/shelfdown.html index 32fa78b2..119330a7 100644 --- a/cps/templates/shelfdown.html +++ b/cps/templates/shelfdown.html @@ -32,28 +32,28 @@ {% for entry in entries %}
    - +

    {{entry.title|shortentitle}}

    {% for author in entry.authors %} - {{author.name.replace('|',',')}} + {{author.name.replace('|',',')}} {% if not loop.last %} & {% endif %} {% endfor %}

    - +
    - +
    {% if g.user.role_download() %} {% if entry.data|length %}
    {% if entry.data|length < 2 %} - + {% for format in entry.data %} - + {{format.format}} ({{ format.uncompressed_size|filesizeformat }}) {% endfor %} @@ -79,4 +79,4 @@ {% endblock %} - \ No newline at end of file + diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html index 6c57915d..386193bb 100644 --- a/cps/templates/user_edit.html +++ b/cps/templates/user_edit.html @@ -15,7 +15,7 @@
    {% if ( g.user and g.user.role_passwd() or g.user.role_admin() ) and not content.role_anonymous() %} {% if g.user and g.user.role_admin() and g.allow_registration and not new_user and not profile %} - + {% else %}
    @@ -161,7 +161,7 @@
    {% if not profile %} - {{_('Back')}} + {{_('Back')}} {% endif %}
    @@ -171,8 +171,8 @@

    {{_('Recent Downloads')}}

    {% for entry in downloads %} {% endfor %} diff --git a/cps/ub.py b/cps/ub.py index ba69c4ec..fa8a86e5 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -599,6 +599,8 @@ class Config: # everywhere to curent should work. Migration is done by checking if relevant coloums are existing, and than adding # rows with SQL commands def migrate_Database(): + if not engine.dialect.has_table(engine.connect(), "book_read_link"): + ReadBook.__table__.create(bind=engine) if not engine.dialect.has_table(engine.connect(), "bookmark"): Bookmark.__table__.create(bind=engine) if not engine.dialect.has_table(engine.connect(), "registration"): diff --git a/cps/uploader.py b/cps/uploader.py index df516d24..8dcddb8c 100644 --- a/cps/uploader.py +++ b/cps/uploader.py @@ -65,6 +65,7 @@ logger = logging.getLogger("book_formats") try: from wand.image import Image from wand import version as ImageVersion + from wand.exceptions import PolicyError use_generic_pdf_cover = False except (ImportError, RuntimeError) as e: logger.warning('cannot import Image, generating pdf covers for pdf uploads will not work: %s', e) @@ -160,12 +161,18 @@ def pdf_preview(tmp_file_path, tmp_dir): if use_generic_pdf_cover: return None else: - cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg" - with Image(filename=tmp_file_path + "[0]", resolution=150) as img: - img.compression_quality = 88 - img.save(filename=os.path.join(tmp_dir, cover_file_name)) - return cover_file_name - + try: + cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg" + with Image(filename=tmp_file_path + "[0]", resolution=150) as img: + img.compression_quality = 88 + img.save(filename=os.path.join(tmp_dir, cover_file_name)) + return cover_file_name + except PolicyError as ex: + logger.warning('Pdf extraction forbidden by Imagemagick policy: %s', ex) + return None + except Exception as ex: + logger.warning('Cannot extract cover image, using default: %s', ex) + return None def get_versions(): if not use_generic_pdf_cover: @@ -197,5 +204,5 @@ def upload(uploadfile): md5.update(filename.encode('utf-8')) tmp_file_path = os.path.join(tmp_dir, md5.hexdigest()) uploadfile.save(tmp_file_path) - meta = book_formats.process(tmp_file_path, filename_root, file_extension) + meta = process(tmp_file_path, filename_root, file_extension) return meta diff --git a/cps/web.py b/cps/web.py index 346cb005..0a4a6e9d 100644 --- a/cps/web.py +++ b/cps/web.py @@ -31,7 +31,6 @@ import os from sqlalchemy.exc import IntegrityError from flask_login import login_user, logout_user, login_required, current_user from flask_babel import gettext as _ - from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.datastructures import Headers from babel import Locale as LC @@ -50,6 +49,7 @@ import gdriveutils from redirect import redirect_back from cps import lm, babel, ub, config, get_locale, language_table, app from pagination import Pagination +# from admin import check_valid_domain # from oauth_bb import oauth_check, register_user_with_oauth try: @@ -280,6 +280,15 @@ def speaking_language(languages=None): lang.name = _(isoLanguages.get(part3=lang.lang_code).name) return languages +# checks if domain is in database (including wildcards) +# example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name; +# from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/ +def check_valid_domain(domain_text): + domain_text = domain_text.split('@', 1)[-1].lower() + sql = "SELECT * FROM registration WHERE :domain LIKE domain;" + result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all() + return len(result) + # Orders all Authors in the list according to authors sort def order_authors(entry): @@ -359,72 +368,6 @@ def get_email_status_json(): return response -# checks if domain is in database (including wildcards) -# example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name; -# from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/ -def check_valid_domain(domain_text): - domain_text = domain_text.split('@', 1)[-1].lower() - sql = "SELECT * FROM registration WHERE :domain LIKE domain;" - result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all() - return len(result) - - -@web.route("/ajax/editdomain", methods=['POST']) -@login_required -@admin_required -def edit_domain(): - # POST /post - # name: 'username', //name of field (column in db) - # pk: 1 //primary key (record id) - # value: 'superuser!' //new value - vals = request.form.to_dict() - answer = ub.session.query(ub.Registration).filter(ub.Registration.id == vals['pk']).first() - # domain_name = request.args.get('domain') - answer.domain = vals['value'].replace('*', '%').replace('?', '_').lower() - ub.session.commit() - return "" - - -@web.route("/ajax/adddomain", methods=['POST']) -@login_required -@admin_required -def add_domain(): - domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower() - check = ub.session.query(ub.Registration).filter(ub.Registration.domain == domain_name).first() - if not check: - new_domain = ub.Registration(domain=domain_name) - ub.session.add(new_domain) - ub.session.commit() - return "" - - -@web.route("/ajax/deletedomain", methods=['POST']) -@login_required -@admin_required -def delete_domain(): - domain_id = request.form.to_dict()['domainid'].replace('*', '%').replace('?', '_').lower() - ub.session.query(ub.Registration).filter(ub.Registration.id == domain_id).delete() - ub.session.commit() - # If last domain was deleted, add all domains by default - if not ub.session.query(ub.Registration).count(): - new_domain = ub.Registration(domain="%.%") - ub.session.add(new_domain) - ub.session.commit() - return "" - - -@web.route("/ajax/domainlist") -@login_required -@admin_required -def list_domain(): - answer = ub.session.query(ub.Registration).all() - json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer]) - js = json.dumps(json_dumps.replace('"', "'")).lstrip('"').strip('"') - response = make_response(js.replace("'", '"')) - response.headers["Content-Type"] = "application/json; charset=utf-8" - return response - - ''' @web.route("/ajax/getcomic///") @login_required diff --git a/optional-requirements-ldap.txt b/optional-requirements-ldap.txt deleted file mode 100644 index 98519145..00000000 --- a/optional-requirements-ldap.txt +++ /dev/null @@ -1 +0,0 @@ -python_ldap>=3.0.0 \ No newline at end of file diff --git a/optional-requirements.txt b/optional-requirements.txt index 154612b3..399acec4 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -14,6 +14,8 @@ six==1.10.0 # goodreads goodreads>=0.3.2 python-Levenshtein>=0.12.0 +# ldap login +python_ldap>=3.0.0 # other lxml>=3.8.0 rarfile>=2.7 From 1fc4bc5204f6999d445c834da9e8a313ea44e0ec Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sat, 9 Feb 2019 19:01:57 +0100 Subject: [PATCH 19/96] Fix routes Fix error page --- cps/shelf.py | 12 ++++++------ cps/templates/http_error.html | 2 +- cps/web.py | 7 +++---- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/cps/shelf.py b/cps/shelf.py index 28ee4a3d..6300a0ce 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -22,7 +22,7 @@ # along with this program. If not, see . from flask import Blueprint, request, flash, redirect, url_for -from cps import ub +from cps import ub, searched_ids from flask_babel import gettext as _ from sqlalchemy.sql.expression import func, or_ from flask_login import login_required, current_user @@ -104,18 +104,18 @@ def search_to_shelf(shelf_id): flash(_(u"User is not allowed to edit public shelves"), category="error") return redirect(url_for('index')) - if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]: + if current_user.id in searched_ids and searched_ids[current_user.id]: books_for_shelf = list() books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all() if books_in_shelf: book_ids = list() for book_id in books_in_shelf: book_ids.append(book_id.book_id) - for id in ub.searched_ids[current_user.id]: + for id in searched_ids[current_user.id]: if id not in book_ids: books_for_shelf.append(id) else: - books_for_shelf = ub.searched_ids[current_user.id] + books_for_shelf = searched_ids[current_user.id] if not books_for_shelf: app.logger.info("Books are already part of the shelf: %s" % shelf.name) @@ -136,7 +136,7 @@ def search_to_shelf(shelf_id): flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success") else: flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) @shelf.route("/shelf/remove//") @@ -259,7 +259,7 @@ def delete_shelf(shelf_id): ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete() ub.session.commit() app.logger.info(_(u"successfully deleted shelf %(name)s", name=cur_shelf.name, category="success")) - return redirect(url_for('index')) + return redirect(url_for('web.index')) @shelf.route("/shelf/") diff --git a/cps/templates/http_error.html b/cps/templates/http_error.html index 98763cdc..36471fce 100644 --- a/cps/templates/http_error.html +++ b/cps/templates/http_error.html @@ -17,7 +17,7 @@ {% endif %} -
    +

    {{ error_code }}

    {{ error_name }}

    {{_('Back to home')}} diff --git a/cps/web.py b/cps/web.py index 0a4a6e9d..ae577e7e 100644 --- a/cps/web.py +++ b/cps/web.py @@ -23,7 +23,7 @@ from cps import mimetypes, global_WorkerThread, searched_ids from flask import render_template, request, redirect, url_for, send_from_directory, make_response, g, flash, abort -# from werkzeug.exceptions import default_exceptions +from werkzeug.exceptions import default_exceptions import helper import os # from sqlalchemy.sql.expression import func @@ -101,7 +101,7 @@ EXTENSIONS_AUDIO = {'mp3', 'm4a', 'm4b'} # EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] + (['rar','cbr'] if rar_support else [])) -''''# custom error page +# custom error page def error_http(error): return render_template('http_error.html', error_code=error.code, @@ -115,7 +115,7 @@ for ex in default_exceptions: if ex < 500: app.register_error_handler(ex, error_http) -''' + web = Blueprint('web', __name__) @@ -967,7 +967,6 @@ def advanced_search(): db.session.connection().connection.connection.create_function("lower", 1, db.lcase) q = db.session.query(db.Books) - # postargs = request.form.to_dict() include_tag_inputs = request.args.getlist('include_tag') exclude_tag_inputs = request.args.getlist('exclude_tag') From c1ef1bcd19bd91bf21f211825fb52dcdf1fe756a Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sat, 9 Feb 2019 21:26:17 +0100 Subject: [PATCH 20/96] User and admin pages are working again --- cps/admin.py | 6 +++--- cps/ldap.py | 39 ++++++++++++++++++++++++++++++++++++ cps/oauth_bb.py | 29 +++++++++++++++++++++------ cps/ub.py | 22 +++++++++++++++----- cps/web.py | 53 +++++++++++++++++++++++-------------------------- 5 files changed, 107 insertions(+), 42 deletions(-) create mode 100644 cps/ldap.py diff --git a/cps/admin.py b/cps/admin.py index f0e90e5c..1b8824ca 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -38,7 +38,7 @@ from sqlalchemy.exc import IntegrityError from gdriveutils import is_gdrive_ready, gdrive_support, downloadFile, deleteDatabaseOnChange, listRootFolders import helper from werkzeug.security import generate_password_hash -from sqlalchemy.sql.expression import text +from oauth_bb import oauth_check try: from goodreads.client import GoodreadsClient @@ -591,7 +591,7 @@ def new_user(): content.sidebar_view = config.config_default_show content.mature_content = bool(config.config_default_show & ub.MATURE_CONTENT) return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - languages=languages, title=_(u"Add new user"), page="newuser") + languages=languages, title=_(u"Add new user"), page="newuser", registered_oauth=oauth_check) @admi.route("/admin/mailsettings", methods=["GET", "POST"]) @@ -767,7 +767,7 @@ def edit_user(user_id): flash(_(u"An unknown error occured."), category="error") return render_title_template("user_edit.html", translations=translations, languages=languages, new_user=0, content=content, downloads=downloads, title=_(u"Edit User %(nick)s", - nick=content.nickname), page="edituser") + nick=content.nickname), page="edituser", registered_oauth=oauth_check) @admi.route("/admin/resetpassword/") diff --git a/cps/ldap.py b/cps/ldap.py new file mode 100644 index 00000000..93995b3e --- /dev/null +++ b/cps/ldap.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2019 Krakinou +# +# This program 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. +# +# This program 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 this program. If not, see . + +import ldap +from cps import ub, app, request +from flask import flash, url_for +from redirect import redirect_back +from flask_login import login_user +from flask_babel import gettext as _ + +def login(form, user): + try: + ub.User.try_login(form['username'], form['password']) + login_user(user, remember=True) + flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") + return redirect_back(url_for("web.index")) + except ldap.INVALID_CREDENTIALS: + ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) + app.logger.info('LDAP Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) + flash(_(u"Wrong Username or Password"), category="error") + +def logout(): + pass diff --git a/cps/oauth_bb.py b/cps/oauth_bb.py index 0be03617..7cfe1d92 100644 --- a/cps/oauth_bb.py +++ b/cps/oauth_bb.py @@ -25,16 +25,33 @@ from flask_dance.contrib.google import make_google_blueprint, google from flask_dance.consumer import oauth_authorized, oauth_error from sqlalchemy.orm.exc import NoResultFound from oauth import OAuthBackend -from flask import flash, session, redirect, url_for, request +from flask import flash, session, redirect, url_for, request, make_response, abort +import json from cps import config, app import ub from flask_login import login_user, login_required, current_user from flask_babel import gettext as _ -from web import github_oauth_required +# from web import github_oauth_required +from functools import wraps oauth_check = {} +def github_oauth_required(f): + @wraps(f) + def inner(*args, **kwargs): + if config.config_use_github_oauth: + return f(*args, **kwargs) + if request.is_xhr: + data = {'status': 'error', 'message': 'Not Found'} + response = make_response(json.dumps(data, ensure_ascii=False)) + response.headers["Content-Type"] = "application/json; charset=utf-8" + return response, 404 + abort(404) + + return inner + + def register_oauth_blueprint(blueprint, show_name): if blueprint.name != "": oauth_check[blueprint.name] = show_name @@ -246,7 +263,7 @@ def github_error(blueprint, error, error_description=None, error_uri=None): ) flash(msg, category="error") - +''' @oauth.route('/github') @github_oauth_required def github_login(): @@ -277,7 +294,7 @@ def google_login(): return bind_oauth_or_register(google_blueprint.name, account_info_json['id'], 'google.login') flash(_(u"Google Oauth error, please retry later."), category="error") return redirect(url_for('login')) - +''' @oauth_error.connect_via(google_blueprint) def google_error(blueprint, error, error_description=None, error_uri=None): @@ -292,8 +309,8 @@ def google_error(blueprint, error, error_description=None, error_uri=None): ) flash(msg, category="error") - +''' @oauth.route('/unlink/google', methods=["GET"]) @login_required def google_login_unlink(): - return unlink_oauth(google_blueprint.name) + return unlink_oauth(google_blueprint.name)''' diff --git a/cps/ub.py b/cps/ub.py index fa8a86e5..59f0b613 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -23,6 +23,7 @@ from sqlalchemy import exc from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import * from flask_login import AnonymousUserMixin +from flask_dance.consumer.backend.sqla import OAuthConsumerMixin import sys import os import logging @@ -32,6 +33,11 @@ import datetime from binascii import hexlify import cli +try: + import ldap +except ImportError: + pass + engine = create_engine('sqlite:///{0}'.format(cli.settingspath), echo=False) Base = declarative_base() @@ -176,13 +182,12 @@ class UserBase: return '' % self.nickname #Login via LDAP method - ''''@staticmethod + @staticmethod def try_login(username, password): conn = get_ldap_connection() conn.simple_bind_s( config.config_ldap_dn.replace("%s", username), - password - )''' + password) # Baseclass for Users in Calibre-Web, settings which are depending on certain users are stored here. It is derived from # User Base (all access methods are declared there) @@ -202,11 +207,11 @@ class User(UserBase, Base): default_language = Column(String(3), default="all") mature_content = Column(Boolean, default=True) -''' + class OAuth(OAuthConsumerMixin, Base): provider_user_id = Column(String(256)) user_id = Column(Integer, ForeignKey(User.id)) - user = relationship(User)''' + user = relationship(User) # Class for anonymous user is derived from User base and completly overrides methods and properties for the @@ -776,6 +781,13 @@ def clean_database(): session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).delete() +#get LDAP connection +def get_ldap_connection(): + conn = ldap.initialize('ldap://{}'.format(config.config_ldap_provider_url)) + return conn + + + def create_default_config(): settings = Settings() settings.mail_server = "mail.example.com" diff --git a/cps/web.py b/cps/web.py index ae577e7e..91cc8ff0 100644 --- a/cps/web.py +++ b/cps/web.py @@ -26,8 +26,6 @@ from flask import render_template, request, redirect, url_for, send_from_directo from werkzeug.exceptions import default_exceptions import helper import os -# from sqlalchemy.sql.expression import func -# from sqlalchemy.sql.expression import false from sqlalchemy.exc import IntegrityError from flask_login import login_user, logout_user, login_required, current_user from flask_babel import gettext as _ @@ -36,21 +34,31 @@ from werkzeug.datastructures import Headers from babel import Locale as LC from babel.dates import format_date from babel.core import UnknownLocaleError -from functools import wraps import base64 from sqlalchemy.sql import * import json import datetime from iso639 import languages as isoLanguages -import os.path import re import db import gdriveutils from redirect import redirect_back from cps import lm, babel, ub, config, get_locale, language_table, app from pagination import Pagination -# from admin import check_valid_domain -# from oauth_bb import oauth_check, register_user_with_oauth +from sqlalchemy.sql.expression import text + +from oauth_bb import oauth_check, register_user_with_oauth, logout_oauth_user, get_oauth_status + +'''try: + oauth_support = True +except ImportError: + oauth_support = False''' + +try: + import ldap + ldap_support = True +except ImportError: + ldap_support = False try: from googleapiclient.errors import HttpError @@ -70,7 +78,7 @@ except ImportError: levenshtein_support = False try: - from functools import reduce + from functools import reduce, wraps except ImportError: pass # We're not using Python 3 @@ -169,21 +177,6 @@ def remote_login_required(f): return inner -def github_oauth_required(f): - @wraps(f) - def inner(*args, **kwargs): - if config.config_use_github_oauth: - return f(*args, **kwargs) - if request.is_xhr: - data = {'status': 'error', 'message': 'Not Found'} - response = make_response(json.dumps(data, ensure_ascii=False)) - response.headers["Content-Type"] = "application/json; charset=utf-8" - return response, 404 - abort(404) - - return inner - - def google_oauth_required(f): @wraps(f) def inner(*args, **kwargs): @@ -1299,7 +1292,8 @@ def register(): try: ub.session.add(content) ub.session.commit() - # register_user_with_oauth(content) + if oauth_support: + register_user_with_oauth(content) helper.send_registration_mail(to_save["email"], to_save["nickname"], password) except Exception: ub.session.rollback() @@ -1316,7 +1310,8 @@ def register(): flash(_(u"This username or e-mail address is already in use."), category="error") return render_title_template('register.html', title=_(u"register"), page="register") - # register_user_with_oauth() + if oauth_support: + register_user_with_oauth() return render_title_template('register.html', config=config, title=_(u"register"), page="register") @@ -1330,7 +1325,7 @@ def login(): form = request.form.to_dict() user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower())\ .first() - if config.config_use_ldap and user: + '''if config.config_use_ldap and user: import ldap try: ub.User.try_login(form['username'], form['password']) @@ -1341,7 +1336,8 @@ def login(): ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) app.logger.info('LDAP Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) flash(_(u"Wrong Username or Password"), category="error") - elif user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest": + el''' + if user and check_password_hash(user.password, form['password']) and user.nickname is not "Guest": login_user(user, remember=True) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") return redirect_back(url_for("web.index")) @@ -1362,7 +1358,8 @@ def login(): def logout(): if current_user is not None and current_user.is_authenticated: logout_user() - # logout_oauth_user() + if oauth_support: + logout_oauth_user() return redirect(url_for('web.login')) @@ -1475,7 +1472,7 @@ def profile(): downloads = list() languages = speaking_language() translations = babel.list_translations() + [LC('en')] - oauth_status = None # oauth_status = get_oauth_status() + oauth_status = get_oauth_status() for book in content.downloads: downloadBook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() if downloadBook: From 423022671620bcd5805a313473d4e277ea51d87b Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sat, 16 Feb 2019 07:23:08 +0100 Subject: [PATCH 21/96] Link fixes Fixes reader button visible in detail view Fix formats to convert (added htmlz) Fix logger in updater Added request "v3" of github api on update Fix quotes parameter on external calls E-Mail logger working more stable (also on python3) Routing fixes Change import in ub --- cps/admin.py | 73 +-- cps/editbooks.py | 12 +- cps/gdrive.py | 6 +- cps/helper.py | 47 +- cps/oauth.py | 255 ++++---- cps/oauth_bb.py | 399 ++++++------ cps/opds.py | 3 +- cps/shelf.py | 24 +- cps/templates/book_edit.html | 4 +- cps/templates/config_edit.html | 6 +- cps/templates/detail.html | 17 +- cps/templates/index.xml | 57 +- cps/templates/json.txt | 52 +- cps/templates/layout.html | 2 +- cps/templates/osd.xml | 4 +- cps/ub.py | 16 +- cps/updater.py | 54 +- cps/web.py | 73 ++- cps/worker.py | 57 +- optional-requirements.txt | 7 + requirements.txt | 2 - test/Calibre-Web TestSummary.html | 1005 +++++++++++++---------------- 22 files changed, 1062 insertions(+), 1113 deletions(-) diff --git a/cps/admin.py b/cps/admin.py index 1b8824ca..d5be6839 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -21,6 +21,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import logging import os from flask import Blueprint, flash, redirect, url_for from flask import abort, request, make_response @@ -39,20 +40,26 @@ from gdriveutils import is_gdrive_ready, gdrive_support, downloadFile, deleteDat import helper from werkzeug.security import generate_password_hash from oauth_bb import oauth_check +try: + from urllib.parse import quote + from imp import reload +except ImportError: + from urllib import quote +feature_support = dict() try: from goodreads.client import GoodreadsClient - goodreads_support = True + feature_support['goodreads'] = True except ImportError: - goodreads_support = False + feature_support['goodreads'] = False try: import rarfile - rar_support = True + feature_support['rar'] = True except ImportError: - rar_support = False - + feature_support['rar'] = False +feature_support['gdrive'] = gdrive_support admi = Blueprint('admin', __name__) @@ -287,7 +294,7 @@ def configuration_helper(origin): db_change = False success = False filedata = None - if gdrive_support is False: + if not feature_support['gdrive']: gdriveError = _('Import of optional Google Drive requirements missing') else: if not os.path.isfile(os.path.join(config.get_main_dir, 'client_secrets.json')): @@ -327,7 +334,7 @@ def configuration_helper(origin): else: flash(_(u'client_secrets.json is not configured for web application'), category="error") return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdrive_support, gdriveError=gdriveError, + gdriveError=gdriveError, goodreads=goodreads_support, title=_(u"Basic Configuration"), page="config") # always show google drive settings, but in case of error deny support @@ -353,7 +360,7 @@ def configuration_helper(origin): ub.session.commit() flash(_(u'Keyfile location is not valid, please enter correct path'), category="error") return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdrive_support, gdriveError=gdriveError, + gdriveError=gdriveError, goodreads=goodreads_support, title=_(u"Basic Configuration"), page="config") if "config_certfile" in to_save: @@ -365,9 +372,8 @@ def configuration_helper(origin): ub.session.commit() flash(_(u'Certfile location is not valid, please enter correct path'), category="error") return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, title=_(u"Basic Configuration"), - page="config") + gdriveError=gdriveError, feature_support=feature_support, + title=_(u"Basic Configuration"), page="config") content.config_uploading = 0 content.config_anonbrowse = 0 content.config_public_reg = 0 @@ -391,9 +397,8 @@ def configuration_helper(origin): ub.session.commit() flash(_(u'Please enter a LDAP provider and a DN'), category="error") return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, title=_(u"Basic Configuration"), - page="config") + gdriveError=gdriveError, feature_support=feature_support, + title=_(u"Basic Configuration"), page="config") else: content.config_use_ldap = 1 content.config_ldap_provider_url = to_save["config_ldap_provider_url"] @@ -450,9 +455,8 @@ def configuration_helper(origin): ub.session.commit() flash(_(u'Logfile location is not valid, please enter correct path'), category="error") return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, title=_(u"Basic Configuration"), - page="config") + gdriveError=gdriveError, feature_support=feature_support, + title=_(u"Basic Configuration"), page="config") else: content.config_logfile = to_save["config_logfile"] reboot_required = True @@ -465,8 +469,7 @@ def configuration_helper(origin): else: flash(check[1], category="error") return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdrive_support, goodreads=goodreads_support, - rarfile_support=rar_support, title=_(u"Basic Configuration")) + feature_support=feature_support, title=_(u"Basic Configuration")) try: if content.config_use_google_drive and is_gdrive_ready() and not \ os.path.exists(os.path.join(content.config_calibre_dir, "metadata.db")): @@ -479,20 +482,18 @@ def configuration_helper(origin): flash(_(u"Calibre-Web configuration updated"), category="success") config.loadSettings() app.logger.setLevel(config.config_log_level) - logging.getLogger("book_formats").setLevel(config.config_log_level) + logging.getLogger("uploader").setLevel(config.config_log_level) except Exception as e: flash(e, category="error") return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, rarfile_support=rar_support, + gdriveError=gdriveError, feature_support=feature_support, title=_(u"Basic Configuration"), page="config") if db_change: reload(db) if not db.setup_db(): flash(_(u'DB location is not valid, please enter correct path'), category="error") return render_title_template("config_edit.html", content=config, origin=origin, - gdrive=gdrive_support, gdriveError=gdriveError, - goodreads=goodreads_support, rarfile_support=rar_support, + gdriveError=gdriveError, feature_support=feature_support, title=_(u"Basic Configuration"), page="config") if reboot_required: # stop Server @@ -501,15 +502,14 @@ def configuration_helper(origin): app.logger.info('Reboot required, restarting') if origin: success = True - if is_gdrive_ready() and gdrive_support is True: # and config.config_use_google_drive == True: + if is_gdrive_ready() and feature_support['gdrive'] is True: # and config.config_use_google_drive == True: gdrivefolders = listRootFolders() else: gdrivefolders = list() return render_title_template("config_edit.html", origin=origin, success=success, content=config, show_authenticate_google_drive=not is_gdrive_ready(), - gdrive=gdrive_support, gdriveError=gdriveError, - gdrivefolders=gdrivefolders, rarfile_support=rar_support, - goodreads=goodreads_support, title=_(u"Basic Configuration"), page="config") + gdriveError=gdriveError, gdrivefolders=gdrivefolders, feature_support=feature_support, + title=_(u"Basic Configuration"), page="config") @admi.route("/admin/user/new", methods=["GET", "POST"]) @@ -569,20 +569,20 @@ def new_user(): if not to_save["nickname"] or not to_save["email"] or not to_save["password"]: flash(_(u"Please fill out all fields!"), category="error") return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - title=_(u"Add new user")) + registered_oauth=oauth_check, title=_(u"Add new user")) content.password = generate_password_hash(to_save["password"]) content.nickname = to_save["nickname"] if config.config_public_reg and not check_valid_domain(to_save["email"]): flash(_(u"E-mail is not from valid domain"), category="error") return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - title=_(u"Add new user")) + registered_oauth=oauth_check, title=_(u"Add new user")) else: content.email = to_save["email"] try: ub.session.add(content) ub.session.commit() flash(_(u"User '%(user)s' created", user=content.nickname), category="success") - return redirect(url_for('admin')) + return redirect(url_for('admin.admin')) except IntegrityError: ub.session.rollback() flash(_(u"Found an existing account for this e-mail address or nickname."), category="error") @@ -591,7 +591,8 @@ def new_user(): content.sidebar_view = config.config_default_show content.mature_content = bool(config.config_default_show & ub.MATURE_CONTENT) return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - languages=languages, title=_(u"Add new user"), page="newuser", registered_oauth=oauth_check) + languages=languages, title=_(u"Add new user"), page="newuser", + registered_oauth=oauth_check) @admi.route("/admin/mailsettings", methods=["GET", "POST"]) @@ -649,7 +650,7 @@ def edit_user(user_id): ub.session.query(ub.User).filter(ub.User.id == content.id).delete() ub.session.commit() flash(_(u"User '%(nick)s' deleted", nick=content.nickname), category="success") - return redirect(url_for('admin')) + return redirect(url_for('admin.admin')) else: if "password" in to_save and to_save["password"]: content.password = generate_password_hash(to_save["password"]) @@ -766,8 +767,8 @@ def edit_user(user_id): ub.session.rollback() flash(_(u"An unknown error occured."), category="error") return render_title_template("user_edit.html", translations=translations, languages=languages, new_user=0, - content=content, downloads=downloads, title=_(u"Edit User %(nick)s", - nick=content.nickname), page="edituser", registered_oauth=oauth_check) + content=content, downloads=downloads, registered_oauth=oauth_check, + title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") @admi.route("/admin/resetpassword/") @@ -787,7 +788,7 @@ def reset_password(user_id): except Exception: ub.session.rollback() flash(_(u"An unknown error occurred. Please try again later."), category="error") - return redirect(url_for('admin')) + return redirect(url_for('admin.admin')) @admi.route("/get_update_status", methods=['GET']) diff --git a/cps/editbooks.py b/cps/editbooks.py index 2c20b8a9..c758bb18 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -42,7 +42,7 @@ from iso639 import languages as isoLanguages editbook = Blueprint('editbook', __name__) -EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'html', 'rtf', 'odt'} +EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt'} EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'odt', 'mp3', 'm4a', 'm4b'} @@ -380,7 +380,7 @@ def upload_single_file(request, book, book_id): # Queue uploader info uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=book.title) global_WorkerThread.add_upload(current_user.nickname, - "" + uploadText + "") + "" + uploadText + "") def upload_cover(request, book): if 'btn-upload-cover' in request.files: @@ -589,10 +589,10 @@ def upload(): flash( _("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) else: flash(_('File to be uploaded must have an extension'), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) # extract metadata from file meta = uploader.upload(requested_file) @@ -612,12 +612,12 @@ def upload(): os.makedirs(filepath) except OSError: flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) try: copyfile(meta.file_path, saved_filename) except OSError: flash(_(u"Failed to store file %(file)s (Permission denied).", file=saved_filename), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) try: os.unlink(meta.file_path) except OSError: diff --git a/cps/gdrive.py b/cps/gdrive.py index 3cd9c1dc..025c2d65 100644 --- a/cps/gdrive.py +++ b/cps/gdrive.py @@ -70,7 +70,7 @@ def google_drive_callback(): f.write(credentials.to_json()) except ValueError as error: app.logger.error(error) - return redirect(url_for('configuration')) + return redirect(url_for('admin.configuration')) @gdrive.route("/gdrive/watch/subscribe") @@ -102,7 +102,7 @@ def watch_gdrive(): else: flash(reason['message'], category="error") - return redirect(url_for('configuration')) + return redirect(url_for('admin.configuration')) @gdrive.route("/gdrive/watch/revoke") @@ -121,7 +121,7 @@ def revoke_watch_gdrive(): ub.session.merge(settings) ub.session.commit() config.loadSettings() - return redirect(url_for('configuration')) + return redirect(url_for('admin.configuration')) @gdrive.route("/gdrive/watch/callback", methods=['GET', 'POST']) diff --git a/cps/helper.py b/cps/helper.py index ba323449..16530410 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -19,13 +19,13 @@ # along with this program. If not, see . -import db -from cps import config +from cps import config, global_WorkerThread, get_locale from flask import current_app as app from tempfile import gettempdir import sys import os import re +import db import unicodedata import worker import time @@ -40,7 +40,6 @@ try: import gdriveutils as gd except ImportError: pass -# import web import random from subproc_wrapper import process_open import ub @@ -244,7 +243,7 @@ def get_sorted_author(value): else: value2 = value except Exception: - web.app.logger.error("Sorting author " + str(value) + "failed") + app.logger.error("Sorting author " + str(value) + "failed") value2 = value return value2 @@ -261,13 +260,13 @@ def delete_book_file(book, calibrepath, book_format=None): else: if os.path.isdir(path): if len(next(os.walk(path))[1]): - web.app.logger.error( + app.logger.error( "Deleting book " + str(book.id) + " failed, path has subfolders: " + book.path) return False shutil.rmtree(path, ignore_errors=True) return True else: - web.app.logger.error("Deleting book " + str(book.id) + " failed, book path not valid: " + book.path) + app.logger.error("Deleting book " + str(book.id) + " failed, book path not valid: " + book.path) return False @@ -290,7 +289,7 @@ def update_dir_structure_file(book_id, calibrepath, first_author): if not os.path.exists(new_title_path): os.renames(path, new_title_path) else: - web.app.logger.info("Copying title: " + path + " into existing: " + new_title_path) + app.logger.info("Copying title: " + path + " into existing: " + new_title_path) for dir_name, subdir_list, file_list in os.walk(path): for file in file_list: os.renames(os.path.join(dir_name, file), @@ -298,8 +297,8 @@ def update_dir_structure_file(book_id, calibrepath, first_author): path = new_title_path localbook.path = localbook.path.split('/')[0] + '/' + new_titledir except OSError as ex: - web.app.logger.error("Rename title from: " + path + " to " + new_title_path + ": " + str(ex)) - web.app.logger.debug(ex, exc_info=True) + app.logger.error("Rename title from: " + path + " to " + new_title_path + ": " + str(ex)) + app.logger.debug(ex, exc_info=True) return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s", src=path, dest=new_title_path, error=str(ex)) if authordir != new_authordir: @@ -308,8 +307,8 @@ def update_dir_structure_file(book_id, calibrepath, first_author): os.renames(path, new_author_path) localbook.path = new_authordir + '/' + localbook.path.split('/')[1] except OSError as ex: - web.app.logger.error("Rename author from: " + path + " to " + new_author_path + ": " + str(ex)) - web.app.logger.debug(ex, exc_info=True) + app.logger.error("Rename author from: " + path + " to " + new_author_path + ": " + str(ex)) + app.logger.debug(ex, exc_info=True) return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s", src=path, dest=new_author_path, error=str(ex)) # Rename all files from old names to new names @@ -322,8 +321,8 @@ def update_dir_structure_file(book_id, calibrepath, first_author): os.path.join(path_name,new_name + '.' + file_format.format.lower())) file_format.name = new_name except OSError as ex: - web.app.logger.error("Rename file in path " + path + " to " + new_name + ": " + str(ex)) - web.app.logger.debug(ex, exc_info=True) + app.logger.error("Rename file in path " + path + " to " + new_name + ": " + str(ex)) + app.logger.debug(ex, exc_info=True) return _("Rename file in path '%(src)s' to '%(dest)s' failed with error: %(error)s", src=path, dest=new_name, error=str(ex)) return False @@ -418,17 +417,17 @@ def delete_book(book, calibrepath, book_format): def get_book_cover(cover_path): if config.config_use_google_drive: try: - if not web.is_gdrive_ready(): + if not gd.is_gdrive_ready(): return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg") path=gd.get_cover_via_gdrive(cover_path) if path: return redirect(path) else: - web.app.logger.error(cover_path + '/cover.jpg not found on Google Drive') + app.logger.error(cover_path + '/cover.jpg not found on Google Drive') return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg") except Exception as e: - web.app.logger.error("Error Message: " + e.message) - web.app.logger.exception(e) + app.logger.error("Error Message: " + e.message) + app.logger.exception(e) # traceback.print_exc() return send_from_directory(os.path.join(os.path.dirname(__file__), "static"),"generic_cover.jpg") else: @@ -439,7 +438,7 @@ def get_book_cover(cover_path): def save_cover(url, book_path): img = requests.get(url) if img.headers.get('content-type') != 'image/jpeg': - web.app.logger.error("Cover is no jpg file, can't save") + app.logger.error("Cover is no jpg file, can't save") return False if config.config_use_google_drive: @@ -448,13 +447,13 @@ def save_cover(url, book_path): f.write(img.content) f.close() gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg'), os.path.join(tmpDir, f.name)) - web.app.logger.info("Cover is saved on Google Drive") + app.logger.info("Cover is saved on Google Drive") return True f = open(os.path.join(config.config_calibre_dir, book_path, "cover.jpg"), "wb") f.write(img.content) f.close() - web.app.logger.info("Cover is saved") + app.logger.info("Cover is saved") return True @@ -462,7 +461,7 @@ def do_download_file(book, book_format, data, headers): if config.config_use_google_drive: startTime = time.time() df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format) - web.app.logger.debug(time.time() - startTime) + app.logger.debug(time.time() - startTime) if df: return gd.do_gdrive_download(df, headers) else: @@ -471,7 +470,7 @@ def do_download_file(book, book_format, data, headers): filename = os.path.join(config.config_calibre_dir, book.path) if not os.path.isfile(os.path.join(filename, data.name + "." + book_format)): # ToDo: improve error handling - web.app.logger.error('File not found: %s' % os.path.join(filename, data.name + "." + book_format)) + app.logger.error('File not found: %s' % os.path.join(filename, data.name + "." + book_format)) response = make_response(send_from_directory(filename, data.name + "." + book_format)) response.headers = headers return response @@ -497,7 +496,7 @@ def check_unrar(unrarLocation): version = value.group(1) except OSError as e: error = True - web.app.logger.exception(e) + app.logger.exception(e) version =_(u'Error excecuting UnRar') else: version = _(u'Unrar binary file not found') @@ -522,7 +521,7 @@ def render_task_status(tasklist): if task['user'] == current_user.nickname or current_user.role_admin(): # task2 = copy.deepcopy(task) # = task if task['formStarttime']: - task['starttime'] = format_datetime(task['formStarttime'], format='short', locale=web.get_locale()) + task['starttime'] = format_datetime(task['formStarttime'], format='short', locale=get_locale()) # task2['formStarttime'] = "" else: if 'starttime' not in task: diff --git a/cps/oauth.py b/cps/oauth.py index 679e7f31..960a3810 100644 --- a/cps/oauth.py +++ b/cps/oauth.py @@ -2,133 +2,136 @@ # -*- coding: utf-8 -*- from flask import session -from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user -from sqlalchemy.orm.exc import NoResultFound - - -class OAuthBackend(SQLAlchemyBackend): - """ - Stores and retrieves OAuth tokens using a relational database through - the `SQLAlchemy`_ ORM. - - .. _SQLAlchemy: http://www.sqlalchemy.org/ - """ - def __init__(self, model, session, - user=None, user_id=None, user_required=None, anon_user=None, - cache=None): - super(OAuthBackend, self).__init__(model, session, user, user_id, user_required, anon_user, cache) - - def get(self, blueprint, user=None, user_id=None): - if blueprint.name + '_oauth_token' in session and session[blueprint.name + '_oauth_token'] != '': - return session[blueprint.name + '_oauth_token'] - # check cache - cache_key = self.make_cache_key(blueprint=blueprint, user=user, user_id=user_id) - token = self.cache.get(cache_key) - if token: +try: + from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user + from sqlalchemy.orm.exc import NoResultFound + + class OAuthBackend(SQLAlchemyBackend): + """ + Stores and retrieves OAuth tokens using a relational database through + the `SQLAlchemy`_ ORM. + + .. _SQLAlchemy: http://www.sqlalchemy.org/ + """ + def __init__(self, model, session, + user=None, user_id=None, user_required=None, anon_user=None, + cache=None): + super(OAuthBackend, self).__init__(model, session, user, user_id, user_required, anon_user, cache) + + def get(self, blueprint, user=None, user_id=None): + if blueprint.name + '_oauth_token' in session and session[blueprint.name + '_oauth_token'] != '': + return session[blueprint.name + '_oauth_token'] + # check cache + cache_key = self.make_cache_key(blueprint=blueprint, user=user, user_id=user_id) + token = self.cache.get(cache_key) + if token: + return token + + # if not cached, make database queries + query = ( + self.session.query(self.model) + .filter_by(provider=blueprint.name) + ) + uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) + u = first(_get_real_user(ref, self.anon_user) + for ref in (user, self.user, blueprint.config.get("user"))) + + use_provider_user_id = False + if blueprint.name + '_oauth_user_id' in session and session[blueprint.name + '_oauth_user_id'] != '': + query = query.filter_by(provider_user_id=session[blueprint.name + '_oauth_user_id']) + use_provider_user_id = True + + if self.user_required and not u and not uid and not use_provider_user_id: + #raise ValueError("Cannot get OAuth token without an associated user") + return None + # check for user ID + if hasattr(self.model, "user_id") and uid: + query = query.filter_by(user_id=uid) + # check for user (relationship property) + elif hasattr(self.model, "user") and u: + query = query.filter_by(user=u) + # if we have the property, but not value, filter by None + elif hasattr(self.model, "user_id"): + query = query.filter_by(user_id=None) + # run query + try: + token = query.one().token + except NoResultFound: + token = None + + # cache the result + self.cache.set(cache_key, token) + return token - # if not cached, make database queries - query = ( - self.session.query(self.model) - .filter_by(provider=blueprint.name) - ) - uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) - u = first(_get_real_user(ref, self.anon_user) - for ref in (user, self.user, blueprint.config.get("user"))) - - use_provider_user_id = False - if blueprint.name + '_oauth_user_id' in session and session[blueprint.name + '_oauth_user_id'] != '': - query = query.filter_by(provider_user_id=session[blueprint.name + '_oauth_user_id']) - use_provider_user_id = True - - if self.user_required and not u and not uid and not use_provider_user_id: - #raise ValueError("Cannot get OAuth token without an associated user") - return None - # check for user ID - if hasattr(self.model, "user_id") and uid: - query = query.filter_by(user_id=uid) - # check for user (relationship property) - elif hasattr(self.model, "user") and u: - query = query.filter_by(user=u) - # if we have the property, but not value, filter by None - elif hasattr(self.model, "user_id"): - query = query.filter_by(user_id=None) - # run query - try: - token = query.one().token - except NoResultFound: - token = None - - # cache the result - self.cache.set(cache_key, token) - - return token - - def set(self, blueprint, token, user=None, user_id=None): - uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) - u = first(_get_real_user(ref, self.anon_user) + def set(self, blueprint, token, user=None, user_id=None): + uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) + u = first(_get_real_user(ref, self.anon_user) + for ref in (user, self.user, blueprint.config.get("user"))) + + if self.user_required and not u and not uid: + raise ValueError("Cannot set OAuth token without an associated user") + + # if there was an existing model, delete it + existing_query = ( + self.session.query(self.model) + .filter_by(provider=blueprint.name) + ) + # check for user ID + has_user_id = hasattr(self.model, "user_id") + if has_user_id and uid: + existing_query = existing_query.filter_by(user_id=uid) + # check for user (relationship property) + has_user = hasattr(self.model, "user") + if has_user and u: + existing_query = existing_query.filter_by(user=u) + # queue up delete query -- won't be run until commit() + existing_query.delete() + # create a new model for this token + kwargs = { + "provider": blueprint.name, + "token": token, + } + if has_user_id and uid: + kwargs["user_id"] = uid + if has_user and u: + kwargs["user"] = u + self.session.add(self.model(**kwargs)) + # commit to delete and add simultaneously + self.session.commit() + # invalidate cache + self.cache.delete(self.make_cache_key( + blueprint=blueprint, user=user, user_id=user_id + )) + + def delete(self, blueprint, user=None, user_id=None): + query = ( + self.session.query(self.model) + .filter_by(provider=blueprint.name) + ) + uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) + u = first(_get_real_user(ref, self.anon_user) for ref in (user, self.user, blueprint.config.get("user"))) - if self.user_required and not u and not uid: - raise ValueError("Cannot set OAuth token without an associated user") - - # if there was an existing model, delete it - existing_query = ( - self.session.query(self.model) - .filter_by(provider=blueprint.name) - ) - # check for user ID - has_user_id = hasattr(self.model, "user_id") - if has_user_id and uid: - existing_query = existing_query.filter_by(user_id=uid) - # check for user (relationship property) - has_user = hasattr(self.model, "user") - if has_user and u: - existing_query = existing_query.filter_by(user=u) - # queue up delete query -- won't be run until commit() - existing_query.delete() - # create a new model for this token - kwargs = { - "provider": blueprint.name, - "token": token, - } - if has_user_id and uid: - kwargs["user_id"] = uid - if has_user and u: - kwargs["user"] = u - self.session.add(self.model(**kwargs)) - # commit to delete and add simultaneously - self.session.commit() - # invalidate cache - self.cache.delete(self.make_cache_key( - blueprint=blueprint, user=user, user_id=user_id - )) - - def delete(self, blueprint, user=None, user_id=None): - query = ( - self.session.query(self.model) - .filter_by(provider=blueprint.name) - ) - uid = first([user_id, self.user_id, blueprint.config.get("user_id")]) - u = first(_get_real_user(ref, self.anon_user) - for ref in (user, self.user, blueprint.config.get("user"))) - - if self.user_required and not u and not uid: - raise ValueError("Cannot delete OAuth token without an associated user") - - # check for user ID - if hasattr(self.model, "user_id") and uid: - query = query.filter_by(user_id=uid) - # check for user (relationship property) - elif hasattr(self.model, "user") and u: - query = query.filter_by(user=u) - # if we have the property, but not value, filter by None - elif hasattr(self.model, "user_id"): - query = query.filter_by(user_id=None) - # run query - query.delete() - self.session.commit() - # invalidate cache - self.cache.delete(self.make_cache_key( - blueprint=blueprint, user=user, user_id=user_id, - )) + if self.user_required and not u and not uid: + raise ValueError("Cannot delete OAuth token without an associated user") + + # check for user ID + if hasattr(self.model, "user_id") and uid: + query = query.filter_by(user_id=uid) + # check for user (relationship property) + elif hasattr(self.model, "user") and u: + query = query.filter_by(user=u) + # if we have the property, but not value, filter by None + elif hasattr(self.model, "user_id"): + query = query.filter_by(user_id=None) + # run query + query.delete() + self.session.commit() + # invalidate cache + self.cache.delete(self.make_cache_key( + blueprint=blueprint, user=user, user_id=user_id, + )) + +except ImportError: + pass diff --git a/cps/oauth_bb.py b/cps/oauth_bb.py index 7cfe1d92..830292e0 100644 --- a/cps/oauth_bb.py +++ b/cps/oauth_bb.py @@ -20,11 +20,14 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see -from flask_dance.contrib.github import make_github_blueprint, github -from flask_dance.contrib.google import make_google_blueprint, google -from flask_dance.consumer import oauth_authorized, oauth_error +try: + from flask_dance.contrib.github import make_github_blueprint, github + from flask_dance.contrib.google import make_google_blueprint, google + from flask_dance.consumer import oauth_authorized, oauth_error + from oauth import OAuthBackend +except ImportError: + pass from sqlalchemy.orm.exc import NoResultFound -from oauth import OAuthBackend from flask import flash, session, redirect, url_for, request, make_response, abort import json from cps import config, app @@ -91,226 +94,226 @@ def logout_oauth_user(): if oauth + '_oauth_user_id' in session: session.pop(oauth + '_oauth_user_id') +if ub.oauth_support: + github_blueprint = make_github_blueprint( + client_id=config.config_github_oauth_client_id, + client_secret=config.config_github_oauth_client_secret, + redirect_to="github_login",) + + google_blueprint = make_google_blueprint( + client_id=config.config_google_oauth_client_id, + client_secret=config.config_google_oauth_client_secret, + redirect_to="google_login", + scope=[ + "https://www.googleapis.com/auth/plus.me", + "https://www.googleapis.com/auth/userinfo.email", + ] + ) -github_blueprint = make_github_blueprint( - client_id=config.config_github_oauth_client_id, - client_secret=config.config_github_oauth_client_secret, - redirect_to="github_login",) - -google_blueprint = make_google_blueprint( - client_id=config.config_google_oauth_client_id, - client_secret=config.config_google_oauth_client_secret, - redirect_to="google_login", - scope=[ - "https://www.googleapis.com/auth/plus.me", - "https://www.googleapis.com/auth/userinfo.email", - ] -) - -app.register_blueprint(google_blueprint, url_prefix="/login") -app.register_blueprint(github_blueprint, url_prefix='/login') + app.register_blueprint(google_blueprint, url_prefix="/login") + app.register_blueprint(github_blueprint, url_prefix='/login') -github_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) -google_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) + github_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) + google_blueprint.backend = OAuthBackend(ub.OAuth, ub.session, user=current_user, user_required=True) -if config.config_use_github_oauth: - register_oauth_blueprint(github_blueprint, 'GitHub') -if config.config_use_google_oauth: - register_oauth_blueprint(google_blueprint, 'Google') + if config.config_use_github_oauth: + register_oauth_blueprint(github_blueprint, 'GitHub') + if config.config_use_google_oauth: + register_oauth_blueprint(google_blueprint, 'Google') -@oauth_authorized.connect_via(github_blueprint) -def github_logged_in(blueprint, token): - if not token: - flash(_("Failed to log in with GitHub."), category="error") - return False + @oauth_authorized.connect_via(github_blueprint) + def github_logged_in(blueprint, token): + if not token: + flash(_("Failed to log in with GitHub."), category="error") + return False - resp = blueprint.session.get("/user") - if not resp.ok: - flash(_("Failed to fetch user info from GitHub."), category="error") - return False + resp = blueprint.session.get("/user") + if not resp.ok: + flash(_("Failed to fetch user info from GitHub."), category="error") + return False - github_info = resp.json() - github_user_id = str(github_info["id"]) - return oauth_update_token(blueprint, token, github_user_id) + github_info = resp.json() + github_user_id = str(github_info["id"]) + return oauth_update_token(blueprint, token, github_user_id) -@oauth_authorized.connect_via(google_blueprint) -def google_logged_in(blueprint, token): - if not token: - flash(_("Failed to log in with Google."), category="error") - return False + @oauth_authorized.connect_via(google_blueprint) + def google_logged_in(blueprint, token): + if not token: + flash(_("Failed to log in with Google."), category="error") + return False - resp = blueprint.session.get("/oauth2/v2/userinfo") - if not resp.ok: - flash(_("Failed to fetch user info from Google."), category="error") - return False + resp = blueprint.session.get("/oauth2/v2/userinfo") + if not resp.ok: + flash(_("Failed to fetch user info from Google."), category="error") + return False - google_info = resp.json() - google_user_id = str(google_info["id"]) + google_info = resp.json() + google_user_id = str(google_info["id"]) - return oauth_update_token(blueprint, token, google_user_id) + return oauth_update_token(blueprint, token, google_user_id) -def oauth_update_token(blueprint, token, provider_user_id): - session[blueprint.name + "_oauth_user_id"] = provider_user_id - session[blueprint.name + "_oauth_token"] = token + def oauth_update_token(blueprint, token, provider_user_id): + session[blueprint.name + "_oauth_user_id"] = provider_user_id + session[blueprint.name + "_oauth_token"] = token - # Find this OAuth token in the database, or create it - query = ub.session.query(ub.OAuth).filter_by( - provider=blueprint.name, - provider_user_id=provider_user_id, - ) - try: - oauth = query.one() - # update token - oauth.token = token - except NoResultFound: - oauth = ub.OAuth( + # Find this OAuth token in the database, or create it + query = ub.session.query(ub.OAuth).filter_by( provider=blueprint.name, provider_user_id=provider_user_id, - token=token, ) - try: - ub.session.add(oauth) - ub.session.commit() - except Exception as e: - app.logger.exception(e) - ub.session.rollback() - - # Disable Flask-Dance's default behavior for saving the OAuth token - return False + try: + oauth = query.one() + # update token + oauth.token = token + except NoResultFound: + oauth = ub.OAuth( + provider=blueprint.name, + provider_user_id=provider_user_id, + token=token, + ) + try: + ub.session.add(oauth) + ub.session.commit() + except Exception as e: + app.logger.exception(e) + ub.session.rollback() + + # Disable Flask-Dance's default behavior for saving the OAuth token + return False -def bind_oauth_or_register(provider, provider_user_id, redirect_url): - query = ub.session.query(ub.OAuth).filter_by( - provider=provider, - provider_user_id=provider_user_id, - ) - try: - oauth = query.one() - # already bind with user, just login - if oauth.user: - login_user(oauth.user) - return redirect(url_for('index')) - else: - # bind to current user + def bind_oauth_or_register(provider, provider_user_id, redirect_url): + query = ub.session.query(ub.OAuth).filter_by( + provider=provider, + provider_user_id=provider_user_id, + ) + try: + oauth = query.one() + # already bind with user, just login + if oauth.user: + login_user(oauth.user) + return redirect(url_for('web.index')) + else: + # bind to current user + if current_user and current_user.is_authenticated: + oauth.user = current_user + try: + ub.session.add(oauth) + ub.session.commit() + except Exception as e: + app.logger.exception(e) + ub.session.rollback() + return redirect(url_for('web.register')) + except NoResultFound: + return redirect(url_for(redirect_url)) + + + def get_oauth_status(): + status = [] + query = ub.session.query(ub.OAuth).filter_by( + user_id=current_user.id, + ) + try: + oauths = query.all() + for oauth in oauths: + status.append(oauth.provider) + return status + except NoResultFound: + return None + + + def unlink_oauth(provider): + if request.host_url + 'me' != request.referrer: + pass + query = ub.session.query(ub.OAuth).filter_by( + provider=provider, + user_id=current_user.id, + ) + try: + oauth = query.one() if current_user and current_user.is_authenticated: oauth.user = current_user try: - ub.session.add(oauth) + ub.session.delete(oauth) ub.session.commit() + logout_oauth_user() + flash(_("Unlink to %(oauth)s success.", oauth=oauth_check[provider]), category="success") except Exception as e: app.logger.exception(e) ub.session.rollback() - return redirect(url_for('web.register')) - except NoResultFound: - return redirect(url_for(redirect_url)) - - -def get_oauth_status(): - status = [] - query = ub.session.query(ub.OAuth).filter_by( - user_id=current_user.id, - ) - try: - oauths = query.all() - for oauth in oauths: - status.append(oauth.provider) - return status - except NoResultFound: - return None - - -def unlink_oauth(provider): - if request.host_url + 'me' != request.referrer: - pass - query = ub.session.query(ub.OAuth).filter_by( - provider=provider, - user_id=current_user.id, - ) - try: - oauth = query.one() - if current_user and current_user.is_authenticated: - oauth.user = current_user - try: - ub.session.delete(oauth) - ub.session.commit() - logout_oauth_user() - flash(_("Unlink to %(oauth)s success.", oauth=oauth_check[provider]), category="success") - except Exception as e: - app.logger.exception(e) - ub.session.rollback() - flash(_("Unlink to %(oauth)s failed.", oauth=oauth_check[provider]), category="error") - except NoResultFound: - app.logger.warning("oauth %s for user %d not fount" % (provider, current_user.id)) - flash(_("Not linked to %(oauth)s.", oauth=oauth_check[provider]), category="error") - return redirect(url_for('profile')) - - -# notify on OAuth provider error -@oauth_error.connect_via(github_blueprint) -def github_error(blueprint, error, error_description=None, error_uri=None): - msg = ( - "OAuth error from {name}! " - "error={error} description={description} uri={uri}" - ).format( - name=blueprint.name, - error=error, - description=error_description, - uri=error_uri, - ) - flash(msg, category="error") - -''' -@oauth.route('/github') -@github_oauth_required -def github_login(): - if not github.authorized: - return redirect(url_for('github.login')) - account_info = github.get('/user') - if account_info.ok: - account_info_json = account_info.json() - return bind_oauth_or_register(github_blueprint.name, account_info_json['id'], 'github.login') - flash(_(u"GitHub Oauth error, please retry later."), category="error") - return redirect(url_for('login')) - - -@oauth.route('/unlink/github', methods=["GET"]) -@login_required -def github_login_unlink(): - return unlink_oauth(github_blueprint.name) - - -@oauth.route('/google') -@google_oauth_required -def google_login(): - if not google.authorized: - return redirect(url_for("google.login")) - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - account_info_json = resp.json() - return bind_oauth_or_register(google_blueprint.name, account_info_json['id'], 'google.login') - flash(_(u"Google Oauth error, please retry later."), category="error") - return redirect(url_for('login')) -''' - -@oauth_error.connect_via(google_blueprint) -def google_error(blueprint, error, error_description=None, error_uri=None): - msg = ( - "OAuth error from {name}! " - "error={error} description={description} uri={uri}" - ).format( - name=blueprint.name, - error=error, - description=error_description, - uri=error_uri, - ) - flash(msg, category="error") + flash(_("Unlink to %(oauth)s failed.", oauth=oauth_check[provider]), category="error") + except NoResultFound: + app.logger.warning("oauth %s for user %d not fount" % (provider, current_user.id)) + flash(_("Not linked to %(oauth)s.", oauth=oauth_check[provider]), category="error") + return redirect(url_for('profile')) + + + # notify on OAuth provider error + @oauth_error.connect_via(github_blueprint) + def github_error(blueprint, error, error_description=None, error_uri=None): + msg = ( + "OAuth error from {name}! " + "error={error} description={description} uri={uri}" + ).format( + name=blueprint.name, + error=error, + description=error_description, + uri=error_uri, + ) + flash(msg, category="error") + + ''' + @oauth.route('/github') + @github_oauth_required + def github_login(): + if not github.authorized: + return redirect(url_for('github.login')) + account_info = github.get('/user') + if account_info.ok: + account_info_json = account_info.json() + return bind_oauth_or_register(github_blueprint.name, account_info_json['id'], 'github.login') + flash(_(u"GitHub Oauth error, please retry later."), category="error") + return redirect(url_for('web.login')) + + + @oauth.route('/unlink/github', methods=["GET"]) + @login_required + def github_login_unlink(): + return unlink_oauth(github_blueprint.name) + + + @oauth.route('/google') + @google_oauth_required + def google_login(): + if not google.authorized: + return redirect(url_for("google.login")) + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + account_info_json = resp.json() + return bind_oauth_or_register(google_blueprint.name, account_info_json['id'], 'google.login') + flash(_(u"Google Oauth error, please retry later."), category="error") + return redirect(url_for('web.login')) + ''' + + @oauth_error.connect_via(google_blueprint) + def google_error(blueprint, error, error_description=None, error_uri=None): + msg = ( + "OAuth error from {name}! " + "error={error} description={description} uri={uri}" + ).format( + name=blueprint.name, + error=error, + description=error_description, + uri=error_uri, + ) + flash(msg, category="error") -''' -@oauth.route('/unlink/google', methods=["GET"]) -@login_required -def google_login_unlink(): - return unlink_oauth(google_blueprint.name)''' + ''' + @oauth.route('/unlink/google', methods=["GET"]) + @login_required + def google_login_unlink(): + return unlink_oauth(google_blueprint.name)''' diff --git a/cps/opds.py b/cps/opds.py index dd6ed984..419cdea2 100644 --- a/cps/opds.py +++ b/cps/opds.py @@ -38,7 +38,6 @@ from werkzeug.security import check_password_hash from werkzeug.datastructures import Headers try: from urllib.parse import quote - from imp import reload except ImportError: from urllib import quote @@ -315,7 +314,7 @@ def authenticate(): def render_xml_template(*args, **kwargs): #ToDo: return time in current timezone similar to %z currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00") - xml = render_template(current_time=currtime, *args, **kwargs) + xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs) response = make_response(xml) response.headers["Content-Type"] = "application/atom+xml; charset=utf-8" return response diff --git a/cps/shelf.py b/cps/shelf.py index 6300a0ce..34d8eb47 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -40,7 +40,7 @@ def add_to_shelf(shelf_id, book_id): app.logger.info("Invalid shelf specified") if not request.is_xhr: flash(_(u"Invalid shelf specified"), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) return "Invalid shelf specified", 400 if not shelf.is_public and not shelf.user_id == int(current_user.id): @@ -48,14 +48,14 @@ def add_to_shelf(shelf_id, book_id): if not request.is_xhr: flash(_(u"Sorry you are not allowed to add a book to the the shelf: %(shelfname)s", shelfname=shelf.name), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) return "Sorry you are not allowed to add a book to the the shelf: %s" % shelf.name, 403 if shelf.is_public and not current_user.role_edit_shelfs(): app.logger.info("User is not allowed to edit public shelves") if not request.is_xhr: flash(_(u"You are not allowed to edit public shelves"), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) return "User is not allowed to edit public shelves", 403 book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id, @@ -64,7 +64,7 @@ def add_to_shelf(shelf_id, book_id): app.logger.info("Book is already part of the shelf: %s" % shelf.name) if not request.is_xhr: flash(_(u"Book is already part of the shelf: %(shelfname)s", shelfname=shelf.name), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) return "Book is already part of the shelf: %s" % shelf.name, 400 maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first() @@ -81,7 +81,7 @@ def add_to_shelf(shelf_id, book_id): if "HTTP_REFERER" in request.environ: return redirect(request.environ["HTTP_REFERER"]) else: - return redirect(url_for('index')) + return redirect(url_for('web.index')) return "", 204 @@ -92,17 +92,17 @@ def search_to_shelf(shelf_id): if shelf is None: app.logger.info("Invalid shelf specified") flash(_(u"Invalid shelf specified"), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) if not shelf.is_public and not shelf.user_id == int(current_user.id): app.logger.info("You are not allowed to add a book to the the shelf: %s" % shelf.name) flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) if shelf.is_public and not current_user.role_edit_shelfs(): app.logger.info("User is not allowed to edit public shelves") flash(_(u"User is not allowed to edit public shelves"), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) if current_user.id in searched_ids and searched_ids[current_user.id]: books_for_shelf = list() @@ -120,7 +120,7 @@ def search_to_shelf(shelf_id): if not books_for_shelf: app.logger.info("Books are already part of the shelf: %s" % shelf.name) flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first() if maxOrder[0] is None: @@ -146,7 +146,7 @@ def remove_from_shelf(shelf_id, book_id): if shelf is None: app.logger.info("Invalid shelf specified") if not request.is_xhr: - return redirect(url_for('index')) + return redirect(url_for('web.index')) return "Invalid shelf specified", 400 # if shelf is public and use is allowed to edit shelfs, or if shelf is private and user is owner @@ -165,7 +165,7 @@ def remove_from_shelf(shelf_id, book_id): if book_shelf is None: app.logger.info("Book already removed from shelf") if not request.is_xhr: - return redirect(url_for('index')) + return redirect(url_for('web.index')) return "Book already removed from shelf", 410 ub.session.delete(book_shelf) @@ -180,7 +180,7 @@ def remove_from_shelf(shelf_id, book_id): if not request.is_xhr: flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) return "Sorry you are not allowed to remove a book from this shelf: %s" % shelf.name, 403 diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index 0a3f4bb9..664a5b4f 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -19,7 +19,7 @@

    {{_('Delete formats:')}}

    {% for file in book.data %} {% endfor %}
    @@ -28,7 +28,7 @@ {% if source_formats|length > 0 and conversion_formats|length > 0 %}

    {{_('Convert book format:')}}

    -
    +
    diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index a7d0bcb9..12f04e2d 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -159,7 +159,7 @@
    - {% if goodreads %} + {% if feature_support['goodreads'] %}
    @@ -176,6 +176,7 @@
    {% endif %} + {% if feature_support['ldap'] %}
    @@ -190,6 +191,8 @@
    + {% endif %} + {% if feature_support['oauth'] %}
    @@ -220,6 +223,7 @@
    + {% endif %}
    diff --git a/cps/templates/detail.html b/cps/templates/detail.html index a459b89e..c6019294 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -57,9 +57,22 @@
    {% endif %} {% endif %} + {% if reader_list %} +
    + + +
    + {% endif %} {% if reader_list %} {% if audioentries|length %} -
    + {% endif %} {% endif %} diff --git a/cps/templates/index.xml b/cps/templates/index.xml index 7ab305aa..f7e8d6f0 100644 --- a/cps/templates/index.xml +++ b/cps/templates/index.xml @@ -2,13 +2,12 @@ urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 {{ current_time }} - - + - {{instance}} {{instance}} @@ -16,88 +15,88 @@ {{_('Hot Books')}} - - {{url_for('feed_hot')}} + + {{url_for('opds.feed_hot')}} {{ current_time }} {{_('Popular publications from this catalog based on Downloads.')}} {{_('Best rated Books')}} - - {{url_for('feed_best_rated')}} + + {{url_for('opds.feed_best_rated')}} {{ current_time }} {{_('Popular publications from this catalog based on Rating.')}} {{_('New Books')}} - - {{url_for('feed_new')}} + + {{url_for('opds.feed_new')}} {{ current_time }} {{_('The latest Books')}} {{_('Random Books')}} - - {{url_for('feed_discover')}} + + {{url_for('opds.feed_discover')}} {{ current_time }} {{_('Show Random Books')}} {% if not current_user.is_anonymous %} {{_('Read Books')}} - - {{url_for('feed_read_books')}} + + {{url_for('opds.feed_read_books')}} {{ current_time }} {{_('Read Books')}} - {% endif %} {{_('Unread Books')}} - - {{url_for('feed_unread_books')}} + + {{url_for('opds.feed_unread_books')}} {{ current_time }} {{_('Unread Books')}} + {% endif %} {{_('Authors')}} - - {{url_for('feed_authorindex')}} + + {{url_for('opds.feed_authorindex')}} {{ current_time }} {{_('Books ordered by Author')}} {{_('Publishers')}} - - {{url_for('feed_publisherindex')}} + + {{url_for('opds.feed_publisherindex')}} {{ current_time }} {{_('Books ordered by publisher')}} {{_('Category list')}} - - {{url_for('feed_categoryindex')}} + + {{url_for('opds.feed_categoryindex')}} {{ current_time }} {{_('Books ordered by category')}} {{_('Series list')}} - - {{url_for('feed_seriesindex')}} + + {{url_for('opds.feed_seriesindex')}} {{ current_time }} {{_('Books ordered by series')}} {{_('Public Shelves')}} - - {{url_for('feed_shelfindex', public="public")}} + + {{url_for('opds.feed_shelfindex', public="public")}} {{ current_time }} {{_('Books organized in public shelfs, visible to everyone')}} {% if not current_user.is_anonymous %} {{_('Your Shelves')}} - - {{url_for('feed_shelfindex')}} + + {{url_for('opds.feed_shelfindex')}} {{ current_time }} {{_("User's own shelfs, only visible to the current user himself")}} diff --git a/cps/templates/json.txt b/cps/templates/json.txt index fa5239b1..c068b1b4 100644 --- a/cps/templates/json.txt +++ b/cps/templates/json.txt @@ -1,53 +1,53 @@ { - "pubdate": "{{entry.pubdate}}", - "title": "{{entry.title}}", + "pubdate": "{{entry.pubdate}}", + "title": "{{entry.title}}", "format_metadata": { - {% for format in entry.data %} + {% for format in entry.data %} "{{format.format}}": { - "mtime": "{{entry.last_modified}}", - "size": {{format.uncompressed_size}}, + "mtime": "{{entry.last_modified}}", + "size": {{format.uncompressed_size}}, "path": "" }{% if not loop.last %},{% endif %} {% endfor %} - }, + }, "formats": [ - {% for format in entry.data %} + {% for format in entry.data %} "{{format.format}}"{% if not loop.last %},{% endif %} - {% endfor %} - ], - "series": null, - "cover": "{{url_for('feed_get_cover', book_id=entry.id)}}", + {% endfor %} + ], + "series": null, + "cover": "{{url_for('opds.feed_get_cover', book_id=entry.id)}}", "languages": [ - {% for lang in entry.languages %} + {% for lang in entry.languages %} "{{lang.lang_code}}"{% if not loop.last %},{% endif %} {% endfor %} - ], + ], "comments": "{% if entry.comments|length > 0 %}{{entry.comments[0].text.replace('"', '\\"')|safe}}{% endif %}", "tags": [ {% for tag in entry.tags %} "{{tag.name}}"{% if not loop.last %},{% endif %} - {% endfor %} - ], - "application_id": {{entry.id}}, - "series_index": {% if entry.series|length > 0 %}"{{entry.series_index}}"{% else %}null{% endif %}, - "last_modified": "{{entry.last_modified}}", - "author_sort": "{{entry.author_sort}}", - "uuid": "{{entry.uuid}}", - "timestamp": "{{entry.timestamp}}", - "thumbnail": "{{url_for('feed_get_cover', book_id=entry.id)}}", + {% endfor %} + ], + "application_id": {{entry.id}}, + "series_index": {% if entry.series|length > 0 %}"{{entry.series_index}}"{% else %}null{% endif %}, + "last_modified": "{{entry.last_modified}}", + "author_sort": "{{entry.author_sort}}", + "uuid": "{{entry.uuid}}", + "timestamp": "{{entry.timestamp}}", + "thumbnail": "{{url_for('opds.feed_get_cover', book_id=entry.id)}}", "main_format": { - "{{entry.data[0].format|lower}}": "{{ url_for('get_opds_download_link', book_id=entry.id, book_format=entry.data[0].format|lower)}}" + "{{entry.data[0].format|lower}}": "{{ url_for('opds.get_opds_download_link', book_id=entry.id, book_format=entry.data[0].format|lower)}}" }, "rating":{% if entry.ratings.__len__() > 0 %} "{{entry.ratings[0].rating}}.0"{% else %}0.0{% endif %}, "authors": [ {% for author in entry.authors %} "{{author.name.replace('|',',')}}"{% if not loop.last %},{% endif %} - {% endfor %} - ], + {% endfor %} + ], "other_formats": { {% if entry.data.__len__() > 1 %} {% for format in entry.data[1:] %} - "{{format.format|lower}}": "{{ url_for('get_opds_download_link', book_id=entry.id, book_format=format.format|lower)}}"{% if not loop.last %},{% endif %} + "{{format.format|lower}}": "{{ url_for('opds.get_opds_download_link', book_id=entry.id, book_format=format.format|lower)}}"{% if not loop.last %},{% endif %} {% endfor %} {% endif %} }, "title_sort": "{{entry.sort}}" diff --git a/cps/templates/layout.html b/cps/templates/layout.html index ef83e842..897c6cb6 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -161,7 +161,7 @@ {% if g.user.is_authenticated or g.user.is_anonymous %} {% for shelf in g.public_shelfes %} -
  • {{shelf.name|shortentitle(40)}}
  • +
  • {{shelf.name|shortentitle(40)}}
  • {% endfor %} {% for shelf in g.user.shelf %} diff --git a/cps/templates/osd.xml b/cps/templates/osd.xml index b88e6823..bb741bb5 100644 --- a/cps/templates/osd.xml +++ b/cps/templates/osd.xml @@ -6,9 +6,9 @@ Janeczku https://github.com/janeczku/calibre-web + template="{{url_for('opds.search')}}?query={searchTerms}"/> + template="{{url_for('opds.feed_normal_search')}}?query={searchTerms}"/> open {{lang}} UTF-8 diff --git a/cps/ub.py b/cps/ub.py index 59f0b613..dbc42ac4 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -23,7 +23,6 @@ from sqlalchemy import exc from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import * from flask_login import AnonymousUserMixin -from flask_dance.consumer.backend.sqla import OAuthConsumerMixin import sys import os import logging @@ -34,8 +33,11 @@ from binascii import hexlify import cli try: + from flask_dance.consumer.backend.sqla import OAuthConsumerMixin import ldap + oauth_support = True except ImportError: + oauth_support = False pass engine = create_engine('sqlite:///{0}'.format(cli.settingspath), echo=False) @@ -207,11 +209,11 @@ class User(UserBase, Base): default_language = Column(String(3), default="all") mature_content = Column(Boolean, default=True) - -class OAuth(OAuthConsumerMixin, Base): - provider_user_id = Column(String(256)) - user_id = Column(Integer, ForeignKey(User.id)) - user = relationship(User) +if oauth_support: + class OAuth(OAuthConsumerMixin, Base): + provider_user_id = Column(String(256)) + user_id = Column(Integer, ForeignKey(User.id)) + user = relationship(User) # Class for anonymous user is derived from User base and completly overrides methods and properties for the @@ -834,7 +836,7 @@ def delete_download(book_id): session.commit() # Generate user Guest (translated text), as anoymous user, no rights -def create_anonymous_user(session): +def create_anonymous_user(): user = User() user.nickname = "Guest" user.email = 'no@email' diff --git a/cps/updater.py b/cps/updater.py index b01646a0..e5dd6bae 100644 --- a/cps/updater.py +++ b/cps/updater.py @@ -18,11 +18,10 @@ # along with this program. If not, see . -from cps import config, get_locale +from cps import config, get_locale, Server, app import threading import zipfile import requests -import logging import time from io import BytesIO import os @@ -35,7 +34,6 @@ import json from flask_babel import gettext as _ from babel.dates import format_datetime -import server def is_sha1(sha1): if len(sha1) != 40: @@ -69,39 +67,45 @@ class Updater(threading.Thread): def run(self): try: self.status = 1 - r = requests.get(self._get_request_path(), stream=True) + app.logger.debug(u'Download update file') + headers = {'Accept': 'application/vnd.github.v3+json'} + r = requests.get(self._get_request_path(), stream=True, headers=headers) r.raise_for_status() self.status = 2 + app.logger.debug(u'Opening zipfile') z = zipfile.ZipFile(BytesIO(r.content)) self.status = 3 + app.logger.debug(u'Extracting zipfile') tmp_dir = gettempdir() z.extractall(tmp_dir) foldername = os.path.join(tmp_dir, z.namelist()[0])[:-1] if not os.path.isdir(foldername): self.status = 11 - logging.getLogger('cps.web').info(u'Extracted contents of zipfile not found in temp folder') + app.logger.info(u'Extracted contents of zipfile not found in temp folder') return self.status = 4 + app.logger.debug(u'Replacing files') self.update_source(foldername, config.get_main_dir) self.status = 6 + app.logger.debug(u'Preparing restart of server') time.sleep(2) - server.Server.setRestartTyp(True) - server.Server.stopServer() + Server.setRestartTyp(True) + Server.stopServer() self.status = 7 time.sleep(2) except requests.exceptions.HTTPError as ex: - logging.getLogger('cps.web').info( u'HTTP Error' + ' ' + str(ex)) + app.logger.info( u'HTTP Error' + ' ' + str(ex)) self.status = 8 except requests.exceptions.ConnectionError: - logging.getLogger('cps.web').info(u'Connection error') + app.logger.info(u'Connection error') self.status = 9 except requests.exceptions.Timeout: - logging.getLogger('cps.web').info(u'Timeout while establishing connection') + app.logger.info(u'Timeout while establishing connection') self.status = 10 except requests.exceptions.RequestException: self.status = 11 - logging.getLogger('cps.web').info(u'General error') + app.logger.info(u'General error') def get_update_status(self): return self.status @@ -149,14 +153,14 @@ class Updater(threading.Thread): if sys.platform == "win32" or sys.platform == "darwin": change_permissions = False else: - logging.getLogger('cps.web').debug('Update on OS-System : ' + sys.platform) + app.logger.debug('Update on OS-System : ' + sys.platform) new_permissions = os.stat(root_dst_dir) # print new_permissions for src_dir, __, files in os.walk(root_src_dir): dst_dir = src_dir.replace(root_src_dir, root_dst_dir, 1) if not os.path.exists(dst_dir): os.makedirs(dst_dir) - logging.getLogger('cps.web').debug('Create-Dir: '+dst_dir) + app.logger.debug('Create-Dir: '+dst_dir) if change_permissions: # print('Permissions: User '+str(new_permissions.st_uid)+' Group '+str(new_permissions.st_uid)) os.chown(dst_dir, new_permissions.st_uid, new_permissions.st_gid) @@ -166,20 +170,20 @@ class Updater(threading.Thread): if os.path.exists(dst_file): if change_permissions: permission = os.stat(dst_file) - logging.getLogger('cps.web').debug('Remove file before copy: '+dst_file) + app.logger.debug('Remove file before copy: '+dst_file) os.remove(dst_file) else: if change_permissions: permission = new_permissions shutil.move(src_file, dst_dir) - logging.getLogger('cps.web').debug('Move File '+src_file+' to '+dst_dir) + app.logger.debug('Move File '+src_file+' to '+dst_dir) if change_permissions: try: os.chown(dst_file, permission.st_uid, permission.st_gid) except (Exception) as e: # ex = sys.exc_info() old_permissions = os.stat(dst_file) - logging.getLogger('cps.web').debug('Fail change permissions of ' + str(dst_file) + '. Before: ' + app.logger.debug('Fail change permissions of ' + str(dst_file) + '. Before: ' + str(old_permissions.st_uid) + ':' + str(old_permissions.st_gid) + ' After: ' + str(permission.st_uid) + ':' + str(permission.st_gid) + ' error: '+str(e)) return @@ -215,15 +219,15 @@ class Updater(threading.Thread): for item in remove_items: item_path = os.path.join(destination, item[1:]) if os.path.isdir(item_path): - logging.getLogger('cps.web').debug("Delete dir " + item_path) + app.logger.debug("Delete dir " + item_path) shutil.rmtree(item_path, ignore_errors=True) else: try: - logging.getLogger('cps.web').debug("Delete file " + item_path) + app.logger.debug("Delete file " + item_path) # log_from_thread("Delete file " + item_path) os.remove(item_path) except Exception: - logging.getLogger('cps.web').debug("Could not remove:" + item_path) + app.logger.debug("Could not remove:" + item_path) shutil.rmtree(source, ignore_errors=True) def _nightly_version_info(self): @@ -263,7 +267,8 @@ class Updater(threading.Thread): status['update'] = True try: - r = requests.get(repository_url + '/git/commits/' + commit['object']['sha']) + headers = {'Accept': 'application/vnd.github.v3+json'} + r = requests.get(repository_url + '/git/commits/' + commit['object']['sha'], headers=headers) r.raise_for_status() update_data = r.json() except requests.exceptions.HTTPError as e: @@ -310,7 +315,8 @@ class Updater(threading.Thread): # check if we are more than one update behind if so, go up the tree if parent_commit['sha'] != status['current_commit_hash']: try: - r = requests.get(parent_commit['url']) + headers = {'Accept': 'application/vnd.github.v3+json'} + r = requests.get(parent_commit['url'], headers=headers) r.raise_for_status() parent_data = r.json() @@ -368,7 +374,8 @@ class Updater(threading.Thread): # check if we are more than one update behind if so, go up the tree if commit['sha'] != status['current_commit_hash']: try: - r = requests.get(parent_commit['url']) + headers = {'Accept': 'application/vnd.github.v3+json'} + r = requests.get(parent_commit['url'], headers=headers) r.raise_for_status() parent_data = r.json() @@ -492,7 +499,8 @@ class Updater(threading.Thread): else: status['current_commit_hash'] = version['version'] try: - r = requests.get(repository_url) + headers = {'Accept': 'application/vnd.github.v3+json'} + r = requests.get(repository_url, headers=headers) commit = r.json() r.raise_for_status() except requests.exceptions.HTTPError as e: diff --git a/cps/web.py b/cps/web.py index 91cc8ff0..ef87712f 100644 --- a/cps/web.py +++ b/cps/web.py @@ -47,18 +47,19 @@ from cps import lm, babel, ub, config, get_locale, language_table, app from pagination import Pagination from sqlalchemy.sql.expression import text -from oauth_bb import oauth_check, register_user_with_oauth, logout_oauth_user, get_oauth_status - -'''try: - oauth_support = True +feature_support = dict() +try: + from oauth_bb import oauth_check, register_user_with_oauth, logout_oauth_user, get_oauth_status + feature_support['oauth'] = True except ImportError: - oauth_support = False''' + feature_support['oauth'] = False + oauth_check = {} try: import ldap - ldap_support = True + feature_support['ldap'] = True except ImportError: - ldap_support = False + feature_support['ldap'] = False try: from googleapiclient.errors import HttpError @@ -67,15 +68,15 @@ except ImportError: try: from goodreads.client import GoodreadsClient - goodreads_support = True + feature_support['goodreads'] = True except ImportError: - goodreads_support = False + feature_support['goodreads'] = False try: import Levenshtein - levenshtein_support = True + feature_support['levenshtein'] = True except ImportError: - levenshtein_support = False + feature_support['levenshtein'] = False try: from functools import reduce, wraps @@ -84,9 +85,9 @@ except ImportError: try: import rarfile - rar_support=True + feature_support['rar'] = True except ImportError: - rar_support=False + feature_support['rar'] = False try: from natsort import natsorted as sort @@ -95,18 +96,17 @@ except ImportError: try: from urllib.parse import quote - from imp import reload except ImportError: from urllib import quote - from flask import Blueprint # Global variables EXTENSIONS_AUDIO = {'mp3', 'm4a', 'm4b'} -# EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] + (['rar','cbr'] if rar_support else [])) +'''EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] + + (['rar','cbr'] if feature_support['rar'] else []))''' # custom error page @@ -346,8 +346,8 @@ def before_request(): g.allow_upload = config.config_uploading g.current_theme = config.config_theme g.public_shelfes = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1).order_by(ub.Shelf.name).all() - if not config.db_configured and request.endpoint not in ('web.basic_configuration', 'login') and '/static/' not in request.path: - return redirect(url_for('web.basic_configuration')) + if not config.db_configured and request.endpoint not in ('admin.basic_configuration', 'login') and '/static/' not in request.path: + return redirect(url_for('admin.basic_configuration')) @web.route("/ajax/emailstat") @@ -373,7 +373,7 @@ def get_comic_book(book_id, book_format, page): if bookformat.format.lower() == book_format.lower(): cbr_file = os.path.join(config.config_calibre_dir, book.path, bookformat.name) + "." + book_format if book_format in ("cbr", "rar"): - if rar_support == True: + if feature_support['rar'] == True: rarfile.UNRAR_TOOL = config.config_rarfile_location try: rf = rarfile.RarFile(cbr_file) @@ -636,7 +636,7 @@ def author(book_id, page): author_info = None other_books = [] - if goodreads_support and config.config_use_goodreads: + if feature_support['goodreads'] and config.config_use_goodreads: try: gc = GoodreadsClient(config.config_goodreads_api_key, config.config_goodreads_api_secret) author_info = gc.find_author(author_name=name) @@ -688,7 +688,7 @@ def get_unique_other_books(library_books, author_books): author_books) # Fuzzy match book titles - if levenshtein_support: + if feature_support['levenshtein']: library_titles = reduce(lambda acc, book: acc + [book.title], library_books, []) other_books = filter(lambda author_book: not filter( lambda library_book: @@ -1215,7 +1215,7 @@ def read_book(book_id, book_format): # copyfile(cbr_file, tmp_file) return render_title_template('readcbr.html', comicfile=all_name, title=_(u"Read a Book"), extension=fileext) - '''if rar_support == True: + '''if feature_support['rar']: extensionList = ["cbr","cbt","cbz"] else: extensionList = ["cbt","cbz"] @@ -1292,7 +1292,7 @@ def register(): try: ub.session.add(content) ub.session.commit() - if oauth_support: + if feature_support['oauth']: register_user_with_oauth(content) helper.send_registration_mail(to_save["email"], to_save["nickname"], password) except Exception: @@ -1310,7 +1310,7 @@ def register(): flash(_(u"This username or e-mail address is already in use."), category="error") return render_title_template('register.html', title=_(u"register"), page="register") - if oauth_support: + if feature_support['oauth']: register_user_with_oauth() return render_title_template('register.html', config=config, title=_(u"register"), page="register") @@ -1318,7 +1318,7 @@ def register(): @web.route('/login', methods=['GET', 'POST']) def login(): if not config.db_configured: - return redirect(url_for('web.basic_configuration')) + return redirect(url_for('admin.basic_configuration')) if current_user is not None and current_user.is_authenticated: return redirect(url_for('web.index')) if request.method == "POST": @@ -1358,7 +1358,7 @@ def login(): def logout(): if current_user is not None and current_user.is_authenticated: logout_user() - if oauth_support: + if feature_support['oauth']: logout_oauth_user() return redirect(url_for('web.login')) @@ -1370,7 +1370,7 @@ def remote_login(): ub.session.add(auth_token) ub.session.commit() - verify_url = url_for('verify_token', token=auth_token.auth_token, _external=true) + verify_url = url_for('web.verify_token', token=auth_token.auth_token, _external=true) return render_title_template('remote_login.html', title=_(u"login"), token=auth_token.auth_token, verify_url=verify_url, page="remotelogin") @@ -1385,7 +1385,7 @@ def verify_token(token): # Token not found if auth_token is None: flash(_(u"Token not found"), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) # Token expired if datetime.datetime.now() > auth_token.expiration: @@ -1393,7 +1393,7 @@ def verify_token(token): ub.session.commit() flash(_(u"Token has expired"), category="error") - return redirect(url_for('index')) + return redirect(url_for('web.index')) # Update token with user information auth_token.user_id = current_user.id @@ -1401,7 +1401,7 @@ def verify_token(token): ub.session.commit() flash(_(u"Success! Please return to your device"), category="success") - return redirect(url_for('index')) + return redirect(url_for('web.index')) @web.route('/ajax/verify_token', methods=['POST']) @@ -1472,7 +1472,10 @@ def profile(): downloads = list() languages = speaking_language() translations = babel.list_translations() + [LC('en')] - oauth_status = get_oauth_status() + if feature_support['oauth']: + oauth_status = get_oauth_status() + else: + oauth_status = None for book in content.downloads: downloadBook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first() if downloadBook: @@ -1535,9 +1538,11 @@ def profile(): ub.session.rollback() flash(_(u"Found an existing account for this e-mail address."), category="error") return render_title_template("user_edit.html", content=content, downloads=downloads, - title=_(u"%(name)s's profile", name=current_user.nickname, registered_oauth=oauth_check, oauth_status=oauth_status)) + title=_(u"%(name)s's profile", name=current_user.nickname, + registered_oauth=oauth_check, oauth_status=oauth_status)) flash(_(u"Profile updated"), category="success") return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages, - content=content, downloads=downloads, title=_(u"%(name)s's profile", - name=current_user.nickname), page="me", registered_oauth=oauth_check, oauth_status=oauth_status) + content=content, downloads=downloads, title=_(u"%(name)s's profile", + name=current_user.nickname), page="me", registered_oauth=oauth_check, + oauth_status=oauth_status) diff --git a/cps/worker.py b/cps/worker.py index c0c68833..77df162c 100644 --- a/cps/worker.py +++ b/cps/worker.py @@ -27,14 +27,12 @@ import socket import sys import os from email.generator import Generator -from cps import config, db # , app -# import web +from cps import config, db, app from flask_babel import gettext as _ import re -# import gdriveutils as gd +import gdriveutils as gd from subproc_wrapper import process_open - try: from StringIO import StringIO from email.MIMEBase import MIMEBase @@ -90,8 +88,8 @@ def get_attachment(bookpath, filename): data = file_.read() file_.close() except IOError as e: - # web.app.logger.exception(e) # traceback.print_exc() - # web.app.logger.error(u'The requested file could not be read. Maybe wrong permissions?') + app.logger.exception(e) # traceback.print_exc() + app.logger.error(u'The requested file could not be read. Maybe wrong permissions?') return None attachment = MIMEBase('application', 'octet-stream') @@ -116,8 +114,7 @@ class emailbase(): def send(self, strg): """Send `strg' to the server.""" - if self.debuglevel > 0: - print('send:', repr(strg[:300]), file=sys.stderr) + app.logger.debug('send:' + repr(strg[:300])) if hasattr(self, 'sock') and self.sock: try: if self.transferSize: @@ -141,6 +138,9 @@ class emailbase(): else: raise smtplib.SMTPServerDisconnected('please run connect() first') + def _print_debug(self, *args): + app.logger.debug(args) + def getTransferStatus(self): if self.transferSize: lock2 = threading.Lock() @@ -254,14 +254,14 @@ class WorkerThread(threading.Thread): # if it does - mark the conversion task as complete and return a success # this will allow send to kindle workflow to continue to work if os.path.isfile(file_path + format_new_ext): - # web.app.logger.info("Book id %d already converted to %s", bookid, format_new_ext) + app.logger.info("Book id %d already converted to %s", bookid, format_new_ext) cur_book = db.session.query(db.Books).filter(db.Books.id == bookid).first() self.queue[self.current]['path'] = file_path self.queue[self.current]['title'] = cur_book.title self._handleSuccess() return file_path + format_new_ext else: - web.app.logger.info("Book id %d - target format of %s does not exist. Moving forward with convert.", bookid, format_new_ext) + app.logger.info("Book id %d - target format of %s does not exist. Moving forward with convert.", bookid, format_new_ext) # check if converter-executable is existing if not os.path.exists(config.config_converterpath): @@ -274,22 +274,22 @@ class WorkerThread(threading.Thread): if format_old_ext == '.epub' and format_new_ext == '.mobi': if config.config_ebookconverter == 1: '''if os.name == 'nt': - command = web.ub.config.config_converterpath + u' "' + file_path + u'.epub"' + command = config.config_converterpath + u' "' + file_path + u'.epub"' if sys.version_info < (3, 0): command = command.encode(sys.getfilesystemencoding()) else:''' command = [config.config_converterpath, file_path + u'.epub'] - quotes = (1) + quotes = [1] if config.config_ebookconverter == 2: # Linux py2.7 encode as list without quotes no empty element for parameters # linux py3.x no encode and as list without quotes no empty element for parameters # windows py2.7 encode as string with quotes empty element for parameters is okay # windows py 3.x no encode and as string with quotes empty element for parameters is okay # separate handling for windows and linux - quotes = (1,2) + quotes = [1,2] '''if os.name == 'nt': - command = web.ub.config.config_converterpath + u' "' + file_path + format_old_ext + u'" "' + \ - file_path + format_new_ext + u'" ' + web.ub.config.config_calibre + command = config.config_converterpath + u' "' + file_path + format_old_ext + u'" "' + \ + file_path + format_new_ext + u'" ' + config.config_calibre if sys.version_info < (3, 0): command = command.encode(sys.getfilesystemencoding()) else:''' @@ -317,13 +317,13 @@ class WorkerThread(threading.Thread): if conv_error: error_message = _(u"Kindlegen failed with Error %(error)s. Message: %(message)s", error=conv_error.group(1), message=conv_error.group(2).strip()) - web.app.logger.debug("convert_kindlegen: " + nextline) + app.logger.debug("convert_kindlegen: " + nextline) else: while p.poll() is None: nextline = p.stdout.readline() if os.name == 'nt' and sys.version_info < (3, 0): nextline = nextline.decode('windows-1252') - web.app.logger.debug(nextline.strip('\r\n')) + app.logger.debug(nextline.strip('\r\n')) # parse progress string from calibre-converter progress = re.search("(\d+)%\s.*", nextline) if progress: @@ -353,7 +353,7 @@ class WorkerThread(threading.Thread): return file_path + format_new_ext else: error_message = format_new_ext.upper() + ' format not found on disk' - # web.app.logger.info("ebook converter failed with error while converting book") + app.logger.info("ebook converter failed with error while converting book") if not error_message: error_message = 'Ebook converter failed with unknown error' self._handleError(error_message) @@ -414,7 +414,6 @@ class WorkerThread(threading.Thread): def _send_raw_email(self): self.queue[self.current]['starttime'] = datetime.now() self.UIqueue[self.current]['formStarttime'] = self.queue[self.current]['starttime'] - # self.queue[self.current]['status'] = STAT_STARTED self.UIqueue[self.current]['stat'] = STAT_STARTED obj=self.queue[self.current] # create MIME message @@ -446,8 +445,11 @@ class WorkerThread(threading.Thread): # send email timeout = 600 # set timeout to 5mins - org_stderr = sys.stderr - sys.stderr = StderrLogger() + # redirect output to logfile on python2 pn python3 debugoutput is caught with overwritten + # _print_debug function + if sys.version_info < (3, 0): + org_smtpstderr = smtplib.stderr + smtplib.stderr = StderrLogger() if use_ssl == 2: self.asyncSMTP = email_SSL(obj['settings']["mail_server"], obj['settings']["mail_port"], timeout) @@ -455,7 +457,7 @@ class WorkerThread(threading.Thread): self.asyncSMTP = email(obj['settings']["mail_server"], obj['settings']["mail_port"], timeout) # link to logginglevel - if web.ub.config.config_log_level != logging.DEBUG: + if config.config_log_level != logging.DEBUG: self.asyncSMTP.set_debuglevel(0) else: self.asyncSMTP.set_debuglevel(1) @@ -466,7 +468,9 @@ class WorkerThread(threading.Thread): self.asyncSMTP.sendmail(obj['settings']["mail_from"], obj['recipent'], msg) self.asyncSMTP.quit() self._handleSuccess() - sys.stderr = org_stderr + + if sys.version_info < (3, 0): + smtplib.stderr = org_smtpstderr except (MemoryError) as e: self._handleError(u'Error sending email: ' + e.message) @@ -497,7 +501,7 @@ class WorkerThread(threading.Thread): return retVal def _handleError(self, error_message): - web.app.logger.error(error_message) + app.logger.error(error_message) # self.queue[self.current]['status'] = STAT_FAIL self.UIqueue[self.current]['stat'] = STAT_FAIL self.UIqueue[self.current]['progress'] = "100 %" @@ -519,13 +523,12 @@ class StderrLogger(object): buffer = '' def __init__(self): - self.logger = web.app.logger + self.logger = app.logger def write(self, message): try: if message == '\n': - self.logger.debug(self.buffer) - print(self.buffer) + self.logger.debug(self.buffer.replace("\n","\\n")) self.buffer = '' else: self.buffer += message diff --git a/optional-requirements.txt b/optional-requirements.txt index 399acec4..9b740f6c 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -11,12 +11,19 @@ PyDrive==1.3.1 PyYAML==3.12 rsa==3.4.2 six==1.10.0 + # goodreads goodreads>=0.3.2 python-Levenshtein>=0.12.0 + # ldap login python_ldap>=3.0.0 + # other lxml>=3.8.0 rarfile>=2.7 natsort>=2.2.0 + +# Oauth Login +flask-dance>=0.13.0 +sqlalchemy_utils>=0.33.5 diff --git a/requirements.txt b/requirements.txt index 2b13eb54..3fb23ea3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,5 +13,3 @@ SQLAlchemy>=1.1.0 tornado>=4.1 Wand>=0.4.4 unidecode>=0.04.19 -flask-dance>=0.13.0 -sqlalchemy_utils>=0.33.5 diff --git a/test/Calibre-Web TestSummary.html b/test/Calibre-Web TestSummary.html index 97d89880..32ddf212 100644 --- a/test/Calibre-Web TestSummary.html +++ b/test/Calibre-Web TestSummary.html @@ -30,15 +30,15 @@
    -

    Start Time: 2019-01-27 07:20:26.105524

    +

    Start Time: 2019-02-10 12:10:31.096065

    -

    Stop Time: 2019-01-27 08:08:50.419347

    +

    Stop Time: 2019-02-10 12:53:18.410539

    -

    Duration: 0:48:24.313823

    +

    Duration: 0:42:47.314474

    @@ -577,97 +577,92 @@ - + test_email_ssl.test_SSL_Python27 4 - 4 - 0 + 1 0 + 3 0 Detail - +
    test_SSL_None_setup_error
    - PASS + ERROR
    -