diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..41eeddc --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim + +# Устанавливаем только то, что нужно для разработки +RUN pip install --no-cache-dir flask psycopg2-binary lxml + +WORKDIR /workspace + +# Контейнер будет жить, пока VS Code к нему подключён +CMD ["sleep", "infinity"] \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..141130e --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "OPDS Library Dev", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached", + "forwardPorts": [8085], + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] + } + } +} \ No newline at end of file diff --git a/app/__pycache__/author_utils.cpython-311.pyc b/app/__pycache__/author_utils.cpython-311.pyc new file mode 100644 index 0000000..3311f7a Binary files /dev/null and b/app/__pycache__/author_utils.cpython-311.pyc differ diff --git a/app/__pycache__/book_utils.cpython-311.pyc b/app/__pycache__/book_utils.cpython-311.pyc new file mode 100644 index 0000000..2029bb3 Binary files /dev/null and b/app/__pycache__/book_utils.cpython-311.pyc differ diff --git a/app/__pycache__/db_utils.cpython-311.pyc b/app/__pycache__/db_utils.cpython-311.pyc new file mode 100644 index 0000000..fc1755d Binary files /dev/null and b/app/__pycache__/db_utils.cpython-311.pyc differ diff --git a/app/__pycache__/opds_server.cpython-311.pyc b/app/__pycache__/opds_server.cpython-311.pyc new file mode 100644 index 0000000..e09fe80 Binary files /dev/null and b/app/__pycache__/opds_server.cpython-311.pyc differ diff --git a/app/__pycache__/series_utils.cpython-311.pyc b/app/__pycache__/series_utils.cpython-311.pyc new file mode 100644 index 0000000..ccb1c92 Binary files /dev/null and b/app/__pycache__/series_utils.cpython-311.pyc differ diff --git a/app/author_utils.py b/app/author_utils.py new file mode 100644 index 0000000..b2fc52f --- /dev/null +++ b/app/author_utils.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Модуль для работы с авторами""" +from db_utils import get_cursor + + +def get_authors_by_prefix(prefix=''): + """Возвращает авторов по префиксу фамилии""" + conn, cur = get_cursor() + + if prefix: + cur.execute(""" + SELECT a.id, a.full_name, a.name_hash, + COUNT(DISTINCT ba.book_id) as books_count + FROM authors a + LEFT JOIN book_authors ba ON ba.author_id = a.id + WHERE a.last_name ILIKE %s + GROUP BY a.id, a.full_name, a.name_hash + ORDER BY a.last_name, a.first_name + LIMIT 100 + """, (prefix + '%',)) + else: + cur.execute(""" + SELECT DISTINCT lower(substring(last_name, 1, 1)) as letter, + COUNT(*) as cnt + FROM authors + WHERE last_name ~ '^[а-яА-Яa-zA-Z]' + GROUP BY lower(substring(last_name, 1, 1)) + ORDER BY letter + """) + + result = cur.fetchall() + conn.close() + return result + + +def get_author_prefixes(prefix=''): + """Возвращает группировку по следующим буквам""" + conn, cur = get_cursor() + + next_len = len(prefix) + 1 + cur.execute(""" + SELECT DISTINCT + substring(last_name, %s, 1) as next_letter, + COUNT(*) as cnt, + MIN(last_name) as first_author, + MAX(last_name) as last_author + FROM authors + WHERE last_name ILIKE %s + GROUP BY substring(last_name, %s, 1) + ORDER BY next_letter + """, (next_len, prefix + '%', next_len)) + + result = cur.fetchall() + conn.close() + return result + + +def get_author_by_hash(author_hash): + """Возвращает автора по хешу""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT id, full_name, last_name, first_name, middle_name, name_hash + FROM authors + WHERE name_hash = %s + """, (author_hash,)) + + author = cur.fetchone() + conn.close() + return author + + +def get_author_series(author_id): + """Возвращает серии автора""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT DISTINCT s.id, s.name, s.name_hash, + COUNT(DISTINCT b.id) as books_count + FROM series s + JOIN books b ON b.series_id = s.id + JOIN book_authors ba ON ba.book_id = b.id + WHERE ba.author_id = %s + GROUP BY s.id, s.name, s.name_hash + ORDER BY s.name + """, (author_id,)) + + series = cur.fetchall() + conn.close() + return series + + +def get_author_books(author_id): + """Возвращает книги автора (без серий)""" + conn, cur = get_cursor() + + # Простой запрос без string_agg + cur.execute(""" + SELECT b.id, b.title, b.language, b.added_date, b.series_number, + s.name as series_name, s.name_hash as series_hash + FROM books b + JOIN book_authors ba ON ba.book_id = b.id + LEFT JOIN series s ON s.id = b.series_id + WHERE ba.author_id = %s + GROUP BY b.id, b.title, b.language, b.added_date, b.series_number, + s.name, s.name_hash + ORDER BY b.added_date DESC NULLS LAST + """, (author_id,)) + + books = cur.fetchall() + + # Для каждой книги получаем количество авторов и соавторов + for book in books: + cur.execute(""" + SELECT COUNT(*) as cnt + FROM book_authors + WHERE book_id = %s + """, (book['id'],)) + book['authors_count'] = cur.fetchone()['cnt'] + + cur.execute(""" + SELECT string_agg(a.full_name, ', ' ORDER BY ba.position) as coauthors + FROM book_authors ba + JOIN authors a ON a.id = ba.author_id + WHERE ba.book_id = %s + """, (book['id'],)) + book['coauthors'] = cur.fetchone()['coauthors'] + + conn.close() + return books + + +def get_series_books(series_id): + """Возвращает все книги серии""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT b.id, b.title, b.language, b.series_number, + string_agg(DISTINCT a.full_name, ', ' ORDER BY ba.position) as authors + FROM books b + JOIN book_authors ba ON ba.book_id = b.id + JOIN authors a ON a.id = ba.author_id + WHERE b.series_id = %s + GROUP BY b.id, b.title, b.language, b.series_number + ORDER BY b.series_number + """, (series_id,)) + + books = cur.fetchall() + conn.close() + return books \ No newline at end of file diff --git a/app/book_utils.py b/app/book_utils.py new file mode 100644 index 0000000..74ddede --- /dev/null +++ b/app/book_utils.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Модуль для работы с книгами (извлечение из fb2, конвертация)""" +import zipfile +import os +import base64 +import io +from lxml import etree + +BOOKS_PATH = os.environ.get( + 'BOOKS_PATH', + '/mnt/hdd/share/Torrents/Downloaded/Libruks' +) +COVERS_PATH = os.environ.get('COVERS_PATH', '/mnt/hdd/opds/covers') + +# Пространства имён fb2 +FB2_NS = 'http://www.gribuser.ru/xml/fictionbook/2.0' +XLINK_NS = 'http://www.w3.org/1999/xlink' +NS = {'fb': FB2_NS, 'xl': XLINK_NS} + +# Кэш для распарсенных fb2 файлов (в памяти) +_fb2_cache = {} + + +def find_book_archive(internal_id): + """Находит архив с книгой по internal_id""" + archives_dir = os.path.join(BOOKS_PATH, 'Архивы Либрусек') + + if not os.path.exists(archives_dir): + return None + + for archive_name in sorted(os.listdir(archives_dir)): + if not archive_name.endswith('.zip'): + continue + + try: + parts = archive_name.replace('.zip', '').split('-') + start_id = int(parts[1]) + end_id = int(parts[2]) + except: + continue + + if start_id <= internal_id <= end_id: + return os.path.join(archives_dir, archive_name) + + return None + + +def get_book_fb2(internal_id): + """Извлекает fb2 данные из архива, обрабатывая вложенные zip""" + archive_path = find_book_archive(internal_id) + if not archive_path: + return None + + try: + with zipfile.ZipFile(archive_path, 'r') as zf: + for filename in zf.namelist(): + if str(internal_id) in filename: + data = zf.read(filename) + + # Проверяем, не zip ли это внутри + if data[:4] == b'PK\x03\x04': + inner_zip = zipfile.ZipFile(io.BytesIO(data)) + # Ищем fb2 внутри + for inner_name in inner_zip.namelist(): + if inner_name.lower().endswith('.fb2'): + return inner_zip.read(inner_name) + # Если fb2 не найден, берём первый файл + if inner_zip.namelist(): + return inner_zip.read(inner_zip.namelist()[0]) + + return data + except: + pass + + return None + + +def get_parsed_fb2(internal_id): + """Возвращает распарсенное fb2 дерево (с кэшированием)""" + if internal_id in _fb2_cache: + return _fb2_cache[internal_id] + + fb2_data = get_book_fb2(internal_id) + if not fb2_data: + return None + + try: + parser = etree.XMLParser(recover=True) + tree = etree.fromstring(fb2_data, parser) + _fb2_cache[internal_id] = tree + return tree + except: + return None + + +def get_annotation(tree): + """Извлекает аннотацию из fb2""" + if tree is None: + return None + + annotation = tree.find(f'.//{{{FB2_NS}}}annotation') + if annotation is not None: + texts = [] + for p in annotation.findall(f'.//{{{FB2_NS}}}p'): + text = ''.join(p.itertext()).strip() + if text: + texts.append(text) + + if texts: + return '\n\n'.join(texts) + + text = ''.join(annotation.itertext()).strip() + if text: + return text + + return None + + +def get_cover_image(tree): + """Извлекает обложку из fb2. Возвращает (data, mime_type) или (None, None)""" + if tree is None: + return None, None + + coverpage = tree.find(f'.//{{{FB2_NS}}}coverpage') + if coverpage is None: + return None, None + + image = coverpage.find(f'.//{{{FB2_NS}}}image') + if image is None: + return None, None + + href = image.get(f'{{{XLINK_NS}}}href') + if not href: + return None, None + + binary_id = href.lstrip('#') + binary = tree.find(f".//{{{FB2_NS}}}binary[@id='{binary_id}']") + if binary is None: + return None, None + + mime_type = binary.get('content-type', 'image/jpeg') + data = binary.text + + if not data: + return None, None + + try: + return base64.b64decode(data), mime_type + except: + return None, None + + +def get_cover_cached(internal_id): + """Возвращает обложку из кэша или извлекает из fb2 и кэширует""" + os.makedirs(COVERS_PATH, exist_ok=True) + + cover_path = os.path.join(COVERS_PATH, f"{internal_id}.jpg") + + if os.path.exists(cover_path): + with open(cover_path, 'rb') as f: + return f.read(), 'image/jpeg' + + tree = get_parsed_fb2(internal_id) + if tree is None: + return None, None + + data, mime_type = get_cover_image(tree) + if data is None: + return None, None + + try: + with open(cover_path, 'wb') as f: + f.write(data) + except: + pass + + return data, mime_type + + +def get_table_of_contents(tree): + """Извлекает оглавление из fb2""" + if tree is None: + return [] + + sections = [] + body = tree.find(f'.//{{{FB2_NS}}}body') + + if body is not None: + for i, section in enumerate(body.findall(f'.//{{{FB2_NS}}}section')): + title_elem = section.find(f'{{{FB2_NS}}}title') + if title_elem is not None: + title = ''.join(title_elem.itertext()).strip() + if title: + sections.append({ + 'index': i, + 'title': title + }) + + return sections + + +def get_section_html(tree, section_index): + """Конвертирует секцию fb2 в HTML""" + if tree is None: + return None + + body = tree.find(f'.//{{{FB2_NS}}}body') + if body is None: + return None + + sections = body.findall(f'.//{{{FB2_NS}}}section') + if section_index >= len(sections): + return None + + section = sections[section_index] + + html_parts = [] + + for elem in section.iter(): + tag = etree.QName(elem).localname + + if tag == 'title': + html_parts.append(f'

{"".join(elem.itertext())}

') + elif tag == 'p': + text = ''.join(elem.itertext()).strip() + if text: + html_parts.append(f'

{text}

') + elif tag == 'image': + href = elem.get(f'{{{XLINK_NS}}}href') + if href: + binary_id = href.lstrip('#') + binary = tree.find(f".//{{{FB2_NS}}}binary[@id='{binary_id}']") + if binary is not None: + mime_type = binary.get('content-type', 'image/jpeg') + data = binary.text + if data: + html_parts.append( + f'' + ) + + return '\n'.join(html_parts) + + +def clear_cache(): + """Очищает кэш распарсенных fb2 файлов""" + _fb2_cache.clear() \ No newline at end of file diff --git a/app/db_utils.py b/app/db_utils.py new file mode 100644 index 0000000..2348125 --- /dev/null +++ b/app/db_utils.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Модуль для работы с базой данных""" +import psycopg2 +import psycopg2.extras +import os + +DB_CONN = os.environ.get( + 'DB_CONN', + 'dbname=books user=postgres password=Karabas@Barabas host=172.20.0.4' +) + + +def get_db(): + """Возвращает соединение с БД""" + return psycopg2.connect(DB_CONN) + + +def get_cursor(): + """Возвращает соединение и курсор с RealDictCursor""" + conn = get_db() + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + return conn, cur + + +def get_stats(): + """Возвращает статистику библиотеки""" + conn, cur = get_cursor() + + cur.execute("SELECT COUNT(*) as cnt FROM books") + books_count = cur.fetchone()['cnt'] + + cur.execute("SELECT COUNT(*) as cnt FROM authors") + authors_count = cur.fetchone()['cnt'] + + cur.execute("SELECT COUNT(*) as cnt FROM series") + series_count = cur.fetchone()['cnt'] + + cur.execute("SELECT COUNT(*) as cnt FROM genres") + genres_count = cur.fetchone()['cnt'] + + conn.close() + + return { + 'books': books_count, + 'authors': authors_count, + 'series': series_count, + 'genres': genres_count + } \ No newline at end of file diff --git a/app/genre_utils.py b/app/genre_utils.py new file mode 100644 index 0000000..de119f2 --- /dev/null +++ b/app/genre_utils.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Модуль для работы с жанрами""" +from db_utils import get_cursor + + +def get_root_genres(): + """Возвращает корневые жанры с количеством книг""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT g.id, g.code, g.title_ru, g.title_en, + COUNT(DISTINCT bg.book_id) as books_count + FROM genres g + LEFT JOIN book_genres bg ON bg.genre_id = g.id + WHERE g.is_root = TRUE + GROUP BY g.id, g.code, g.title_ru, g.title_en + ORDER BY books_count DESC + """) + + genres = cur.fetchall() + conn.close() + return genres + + +def get_subgenres(root_code): + """Возвращает поджанры для корневого жанра""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT g.id, g.code, g.title_ru, g.title_en, + COUNT(DISTINCT bg.book_id) as books_count + FROM genres g + LEFT JOIN book_genres bg ON bg.genre_id = g.id + WHERE g.root_code = %s AND g.is_root = FALSE + GROUP BY g.id, g.code, g.title_ru, g.title_en + ORDER BY books_count DESC + """, (root_code,)) + + genres = cur.fetchall() + conn.close() + return genres + + +def get_genre_books(genre_id, limit=100, offset=0): + """Возвращает книги жанра""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT b.id, b.title, b.language, b.added_date, + string_agg(DISTINCT a.full_name, ', ' ORDER BY ba.position) as authors + FROM books b + JOIN book_genres bg ON bg.book_id = b.id + JOIN book_authors ba ON ba.book_id = b.id + JOIN authors a ON a.id = ba.author_id + WHERE bg.genre_id = %s + GROUP BY b.id, b.title, b.language, b.added_date + ORDER BY b.added_date DESC NULLS LAST + LIMIT %s OFFSET %s + """, (genre_id, limit, offset)) + + books = cur.fetchall() + conn.close() + return books + + +def get_custom_genres(): + """Возвращает кастомные жанры (не из genres.xml)""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT g.code, g.parent_code, g.root_code, + COUNT(DISTINCT bg.book_id) as books_count + FROM genres g + LEFT JOIN book_genres bg ON bg.genre_id = g.id + WHERE g.is_custom = TRUE + GROUP BY g.code, g.parent_code, g.root_code + ORDER BY books_count DESC + """) + + genres = cur.fetchall() + conn.close() + return genres \ No newline at end of file diff --git a/app/opds_server.py b/app/opds_server.py new file mode 100644 index 0000000..6f16365 --- /dev/null +++ b/app/opds_server.py @@ -0,0 +1,1324 @@ +#!/usr/bin/env python3 +"""Главный Flask-сервер для Домашней библиотеки""" +from flask import Flask, request, Response, render_template_string +import os +from xml.etree.ElementTree import Element, SubElement, tostring +from xml.dom.minidom import parseString + +from db_utils import get_cursor, get_stats +from author_utils import ( + get_authors_by_prefix, + get_author_prefixes, + get_author_by_hash, + get_author_series, + get_author_books, +) +from book_utils import ( + find_book_archive, + get_parsed_fb2, + get_annotation, + get_cover_cached, + get_table_of_contents, + get_section_html, +) +from series_utils import ( + get_series_by_letter, + get_series_by_hash, + get_series_books, +) + +app = Flask(__name__) + + +# ============ HTML ШАБЛОН ============ + +HTML_TEMPLATE = ''' + + + + + + Домашняя библиотека + + + +
+
+
+

📚 Домашняя библиотека

+
+
{{ stats }}
+
+
+
+
+ +
+ {{ content|safe }} +
+ + +''' + + +# ============ ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ============ + +def get_stats_text(): + """Возвращает текст статистики""" + stats = get_stats() + return (f"{stats['books']:,} книг · " + f"{stats['authors']:,} авторов · " + f"{stats['series']:,} серий").replace(',', ' ') + + +def make_xml(feed): + """Форматирует XML для OPDS""" + xml_str = parseString(tostring(feed)).toprettyxml() + return Response(xml_str, mimetype='application/atom+xml') + + +# ============ ВЕБ-МАРШРУТЫ ============ + +@app.route('/') +def index(): + stats_text = get_stats_text() + + content = ''' + +
+

💡 Подсказка

+

Используйте поиск для быстрого нахождения книг. Или выберите раздел для навигации.

+

+ OPDS-адрес для читалок: + http://192.168.30.2:8085/opds +

+
+ ''' + + return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text) + + +@app.route('/search') +def web_search(): + q = request.args.get('q', '') + stats_text = get_stats_text() + + if not q: + return render_template_string( + HTML_TEMPLATE, query='', + content='

Введите поисковый запрос

', + stats=stats_text + ) + + page = int(request.args.get('page', 1)) + offset = (page - 1) * 20 + + conn, cur = get_cursor() + + cur.execute(""" + SELECT b.id, b.title, b.language, b.added_date, + string_agg(DISTINCT a.full_name, ', ' ORDER BY ba.position) as authors, + (SELECT COUNT(*) FROM book_authors ba2 WHERE ba2.book_id = b.id) as authors_count, + string_agg(DISTINCT g.title_ru, ', ' ORDER BY g.title_ru) as genres + FROM books b + LEFT JOIN book_authors ba ON ba.book_id = b.id + LEFT JOIN authors a ON a.id = ba.author_id + LEFT JOIN book_genres bg ON bg.book_id = b.id + LEFT JOIN genres g ON g.id = bg.genre_id + WHERE b.fts @@ plainto_tsquery('russian', %s) + OR a.full_name ILIKE %s + GROUP BY b.id, b.title, b.language, b.added_date + ORDER BY ts_rank(b.fts, plainto_tsquery('russian', %s)) DESC + LIMIT 20 OFFSET %s + """, (q, f'%{q}%', q, offset)) + + books = cur.fetchall() + conn.close() + + content = ''.format(q) + + if books: + content += '
' + for book in books: + genres_html = '' + if book['genres']: + for genre in book['genres'].split(', ')[:3]: + genres_html += '{}'.format(genre) + + coauthors_html = '' + if book['authors_count'] > 1: + if book['authors_count'] <= 3: + coauthors_html = '👥 соавторы: {}'.format(book['authors']) + else: + coauthors_html = '👥 {} авторов'.format(book['authors_count']) + + title_display = book['title'] + if book.get('language') and book['language'] != 'ru': + title_display += f" ({book['language']})" + + content += ''' +
+
+ +
{}
+
{}{}
+
+ +
+ '''.format(book['id'], title_display, book['authors'] or 'Автор неизвестен', + genres_html, coauthors_html, book['id'], book['id']) + content += '
' + + content += '
' + if page > 1: + content += '← Назад'.format(q, page-1) + content += 'Вперёд →'.format(q, page+1) + content += '
' + else: + content += '

Ничего не найдено

' + + return render_template_string(HTML_TEMPLATE, query=q, content=content, stats=stats_text) + + +@app.route('/authors') +def web_authors(): + prefix = request.args.get('prefix', '') + stats_text = get_stats_text() + + if prefix: + conn, cur = get_cursor() + cur.execute("SELECT COUNT(*) as cnt FROM authors WHERE last_name ILIKE %s", (prefix + '%',)) + cnt = cur.fetchone()['cnt'] + conn.close() + + if cnt > 50: + groups = get_author_prefixes(prefix) + + content = '' + + content += '

Авторы: {}

'.format(prefix.upper()) + content += '
' + + for g in groups: + next_prefix = prefix + g['next_letter'] + content += ''' +
+ {} +
{} авторов
+
{} — {}
+
+ '''.format(next_prefix, next_prefix.upper(), g['cnt'], g['first_author'], g['last_author']) + + content += '
' + else: + authors = get_authors_by_prefix(prefix) + + content = '' + + content += '

Авторы: {}

'.format(prefix.upper()) + content += '
' + + for a in authors: + content += ''' +
+ {} +
{} книг
+
+ '''.format(a['name_hash'], a['full_name'], a['books_count']) + + content += '
' + else: + conn, cur = get_cursor() + cur.execute(""" + SELECT DISTINCT lower(substring(last_name, 1, 1)) as letter, + COUNT(*) as cnt + FROM authors + WHERE last_name ~ '^[а-яА-Яa-zA-Z]' + GROUP BY lower(substring(last_name, 1, 1)) + ORDER BY letter + """) + letters = cur.fetchall() + conn.close() + + content = '

Выберите букву:

' + content += '
' + for l in letters: + content += '{}'.format( + l['letter'], l['letter'].upper()) + content += '
' + + return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text) + + +@app.route('/author/') +def web_author_page(author_hash): + author = get_author_by_hash(author_hash) + stats_text = get_stats_text() + + if not author: + return render_template_string( + HTML_TEMPLATE, query='', + content='

Автор не найден

', + stats=stats_text + ) + + author_series = get_author_series(author['id']) + author_books = get_author_books(author['id']) + + content = ''' + +
+

👤 {}

+
{} книг
+
+ '''.format( + author['full_name'], author['full_name'], + len(author_books) + sum(s['books_count'] for s in author_series) + ) + + if author_series: + content += '

📚 Серии

' + content += '
' + for s in author_series: + content += ''' +
+ {} + {} книг +
+ '''.format(s['name_hash'], s['name'], s['books_count']) + content += '
' + + content += '

📖 Книги и сборники

' + + if author_books: + content += '
' + for book in author_books: + coauthors_html = '' + if book['authors_count'] > 1: + if book['authors_count'] <= 3: + coauthors_list = [ + a.strip() for a in book['coauthors'].split(', ') + if a.strip() != author['full_name'] + ] + coauthors_html = '👥 соавторы: {}'.format( + ', '.join(coauthors_list[:2])) + else: + coauthors_html = '👥 {} авторов'.format( + book['authors_count']) + + series_html = '' + if book['series_name']: + series_html = '📚 {}'.format(book['series_name']) + + date_html = book['added_date'].strftime('%Y') if book['added_date'] else '' + + title_display = book['title'] + if book.get('language') and book['language'] != 'ru': + title_display += f" ({book['language']})" + + content += ''' +
+
+ +
{}
+
{}{}
+
+ +
+ '''.format(book['id'], title_display, date_html, series_html, coauthors_html, + book['id'], book['id']) + content += '
' + else: + content += '

Нет книг

' + + return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text) + + +@app.route('/series/') +def web_series_page(series_hash): + stats_text = get_stats_text() + + series = get_series_by_hash(series_hash) + if not series: + return render_template_string( + HTML_TEMPLATE, query='', + content='

Серия не найдена

', + stats=stats_text + ) + + books = get_series_books(series['id']) + + content = ''' + +
+

📚 {}

+
{} книг
+
+ '''.format(series['name'], series['name'], len(books)) + + if books: + content += '
' + for book in books: + number_html = '' + if book['series_number'] and book['series_number'] > 0: + number_html = '{:.0f}.'.format( + book['series_number']) + + title_display = book['title'] + if book.get('language') and book['language'] != 'ru': + title_display += f" ({book['language']})" + + content += ''' +
+
+
{}{}
+
{}
+
+ +
+ '''.format(number_html, title_display, book['authors'] or '', book['id'], book['id']) + content += '
' + else: + content += '

Нет книг

' + + return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text) + + +@app.route('/genres') +def web_genres(): + stats_text = get_stats_text() + conn, cur = get_cursor() + + cur.execute(""" + SELECT g.id, g.code, g.title_ru, + COUNT(DISTINCT bg.book_id) as books_count + FROM genres g + LEFT JOIN book_genres bg ON bg.genre_id = g.id + WHERE g.is_root = TRUE + GROUP BY g.id, g.code, g.title_ru + ORDER BY books_count DESC + """) + genres = cur.fetchall() + conn.close() + + content = '

Жанры

' + content += '
' + + for g in genres: + content += ''' +
+ {} +
{} книг
+
+ '''.format(g['code'], g['title_ru'] or g['code'], g['books_count']) + + content += '
' + return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text) + + +# ============ СТРАНИЦА КНИГИ ============ + +@app.route('/book/') +def web_book_page(book_id): + stats_text = get_stats_text() + conn, cur = get_cursor() + + cur.execute(""" + SELECT b.id, b.title, b.language, b.internal_id, b.internal_id2, + b.added_date, b.series_number, + s.name as series_name, s.name_hash as series_hash + FROM books b + LEFT JOIN series s ON s.id = b.series_id + WHERE b.id = %s + """, (book_id,)) + book = cur.fetchone() + + if not book: + conn.close() + return render_template_string( + HTML_TEMPLATE, query='', + content='

Книга не найдена

', + stats=stats_text + ) + + cur.execute(""" + SELECT a.id, a.full_name, a.name_hash, ba.position + FROM book_authors ba + JOIN authors a ON a.id = ba.author_id + WHERE ba.book_id = %s + ORDER BY ba.position + """, (book_id,)) + authors = cur.fetchall() + + cur.execute(""" + SELECT g.id, g.code, g.title_ru + FROM book_genres bg + JOIN genres g ON g.id = bg.genre_id + WHERE bg.book_id = %s + ORDER BY g.code + """, (book_id,)) + genres = cur.fetchall() + conn.close() + + internal_id = book['internal_id2'] or book['internal_id'] + + title_display = book['title'] + if book['language'] and book['language'] != 'ru': + title_display += ' (' + book['language'] + ')' + + content = '' + + content += '
' + content += '
' + content += '' + content += '' + content += '
' + + content += '
' + content += '

' + title_display + '

' + + if authors: + content += '

Автор: ' + author_links = [] + for a in authors: + author_links.append('' + a['full_name'] + '') + content += ', '.join(author_links) + '

' + + if book['series_name']: + content += '

Серия: ' + book['series_name'] + '' + if book['series_number'] and book['series_number'] > 0: + content += ' (книга ' + str(int(book['series_number'])) + ')' + content += '

' + + if genres: + content += '

Жанры: ' + for g in genres: + content += '' + (g['title_ru'] or g['code']) + ' ' + content += '

' + + if book['added_date']: + content += '

Добавлена: ' + book['added_date'].strftime('%d.%m.%Y') + '

' + + try: + tree = get_parsed_fb2(internal_id) + annotation = get_annotation(tree) + if annotation: + content += '
' + content += 'Аннотация:
' + content += '

' + annotation + '

' + content += '
' + except: + pass + + content += '
' + + try: + tree = get_parsed_fb2(internal_id) + toc = get_table_of_contents(tree) + if toc: + content += '

📋 Оглавление

' + content += '
' + for i, section in enumerate(toc): + content += '' + content += '
' + except: + pass + + return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text) + + +@app.route('/cover/') +def get_cover(book_id): + """Отдаёт обложку книги""" + conn, cur = get_cursor() + cur.execute("SELECT internal_id, internal_id2 FROM books WHERE id = %s", (book_id,)) + book = cur.fetchone() + conn.close() + + if not book: + return Response('Not found', status=404) + + internal_id = book['internal_id2'] or book['internal_id'] + + if not internal_id: + return Response('Not found', status=404) + + data, mime_type = get_cover_cached(internal_id) + + if data is None: + return Response('Not found', status=404) + + return Response(data, mimetype=mime_type or 'image/jpeg') + + +# ============ ЧТЕНИЕ ГЛАВ ============ + +READ_TEMPLATE = ''' + + + + + + {{ title }} - Домашняя библиотека + + + +
+
+ {% if prev_section is not none %} + ← Предыдущая глава + {% else %} + ← Предыдущая глава + {% endif %} + {{ book_title }} + ↑ К оглавлению +
+
{{ section_title }}
+
+
+ {{ content|safe }} +
+ + + +''' + + +@app.route('/read//') +def read_section(book_id, section_index): + """Чтение главы""" + stats_text = get_stats_text() + conn, cur = get_cursor() + + cur.execute(""" + SELECT b.id, b.title, b.internal_id, b.internal_id2 + FROM books b + WHERE b.id = %s + """, (book_id,)) + book = cur.fetchone() + conn.close() + + if not book: + return render_template_string( + HTML_TEMPLATE, query='', + content='

Книга не найдена

', + stats=stats_text + ) + + internal_id = book['internal_id2'] or book['internal_id'] + + tree = get_parsed_fb2(internal_id) + if tree is None: + return render_template_string( + HTML_TEMPLATE, query='', + content='

Не удалось загрузить книгу

', + stats=stats_text + ) + + toc = get_table_of_contents(tree) + + if section_index < 0 or section_index >= len(toc): + return render_template_string( + HTML_TEMPLATE, query='', + content='

Глава не найдена

', + stats=stats_text + ) + + section_html = get_section_html(tree, section_index) + + if section_html is None: + return render_template_string( + HTML_TEMPLATE, query='', + content='

Не удалось загрузить главу

', + stats=stats_text + ) + + prev_section = section_index - 1 if section_index > 0 else None + next_section = section_index + 1 if section_index < len(toc) - 1 else None + section_title = toc[section_index]['title'] if section_index < len(toc) else '' + + return render_template_string( + READ_TEMPLATE, + title=book['title'], + book_id=book_id, + book_title=book['title'], + section_title=section_title, + prev_section=prev_section, + next_section=next_section, + content=section_html + ) + + +# ============ СКАЧИВАНИЕ ============ + +@app.route('/download/') +def download_book(book_id): + """Отдаёт fb2 файл из архива""" + import zipfile + + conn, cur = get_cursor() + cur.execute(""" + SELECT internal_id, internal_id2, file_size, format, title + FROM books + WHERE id = %s + """, (book_id,)) + book = cur.fetchone() + conn.close() + + if not book: + return "Книга не найдена", 404 + + book_internal_id = book['internal_id2'] or book['internal_id'] + + if not book_internal_id: + return "Не удалось определить ID книги", 404 + + archive_path = find_book_archive(book_internal_id) + + if not archive_path: + return f"Архив для книги ID={book_internal_id} не найден", 404 + + try: + with zipfile.ZipFile(archive_path, 'r') as zf: + for filename in zf.namelist(): + if str(book_internal_id) in filename: + file_data = zf.read(filename) + + return Response( + file_data, + mimetype='application/fb2+xml', + headers={ + 'Content-Disposition': f'attachment; filename="{filename}"' + } + ) + except Exception as e: + return f"Ошибка при чтении архива: {str(e)}", 500 + + return f"Файл для книги ID={book_internal_id} не найден в архиве", 404 + + +# ============ OPDS ============ + +@app.route('/opds') +def opds_root(): + feed = Element('feed', { + 'xmlns': 'http://www.w3.org/2005/Atom', + 'xmlns:opds': 'http://opds-spec.org/2010/catalog' + }) + SubElement(feed, 'title').text = 'Домашняя библиотека' + SubElement(feed, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(feed, 'id').text = 'urn:uuid:12345678-1234-1234-1234-123456789abc' + + entries = [ + ('search', 'Поиск', '/opds/search?q={searchTerms}'), + ('authors', 'Авторы', '/opds/authors'), + ('genres', 'Жанры', '/opds/genres') + ] + + for id_, title, href in entries: + entry = SubElement(feed, 'entry') + SubElement(entry, 'title').text = title + SubElement(entry, 'id').text = f'urn:uuid:{id_}' + SubElement(entry, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(entry, 'link', { + 'href': href, + 'type': 'application/atom+xml' + }) + + return make_xml(feed) + + +@app.route('/opds/search') +def opds_search(): + q = request.args.get('q', '') + page = int(request.args.get('page', 1)) + offset = (page - 1) * 50 + + if not q: + return opds_root() + + conn, cur = get_cursor() + + cur.execute(""" + SELECT b.id, b.title, + string_agg(DISTINCT a.full_name, ', ' ORDER BY ba.position) as author, + string_agg(DISTINCT g.code, ':' ORDER BY g.code) as genre_codes + FROM books b + LEFT JOIN book_authors ba ON ba.book_id = b.id + LEFT JOIN authors a ON a.id = ba.author_id + LEFT JOIN book_genres bg ON bg.book_id = b.id + LEFT JOIN genres g ON g.id = bg.genre_id + WHERE b.fts @@ plainto_tsquery('russian', %s) + OR a.full_name ILIKE %s + GROUP BY b.id, b.title + ORDER BY ts_rank(b.fts, plainto_tsquery('russian', %s)) DESC + LIMIT 50 OFFSET %s + """, (q, f'%{q}%', q, offset)) + + books = cur.fetchall() + conn.close() + + feed = Element('feed', { + 'xmlns': 'http://www.w3.org/2005/Atom', + 'xmlns:dc': 'http://purl.org/dc/terms/', + 'xmlns:opds': 'http://opds-spec.org/2010/catalog' + }) + SubElement(feed, 'title').text = f'Поиск: {q}' + SubElement(feed, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(feed, 'id').text = 'urn:uuid:search' + + for book in books: + entry = SubElement(feed, 'entry') + SubElement(entry, 'title').text = book['title'] + SubElement(entry, 'id').text = f"urn:uuid:book:{book['id']}" + SubElement(entry, 'updated').text = '2026-10-10T00:00:00Z' + author = SubElement(entry, 'author') + SubElement(author, 'name').text = book['author'] + SubElement(entry, 'link', { + 'href': f"/download/{book['id']}", + 'type': 'application/fb2+zip', + 'rel': 'http://opds-spec.org/acquisition' + }) + if book['genre_codes']: + for genre in book['genre_codes'].split(':'): + SubElement(entry, 'category', {'term': genre, 'label': genre}) + + return make_xml(feed) + + +@app.route('/opds/authors') +def opds_authors(): + prefix = request.args.get('prefix', '') + + feed = Element('feed', { + 'xmlns': 'http://www.w3.org/2005/Atom', + 'xmlns:opds': 'http://opds-spec.org/2010/catalog' + }) + SubElement(feed, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(feed, 'id').text = 'urn:uuid:authors' + + if prefix: + conn, cur = get_cursor() + cur.execute("SELECT COUNT(*) as cnt FROM authors WHERE last_name ILIKE %s", (prefix + '%',)) + cnt = cur.fetchone()['cnt'] + conn.close() + + if cnt > 50: + groups = get_author_prefixes(prefix) + SubElement(feed, 'title').text = f'Авторы: {prefix.upper()}' + + for g in groups: + next_prefix = prefix + g['next_letter'] + entry = SubElement(feed, 'entry') + SubElement(entry, 'title').text = f"{next_prefix.upper()} ({g['cnt']})" + SubElement(entry, 'id').text = f"urn:uuid:prefix:{next_prefix}" + SubElement(entry, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(entry, 'link', { + 'href': f"/opds/authors?prefix={next_prefix}", + 'type': 'application/atom+xml' + }) + else: + authors = get_authors_by_prefix(prefix) + SubElement(feed, 'title').text = f'Авторы: {prefix.upper()}' + + for a in authors: + entry = SubElement(feed, 'entry') + SubElement(entry, 'title').text = a['full_name'] + SubElement(entry, 'id').text = f"urn:uuid:author:{a['name_hash']}" + SubElement(entry, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(entry, 'link', { + 'href': f"/opds/author/{a['name_hash']}", + 'type': 'application/atom+xml' + }) + else: + conn, cur = get_cursor() + cur.execute(""" + SELECT DISTINCT lower(substring(last_name, 1, 1)) as letter, + COUNT(*) as cnt + FROM authors + WHERE last_name ~ '^[а-яА-Яa-zA-Z]' + GROUP BY lower(substring(last_name, 1, 1)) + ORDER BY letter + """) + letters = cur.fetchall() + conn.close() + + SubElement(feed, 'title').text = 'Авторы по алфавиту' + + for l in letters: + entry = SubElement(feed, 'entry') + SubElement(entry, 'title').text = f"{l['letter'].upper()} ({l['cnt']})" + SubElement(entry, 'id').text = f"urn:uuid:letter:{l['letter']}" + SubElement(entry, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(entry, 'link', { + 'href': f"/opds/authors?prefix={l['letter']}", + 'type': 'application/atom+xml' + }) + + return make_xml(feed) + + +@app.route('/opds/author/') +def opds_author_books(author_hash): + author = get_author_by_hash(author_hash) + + if not author: + return make_xml(Element('feed', { + 'xmlns': 'http://www.w3.org/2005/Atom', + 'xmlns:opds': 'http://opds-spec.org/2010/catalog' + })) + + books = get_author_books(author['id']) + + feed = Element('feed', { + 'xmlns': 'http://www.w3.org/2005/Atom', + 'xmlns:dc': 'http://purl.org/dc/terms/', + 'xmlns:opds': 'http://opds-spec.org/2010/catalog' + }) + SubElement(feed, 'title').text = f'Книги: {author["full_name"]}' + SubElement(feed, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(feed, 'id').text = f'urn:uuid:author:{author_hash}' + + for book in books: + entry = SubElement(feed, 'entry') + SubElement(entry, 'title').text = book['title'] + SubElement(entry, 'id').text = f"urn:uuid:book:{book['id']}" + SubElement(entry, 'updated').text = '2026-10-10T00:00:00Z' + author_elem = SubElement(entry, 'author') + SubElement(author_elem, 'name').text = author['full_name'] + SubElement(entry, 'link', { + 'href': f"/download/{book['id']}", + 'type': 'application/fb2+zip', + 'rel': 'http://opds-spec.org/acquisition' + }) + + return make_xml(feed) + + +@app.route('/opds/genres') +def opds_genres(): + conn, cur = get_cursor() + cur.execute(""" + SELECT g.code, g.title_ru, COUNT(bg.book_id) as cnt + FROM genres g + LEFT JOIN book_genres bg ON bg.genre_id = g.id + WHERE g.is_root = TRUE + GROUP BY g.code, g.title_ru + ORDER BY cnt DESC + """) + genres = cur.fetchall() + conn.close() + + feed = Element('feed', { + 'xmlns': 'http://www.w3.org/2005/Atom', + 'xmlns:opds': 'http://opds-spec.org/2010/catalog' + }) + SubElement(feed, 'title').text = 'Жанры' + SubElement(feed, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(feed, 'id').text = 'urn:uuid:genres' + + for g in genres: + entry = SubElement(feed, 'entry') + SubElement(entry, 'title').text = f"{g['title_ru'] or g['code']} ({g['cnt']})" + SubElement(entry, 'id').text = f"urn:uuid:genre:{g['code']}" + SubElement(entry, 'updated').text = '2026-10-10T00:00:00Z' + SubElement(entry, 'link', { + 'href': f"/opds/genres?parent={g['code']}", + 'type': 'application/atom+xml' + }) + + return make_xml(feed) + + +# ============ ЗАПУСК ============ + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8085, debug=False) diff --git a/app/series_utils.py b/app/series_utils.py new file mode 100644 index 0000000..1ede83f --- /dev/null +++ b/app/series_utils.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Модуль для работы с сериями""" +from db_utils import get_cursor + + +def get_series_by_letter(letter=''): + """Возвращает серии по первой букве""" + conn, cur = get_cursor() + + if letter: + cur.execute(""" + SELECT s.id, s.name, s.name_hash, + COUNT(DISTINCT b.id) as books_count + FROM series s + LEFT JOIN books b ON b.series_id = s.id + WHERE s.name ILIKE %s + GROUP BY s.id, s.name, s.name_hash + ORDER BY s.name + LIMIT 100 + """, (letter + '%',)) + else: + cur.execute(""" + SELECT DISTINCT lower(substring(name, 1, 1)) as letter, + COUNT(*) as cnt + FROM series + WHERE name ~ '^[а-яА-Яa-zA-Z]' + GROUP BY lower(substring(name, 1, 1)) + ORDER BY letter + """) + + result = cur.fetchall() + conn.close() + return result + + +def get_series_by_hash(series_hash): + """Возвращает серию по хешу""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT id, name, name_hash + FROM series + WHERE name_hash = %s + """, (series_hash,)) + + series = cur.fetchone() + conn.close() + return series + + +def get_series_books(series_id): + """Возвращает все книги серии""" + conn, cur = get_cursor() + + cur.execute(""" + SELECT b.id, b.title, b.language, b.series_number, + string_agg(DISTINCT a.full_name, ', ' ORDER BY ba.position) as authors + FROM books b + JOIN book_authors ba ON ba.book_id = b.id + JOIN authors a ON a.id = ba.author_id + WHERE b.series_id = %s + GROUP BY b.id, b.title, b.language, b.series_number + ORDER BY b.series_number NULLS LAST, b.title + """, (series_id,)) + + books = cur.fetchall() + conn.close() + return books \ No newline at end of file diff --git a/data/genres.xml b/data/genres.xml index f55cd8a..823fbf8 100644 --- a/data/genres.xml +++ b/data/genres.xml @@ -1,933 +1,933 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +