mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-08 02:34:06 -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.constants import cache_dir, iswindows
|
||||||
from calibre.customize.ui import plugin_for_input_format
|
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.errors import BookNotFound, HTTPNotFound
|
||||||
|
from calibre.srv.last_read import last_read_cache
|
||||||
from calibre.srv.metadata import book_as_json
|
from calibre.srv.metadata import book_as_json
|
||||||
from calibre.srv.render_book import RENDER_VERSION
|
from calibre.srv.render_book import RENDER_VERSION
|
||||||
from calibre.srv.routes import endpoint, json
|
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']
|
device, cfi, pos_frac = data['device'], data['cfi'], data['pos_frac']
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPNotFound('Invalid data')
|
raise HTTPNotFound('Invalid data')
|
||||||
|
cfi = cfi or None
|
||||||
db.set_last_read_position(
|
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'
|
rd.outheaders['Content-type'] = 'text/plain'
|
||||||
return b''
|
return b''
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ from calibre.srv.ajax import search_result
|
|||||||
from calibre.srv.errors import (
|
from calibre.srv.errors import (
|
||||||
BookNotFound, HTTPBadRequest, HTTPForbidden, HTTPNotFound, HTTPRedirect,
|
BookNotFound, HTTPBadRequest, HTTPForbidden, HTTPNotFound, HTTPRedirect,
|
||||||
)
|
)
|
||||||
|
from calibre.srv.last_read import last_read_cache
|
||||||
from calibre.srv.metadata import (
|
from calibre.srv.metadata import (
|
||||||
book_as_json, categories_as_json, categories_settings, icon_map,
|
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(),
|
'lang_code_for_user_manual': lang_code_for_user_manual(),
|
||||||
}
|
}
|
||||||
ans['library_map'], ans['default_library_id'] = ctx.library_info(rd)
|
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
|
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)
|
text = 'a' * (127 * 1024)
|
||||||
t('<p>{0}<p>{0}'.format(text), [{"n":"p","x":text}, {'n':'p','x':text}])
|
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 __python__ import bound_methods, hash_literals
|
||||||
|
|
||||||
from elementmaker import E
|
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.cover_grid import BORDER_RADIUS
|
||||||
from book_list.globals import get_db
|
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.library_data import (
|
||||||
from book_list.router import open_book, update_window_title
|
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.top_bar import add_button, create_top_bar
|
||||||
from book_list.ui import set_default_panel_handler, show_panel
|
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 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 modals import create_custom_dialog
|
||||||
from session import get_device_uuid, get_interface_data
|
from session import get_device_uuid, get_interface_data
|
||||||
from utils import conditional_timeout, safe_set_inner_html, username_key
|
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)
|
sync_library_books(lid, libraries[lid], sync_data_received)
|
||||||
|
|
||||||
|
|
||||||
def show_recent_stage2(books):
|
def prepare_recent_container(container):
|
||||||
container = document.getElementById(this)
|
|
||||||
if not container or not books.length:
|
|
||||||
return
|
|
||||||
container.style.display = 'block'
|
container.style.display = 'block'
|
||||||
container.style.borderBottom = 'solid 1px currentColor'
|
container.style.borderBottom = 'solid 1px currentColor'
|
||||||
container.style.paddingBottom = '1em'
|
container.style.paddingBottom = '1em'
|
||||||
container.appendChild(E.h2(_(
|
container.appendChild(E.h2(_(
|
||||||
'Continue reading…')))
|
'Continue reading…')))
|
||||||
container.appendChild(E.div(style='display:flex'))
|
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()
|
db = get_db()
|
||||||
to_sync = v'[]'
|
to_sync = v'[]'
|
||||||
username = get_interface_data().username
|
username = get_interface_data().username
|
||||||
@ -129,12 +158,6 @@ def show_recent_stage2(books):
|
|||||||
if book.cover_name:
|
if book.cover_name:
|
||||||
db.get_file(book, book.cover_name, show_cover.bind(img_id))
|
db.get_file(book, book.cover_name, show_cover.bind(img_id))
|
||||||
start_sync(to_sync)
|
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():
|
def show_recent():
|
||||||
@ -242,6 +265,9 @@ def init(container_id):
|
|||||||
recent = E.div(style='display:none', class_='recently-read')
|
recent = E.div(style='display:none', class_='recently-read')
|
||||||
recent_container_id = ensure_id(recent)
|
recent_container_id = ensure_id(recent)
|
||||||
container.appendChild(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)
|
conditional_timeout(recent_container_id, 5, show_recent)
|
||||||
|
|
||||||
# Choose library
|
# Choose library
|
||||||
|
@ -243,6 +243,7 @@ default_interface_data = {
|
|||||||
'custom_list_template': None,
|
'custom_list_template': None,
|
||||||
'num_per_page': 50,
|
'num_per_page': 50,
|
||||||
'lang_code_for_user_manual': '',
|
'lang_code_for_user_manual': '',
|
||||||
|
'recently_read_by_user': v'[]',
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_interface_data():
|
def get_interface_data():
|
||||||
|
Loading…
x
Reference in New Issue
Block a user