Работающий функционал

This commit is contained in:
astakhovp committed 2026-10-10 20:41:32 +03:00
1 parent 3b1df81c41
commit 04138d36b1
14 files changed
+2879 -933

No files matched your search

+9
View File
@@ -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"]
+18
View File
@@ -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"
]
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+150
View File
@@ -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
+247
View File
@@ -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'<h2>{"".join(elem.itertext())}</h2>')
elif tag == 'p':
text = ''.join(elem.itertext()).strip()
if text:
html_parts.append(f'<p>{text}</p>')
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'<img src="data:{mime_type};base64,{data}" '
f'style="max-width: 100%;"/>'
)
return '\n'.join(html_parts)
def clear_cache():
"""Очищает кэш распарсенных fb2 файлов"""
_fb2_cache.clear()
+48
View File
@@ -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
}
+82
View File
@@ -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
+1324
View File
File diff suppressed because it is too large. Load diff
+68
View File
@@ -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
+933 -933
View File
File diff suppressed because it is too large. Load diff