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 = '''
+
+
+
+
+
+ Домашняя библиотека
+
+
+
+
+
+
+
+'''
+
+
+# ============ ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ============
+
+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 = 'Авторы'
+ for i in range(len(prefix)):
+ content += '
→{}'.format(
+ prefix[:i+1], prefix[:i+1].upper())
+ 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 = 'Авторы'
+ for i in range(len(prefix)):
+ content += '
→{}'.format(
+ prefix[:i+1], prefix[:i+1].upper())
+ 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 }} - Домашняя библиотека
+
+
+
+
+
+ {{ 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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+