Content server: When using user accounts on the homepage show recently read books from any device not just the current device. Fixes #1998557 [[feature request] Content-server viewer suggesting of book being read on other devices](https://bugs.launchpad.net/calibre/+bug/1998557)

This commit is contained in:
Kovid Goyal 2022-12-15 17:02:26 +05:30
parent 32acf0c7a5
commit 3537db5a8c
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 158 additions and 17 deletions

View File

@ -13,7 +13,9 @@ from threading import Lock, RLock
from calibre.constants import cache_dir, iswindows
from calibre.customize.ui import plugin_for_input_format
from calibre.ebooks.metadata import authors_to_string
from calibre.srv.errors import BookNotFound, HTTPNotFound
from calibre.srv.last_read import last_read_cache
from calibre.srv.metadata import book_as_json
from calibre.srv.render_book import RENDER_VERSION
from calibre.srv.routes import endpoint, json
@ -220,8 +222,14 @@ def set_last_read_position(ctx, rd, library_id, book_id, fmt):
device, cfi, pos_frac = data['device'], data['cfi'], data['pos_frac']
except Exception:
raise HTTPNotFound('Invalid data')
cfi = cfi or None
db.set_last_read_position(
book_id, fmt, user=user, device=device, cfi=cfi or None, pos_frac=pos_frac)
book_id, fmt, user=user, device=device, cfi=cfi, pos_frac=pos_frac)
if user:
with db.safe_read_lock:
tt = db._field_for('title', book_id)
tt += ' ' + _('by') + ' ' + authors_to_string(db._field_for('authors', book_id))
last_read_cache().add_last_read_position(library_id, book_id, fmt, user, cfi, pos_frac, tt)
rd.outheaders['Content-type'] = 'text/plain'
return b''

View File

@ -18,6 +18,7 @@ from calibre.srv.ajax import search_result
from calibre.srv.errors import (
BookNotFound, HTTPBadRequest, HTTPForbidden, HTTPNotFound, HTTPRedirect,
)
from calibre.srv.last_read import last_read_cache
from calibre.srv.metadata import (
book_as_json, categories_as_json, categories_settings, icon_map,
)
@ -167,6 +168,9 @@ def basic_interface_data(ctx, rd):
'lang_code_for_user_manual': lang_code_for_user_manual(),
}
ans['library_map'], ans['default_library_id'] = ctx.library_info(rd)
if ans['username']:
lrc = last_read_cache()
ans['recently_read_by_user'] = lrc.get_recently_read(ans['username'])
return ans

View File

@ -0,0 +1,87 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2022, Kovid Goyal <kovid at kovidgoyal.net>
import apsw
import os
from contextlib import suppress
from threading import Lock
from time import time_ns
from calibre.constants import cache_dir
creation_sql = '''
CREATE TABLE IF NOT EXISTS last_read_positions ( id INTEGER PRIMARY KEY,
library_id TEXT NOT NULL,
book INTEGER NOT NULL,
format TEXT NOT NULL COLLATE NOCASE,
user TEXT NOT NULL,
cfi TEXT NOT NULL,
epoch INTEGER NOT NULL,
pos_frac REAL NOT NULL DEFAULT 0,
tooltip TEXT NOT NULL,
UNIQUE(user, library_id, book, format)
);
CREATE INDEX IF NOT EXISTS users_id ON last_read_positions (user);
'''
lock = Lock()
class LastReadCache:
def __init__(self, path='', limit=5):
self.limit = limit
self.conn = apsw.Connection(path or os.path.join(cache_dir(), 'srv-last-read.sqlite'))
self.execute(creation_sql)
def get(self, *args, **kw):
ans = self.conn.cursor().execute(*args)
if kw.get('all', True):
return ans.fetchall()
with suppress(StopIteration, IndexError):
return next(ans)[0]
def execute(self, sql, bindings=None):
cursor = self.conn.cursor()
return cursor.execute(sql, bindings)
def add_last_read_position(self, library_id, book_id, fmt, user, cfi, pos_frac, tooltip):
with lock, self.conn:
if not cfi:
self.execute(
'DELETE FROM last_read_positions WHERE library_id=? AND book=? AND format=? AND user=?',
(library_id, book_id, fmt, user))
else:
epoch = time_ns()
self.execute(
'INSERT OR REPLACE INTO last_read_positions(library_id,book,format,user,cfi,epoch,pos_frac,tooltip) VALUES (?,?,?,?,?,?,?,?)',
(library_id, book_id, fmt, user, cfi, epoch, pos_frac, tooltip))
items = tuple(self.get('SELECT epoch FROM last_read_positions WHERE user=? ORDER BY epoch DESC', (user,), all=True))
if len(items) > self.limit:
limit_epoch = items[self.limit][0]
self.execute('DELETE FROM last_read_positions WHERE user=? AND epoch <= ?', (user, limit_epoch))
return epoch
def get_recently_read(self, user):
with lock:
ans = []
for library_id, book, fmt, cfi, epoch, pos_frac, tooltip in self.execute(
'SELECT library_id,book,format,cfi,epoch,pos_frac,tooltip FROM last_read_positions WHERE user=? ORDER BY epoch DESC', (user,)
):
ans.append({
'library_id': library_id, 'book_id': book, 'format': fmt,
'cfi': cfi, 'epoch':epoch, 'pos_frac':pos_frac, 'tooltip': tooltip,
})
return ans
path_cache = {}
def last_read_cache(path=''):
with lock:
ans = path_cache.get(path)
if ans is None:
ans = path_cache[path] = LastReadCache(path)
return ans

View File

@ -262,3 +262,18 @@ class ContentTest(LibraryBaseTest):
text = 'a' * (127 * 1024)
t('<p>{0}<p>{0}'.format(text), [{"n":"p","x":text}, {'n':'p','x':text}])
# }}}
def test_last_read_cache(self): # {{{
from calibre.srv.last_read import last_read_cache, path_cache
path_cache.clear()
lrc = last_read_cache(':memory:')
epoch = lrc.add_last_read_position('lib', 1, 'FMT', 'user', 'epubcfi(/)', 0.1, 'tt')
expected = {'library_id': 'lib', 'book_id': 1, 'format': 'FMT', 'cfi': 'epubcfi(/)', 'epoch': epoch, 'pos_frac': 0.1, 'tooltip': 'tt'}
self.ae(lrc.get_recently_read('user'), [expected])
epoch = lrc.add_last_read_position('lib', 1, 'FMT', 'user', 'epubcfi(/)', 0.2, 'tt')
expected['epoch'], expected['pos_frac'] = epoch, 0.2
self.ae(lrc.get_recently_read('user'), [expected])
for book_id in range(2, 7):
lrc.add_last_read_position('lib', book_id, 'FMT', 'user', 'epubcfi(/)', 0.1, 'tt')
self.ae(len(lrc.get_recently_read('user')), lrc.limit)
# }}}

View File

@ -3,16 +3,18 @@
from __python__ import bound_methods, hash_literals
from elementmaker import E
from gettext import gettext as _
from ajax import ajax_send
from ajax import absolute_path, ajax_send
from book_list.cover_grid import BORDER_RADIUS
from book_list.globals import get_db
from book_list.library_data import last_virtual_library_for, sync_library_books, all_libraries
from book_list.router import open_book, update_window_title
from book_list.library_data import (
all_libraries, last_virtual_library_for, sync_library_books
)
from book_list.router import open_book, open_book_url, update_window_title
from book_list.top_bar import add_button, create_top_bar
from book_list.ui import set_default_panel_handler, show_panel
from dom import add_extra_css, build_rule, clear, ensure_id, set_css, unique_id
from gettext import gettext as _
from modals import create_custom_dialog
from session import get_device_uuid, get_interface_data
from utils import conditional_timeout, safe_set_inner_html, username_key
@ -98,17 +100,44 @@ def start_sync(to_sync):
sync_library_books(lid, libraries[lid], sync_data_received)
def show_recent_stage2(books):
container = document.getElementById(this)
if not container or not books.length:
return
def prepare_recent_container(container):
container.style.display = 'block'
container.style.borderBottom = 'solid 1px currentColor'
container.style.paddingBottom = '1em'
container.appendChild(E.h2(_(
'Continue reading…')))
container.appendChild(E.div(style='display:flex'))
images = container.lastChild
cover_container = container.lastChild
container.appendChild(E.div(style='margin: 1rem 1rem',
create_button(
_('Browse all downloaded books…'),
action=def():
show_panel('local_books')
)))
return cover_container
def show_recent_for_user(container_id, interface_data):
container = document.getElementById(container_id)
images = prepare_recent_container(container)
for item in interface_data.recently_read_by_user[:3]:
q = {'library_id': item.library_id}
if item.cfi:
q.bookpos = item.cfi
url_to_read = open_book_url(item.book_id, item.format, q)
images.appendChild(E.div(style='margin: 0 1em',
E.a(
title=item.tooltip,
href=url_to_read,
E.img(alt=item.tooltip, src=absolute_path(f'get/cover/{item.book_id}/{item.library_id}'))
)))
def show_recent_stage2(books):
container = document.getElementById(this)
if not container or not books.length:
return
images = prepare_recent_container(container)
db = get_db()
to_sync = v'[]'
username = get_interface_data().username
@ -129,12 +158,6 @@ def show_recent_stage2(books):
if book.cover_name:
db.get_file(book, book.cover_name, show_cover.bind(img_id))
start_sync(to_sync)
container.appendChild(E.div(style='margin: 1rem 1rem',
create_button(
_('Browse all downloaded books…'),
action=def():
show_panel('local_books')
)))
def show_recent():
@ -242,6 +265,9 @@ def init(container_id):
recent = E.div(style='display:none', class_='recently-read')
recent_container_id = ensure_id(recent)
container.appendChild(recent)
if interface_data.username and interface_data.recently_read_by_user and interface_data.recently_read_by_user.length > 0:
show_recent_for_user(recent_container_id, interface_data)
else:
conditional_timeout(recent_container_id, 5, show_recent)
# Choose library

View File

@ -243,6 +243,7 @@ default_interface_data = {
'custom_list_template': None,
'num_per_page': 50,
'lang_code_for_user_manual': '',
'recently_read_by_user': v'[]',
}
def get_interface_data():