mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-07 18:24:30 -04:00
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:
parent
32acf0c7a5
commit
3537db5a8c
@ -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''
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
87
src/calibre/srv/last_read.py
Normal file
87
src/calibre/srv/last_read.py
Normal 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
|
@ -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)
|
||||
# }}}
|
||||
|
@ -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
|
||||
|
@ -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():
|
||||
|
Loading…
x
Reference in New Issue
Block a user