Files

1325 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Домашняя библиотека</title>
<style>
:root {
--primary: #2c3e50;
--accent: #3498db;
--background: #f8f9fa;
--card: #ffffff;
--text: #333333;
--text-light: #666666;
--border: #e0e0e0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
background: var(--background);
color: var(--text);
line-height: 1.6;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.header h1 { font-size: 24px; font-weight: 600; }
.header h1 a { color: white; text-decoration: none; }
.header .stats { font-size: 14px; opacity: 0.9; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.search-section {
background: var(--card);
border-radius: 12px;
padding: 20px;
margin: 20px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.search-box { display: flex; gap: 10px; }
.search-box input {
flex: 1;
padding: 12px 20px;
font-size: 16px;
border: 2px solid var(--border);
border-radius: 25px;
outline: none;
transition: border-color 0.3s;
}
.search-box input:focus { border-color: var(--accent); }
.search-box button {
padding: 12px 30px;
font-size: 16px;
background: var(--accent);
color: white;
border: none;
border-radius: 25px;
cursor: pointer;
transition: background 0.3s;
}
.search-box button:hover { background: #2980b9; }
.nav-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 20px 0;
}
.nav-card {
background: var(--card);
border-radius: 12px;
padding: 25px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
transition: transform 0.3s, box-shadow 0.3s;
cursor: pointer;
text-decoration: none;
color: var(--text);
display: block;
}
.nav-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
}
.nav-card .icon { font-size: 48px; margin-bottom: 15px; }
.nav-card .title { font-size: 18px; font-weight: 600; margin-bottom: 10px; }
.nav-card .description { color: var(--text-light); font-size: 13px; }
.breadcrumbs {
background: var(--card);
border-radius: 8px;
padding: 12px 20px;
margin: 15px 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
.breadcrumbs a { color: var(--accent); text-decoration: none; }
.breadcrumbs a:hover { text-decoration: underline; }
.breadcrumbs .separator { margin: 0 8px; color: #999; }
.book-list {
background: var(--card);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
overflow: hidden;
}
.book-item {
padding: 15px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
}
.book-item:hover { background: #f8f9fa; }
.book-item:last-child { border-bottom: none; }
.book-info { flex: 1; }
.book-title { font-weight: 600; font-size: 16px; }
.book-title a { color: var(--text); text-decoration: none; }
.book-title a:hover { color: var(--accent); }
.book-meta { margin-top: 5px; font-size: 13px; color: var(--text-light); }
.book-genres { margin-top: 5px; }
.genre-tag {
display: inline-block;
padding: 3px 8px;
margin: 2px;
background: #e8f4fd;
color: #2980b9;
border-radius: 12px;
font-size: 12px;
}
.coauthors-tag {
display: inline-block;
padding: 3px 8px;
margin: 2px;
background: #fef9e7;
color: #f39c12;
border-radius: 12px;
font-size: 12px;
}
.book-actions { margin-left: 20px; }
.download-btn {
padding: 8px 16px;
background: #27ae60;
color: white;
border-radius: 20px;
text-decoration: none;
font-size: 14px;
transition: background 0.3s;
display: inline-block;
}
.download-btn:hover { background: #229954; }
.read-btn {
padding: 8px 16px;
background: var(--accent);
color: white;
border-radius: 20px;
text-decoration: none;
font-size: 14px;
transition: background 0.3s;
display: inline-block;
margin-right: 5px;
}
.read-btn:hover { background: #2980b9; }
.authors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin: 20px 0;
}
.author-card {
background: var(--card);
border-radius: 8px;
padding: 15px;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
text-align: center;
transition: transform 0.2s;
}
.author-card:hover { transform: translateY(-3px); }
.author-card a {
color: var(--text);
text-decoration: none;
font-weight: 500;
}
.author-card a:hover { color: var(--accent); }
.author-card .count {
color: var(--text-light);
font-size: 12px;
margin-top: 5px;
}
.letters-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 20px 0;
}
.letter-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-weight: 600;
transition: all 0.2s;
}
.letter-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.series-list { margin: 20px 0; }
.series-item {
background: var(--card);
border-radius: 8px;
padding: 15px 20px;
margin-bottom: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.series-item a {
color: var(--text);
text-decoration: none;
font-weight: 500;
}
.series-item a:hover { color: var(--accent); }
.section-title {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px;
color: var(--primary);
}
.empty {
text-align: center;
padding: 40px;
color: var(--text-light);
font-size: 18px;
}
.author-header {
background: var(--card);
border-radius: 12px;
padding: 30px;
margin: 20px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
text-align: center;
}
.author-header h2 { font-size: 28px; margin-bottom: 10px; }
.author-header .books-count { color: var(--text-light); font-size: 16px; }
</style>
</head>
<body>
<div class="header">
<div class="header-content">
<div>
<h1><a href="/">📚 Домашняя библиотека</a></h1>
</div>
<div class="stats"><span>{{ stats }}</span></div>
</div>
</div>
<div class="container">
<div class="search-section">
<div class="search-box">
<form action="/search" method="get" style="display: flex; gap: 10px; width: 100%;">
<input type="text" name="q" placeholder="Поиск по названию или автору..." value="{{ query }}" autofocus>
<button type="submit">Поиск</button>
</form>
</div>
</div>
{{ content|safe }}
</div>
</body>
</html>
'''
# ============ ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ============
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 = '''
<div class="nav-cards">
<a href="/authors" class="nav-card">
<div class="icon">👥</div>
<div class="title">Авторы</div>
<div class="description">Навигация по авторам с группировкой по алфавиту</div>
</a>
<a href="/genres" class="nav-card">
<div class="icon">🏷️</div>
<div class="title">Жанры</div>
<div class="description">Просмотр книг по жанрам и категориям</div>
</a>
<a href="/opds" class="nav-card">
<div class="icon">📱</div>
<div class="title">OPDS</div>
<div class="description">Каталог для подключения читалок</div>
</a>
</div>
<div class="search-section">
<h2 style="margin-bottom: 15px;">💡 Подсказка</h2>
<p>Используйте поиск для быстрого нахождения книг. Или выберите раздел для навигации.</p>
<p style="margin-top: 10px; color: #666;">
<strong>OPDS-адрес для читалок:</strong>
<code style="background: #f0f0f0; padding: 5px 10px; border-radius: 5px;">http://192.168.30.2:8085/opds</code>
</p>
</div>
'''
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='<p class="empty">Введите поисковый запрос</p>',
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 = '<div class="breadcrumbs">🔍 Результаты поиска: <strong>{}</strong></div>'.format(q)
if books:
content += '<div class="book-list">'
for book in books:
genres_html = ''
if book['genres']:
for genre in book['genres'].split(', ')[:3]:
genres_html += '<span class="genre-tag">{}</span>'.format(genre)
coauthors_html = ''
if book['authors_count'] > 1:
if book['authors_count'] <= 3:
coauthors_html = '<span class="coauthors-tag">👥 соавторы: {}</span>'.format(book['authors'])
else:
coauthors_html = '<span class="coauthors-tag">👥 {} авторов</span>'.format(book['authors_count'])
title_display = book['title']
if book.get('language') and book['language'] != 'ru':
title_display += f" ({book['language']})"
content += '''
<div class="book-item">
<div class="book-info">
<div class="book-title"><a href="/book/{}">{}</a></div>
<div class="book-meta">{}</div>
<div class="book-genres">{}{}</div>
</div>
<div class="book-actions">
<a href="/book/{}" class="read-btn">Читать</a>
<a href="/download/{}" class="download-btn">Скачать</a>
</div>
</div>
'''.format(book['id'], title_display, book['authors'] or 'Автор неизвестен',
genres_html, coauthors_html, book['id'], book['id'])
content += '</div>'
content += '<div style="text-align: center; margin: 20px;">'
if page > 1:
content += '<a href="/search?q={}&page={}" style="margin: 0 10px;">← Назад</a>'.format(q, page-1)
content += '<a href="/search?q={}&page={}" style="margin: 0 10px;">Вперёд →</a>'.format(q, page+1)
content += '</div>'
else:
content += '<p class="empty">Ничего не найдено</p>'
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 = '<div class="breadcrumbs"><a href="/authors">Авторы</a>'
for i in range(len(prefix)):
content += '<span class="separator">→</span><a href="/authors?prefix={}">{}</a>'.format(
prefix[:i+1], prefix[:i+1].upper())
content += '</div>'
content += '<h2 style="margin: 20px 0;">Авторы: {}</h2>'.format(prefix.upper())
content += '<div class="authors-grid">'
for g in groups:
next_prefix = prefix + g['next_letter']
content += '''
<div class="author-card">
<a href="/authors?prefix={}">{}</a>
<div class="count">{} авторов</div>
<div class="count">{} — {}</div>
</div>
'''.format(next_prefix, next_prefix.upper(), g['cnt'], g['first_author'], g['last_author'])
content += '</div>'
else:
authors = get_authors_by_prefix(prefix)
content = '<div class="breadcrumbs"><a href="/authors">Авторы</a>'
for i in range(len(prefix)):
content += '<span class="separator">→</span><a href="/authors?prefix={}">{}</a>'.format(
prefix[:i+1], prefix[:i+1].upper())
content += '</div>'
content += '<h2 style="margin: 20px 0;">Авторы: {}</h2>'.format(prefix.upper())
content += '<div class="authors-grid">'
for a in authors:
content += '''
<div class="author-card">
<a href="/author/{}">{}</a>
<div class="count">{} книг</div>
</div>
'''.format(a['name_hash'], a['full_name'], a['books_count'])
content += '</div>'
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 = '<h2 style="margin: 20px 0;">Выберите букву:</h2>'
content += '<div class="letters-nav">'
for l in letters:
content += '<a href="/authors?prefix={}" class="letter-btn">{}</a>'.format(
l['letter'], l['letter'].upper())
content += '</div>'
return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text)
@app.route('/author/<author_hash>')
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='<p class="empty">Автор не найден</p>',
stats=stats_text
)
author_series = get_author_series(author['id'])
author_books = get_author_books(author['id'])
content = '''
<div class="breadcrumbs">
<a href="/">Главная</a>
<span class="separator">→</span>
<a href="/authors">Авторы</a>
<span class="separator">→</span>
{}
</div>
<div class="author-header">
<h2>👤 {}</h2>
<div class="books-count">{} книг</div>
</div>
'''.format(
author['full_name'], author['full_name'],
len(author_books) + sum(s['books_count'] for s in author_series)
)
if author_series:
content += '<h2 class="section-title">📚 Серии</h2>'
content += '<div class="series-list">'
for s in author_series:
content += '''
<div class="series-item">
<a href="/series/{}">{}</a>
<span style="color: #999;">{} книг</span>
</div>
'''.format(s['name_hash'], s['name'], s['books_count'])
content += '</div>'
content += '<h2 class="section-title">📖 Книги и сборники</h2>'
if author_books:
content += '<div class="book-list">'
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 = '<span class="coauthors-tag">👥 соавторы: {}</span>'.format(
', '.join(coauthors_list[:2]))
else:
coauthors_html = '<span class="coauthors-tag">👥 {} авторов</span>'.format(
book['authors_count'])
series_html = ''
if book['series_name']:
series_html = '<span class="genre-tag">📚 {}</span>'.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 += '''
<div class="book-item">
<div class="book-info">
<div class="book-title"><a href="/book/{}">{}</a></div>
<div class="book-meta">{}</div>
<div class="book-genres">{}{}</div>
</div>
<div class="book-actions">
<a href="/book/{}" class="read-btn">Читать</a>
<a href="/download/{}" class="download-btn">Скачать</a>
</div>
</div>
'''.format(book['id'], title_display, date_html, series_html, coauthors_html,
book['id'], book['id'])
content += '</div>'
else:
content += '<p class="empty">Нет книг</p>'
return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text)
@app.route('/series/<series_hash>')
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='<p class="empty">Серия не найдена</p>',
stats=stats_text
)
books = get_series_books(series['id'])
content = '''
<div class="breadcrumbs">
<a href="/">Главная</a>
<span class="separator">→</span>
{}
</div>
<div class="author-header">
<h2>📚 {}</h2>
<div class="books-count">{} книг</div>
</div>
'''.format(series['name'], series['name'], len(books))
if books:
content += '<div class="book-list">'
for book in books:
number_html = ''
if book['series_number'] and book['series_number'] > 0:
number_html = '<span style="color: #999; margin-right: 10px;">{:.0f}.</span>'.format(
book['series_number'])
title_display = book['title']
if book.get('language') and book['language'] != 'ru':
title_display += f" ({book['language']})"
content += '''
<div class="book-item">
<div class="book-info">
<div class="book-title">{}{}</div>
<div class="book-meta">{}</div>
</div>
<div class="book-actions">
<a href="/book/{}" class="read-btn">Читать</a>
<a href="/download/{}" class="download-btn">Скачать</a>
</div>
</div>
'''.format(number_html, title_display, book['authors'] or '', book['id'], book['id'])
content += '</div>'
else:
content += '<p class="empty">Нет книг</p>'
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 = '<h2 style="margin: 20px 0;">Жанры</h2>'
content += '<div class="authors-grid">'
for g in genres:
content += '''
<div class="author-card">
<a href="/genre/{}">{}</a>
<div class="count">{} книг</div>
</div>
'''.format(g['code'], g['title_ru'] or g['code'], g['books_count'])
content += '</div>'
return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text)
# ============ СТРАНИЦА КНИГИ ============
@app.route('/book/<int:book_id>')
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='<p class="empty">Книга не найдена</p>',
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 = '<div class="breadcrumbs"><a href="/">Главная</a>'
if authors:
content += '<span class="separator">→</span><a href="/author/' + authors[0]['name_hash'] + '">' + authors[0]['full_name'] + '</a>'
content += '<span class="separator">→</span>' + title_display + '</div>'
content += '<div style="display: flex; gap: 30px; margin: 20px 0; flex-wrap: wrap;">'
content += '<div style="flex: 0 0 200px; text-align: center;">'
content += '<img src="/cover/' + str(book['id']) + '" loading="lazy" onerror="this.style.display=' + chr(39) + 'none' + chr(39) + '" style="width: 180px; min-height: 250px; background: #f0f0f0; border-radius: 8px; object-fit: cover;">'
content += '<div style="margin-top: 15px;"><a href="/download/' + str(book['id']) + '" class="download-btn">Скачать fb2</a></div>'
content += '</div>'
content += '<div style="flex: 1; min-width: 300px;">'
content += '<h2 style="margin-bottom: 15px;">' + title_display + '</h2>'
if authors:
content += '<p style="margin-bottom: 10px;"><strong>Автор:</strong> '
author_links = []
for a in authors:
author_links.append('<a href="/author/' + a['name_hash'] + '" style="color: #3498db;">' + a['full_name'] + '</a>')
content += ', '.join(author_links) + '</p>'
if book['series_name']:
content += '<p style="margin-bottom: 10px;"><strong>Серия:</strong> <a href="/series/' + str(book['series_hash']) + '" style="color: #3498db;">' + book['series_name'] + '</a>'
if book['series_number'] and book['series_number'] > 0:
content += ' <span style="color: #999;">(книга ' + str(int(book['series_number'])) + ')</span>'
content += '</p>'
if genres:
content += '<p style="margin-bottom: 10px;"><strong>Жанры:</strong> '
for g in genres:
content += '<span class="genre-tag">' + (g['title_ru'] or g['code']) + '</span> '
content += '</p>'
if book['added_date']:
content += '<p style="margin-bottom: 10px; color: #999;">Добавлена: ' + book['added_date'].strftime('%d.%m.%Y') + '</p>'
try:
tree = get_parsed_fb2(internal_id)
annotation = get_annotation(tree)
if annotation:
content += '<div style="margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">'
content += '<strong>Аннотация:</strong><br>'
content += '<p style="margin-top: 10px; white-space: pre-line;">' + annotation + '</p>'
content += '</div>'
except:
pass
content += '</div></div>'
try:
tree = get_parsed_fb2(internal_id)
toc = get_table_of_contents(tree)
if toc:
content += '<h2 class="section-title">📋 Оглавление</h2>'
content += '<div class="book-list">'
for i, section in enumerate(toc):
content += '<div class="book-item"><div class="book-info"><div class="book-title"><a href="/read/' + str(book['id']) + '/' + str(i) + '">' + section['title'] + '</a></div></div></div>'
content += '</div>'
except:
pass
return render_template_string(HTML_TEMPLATE, query='', content=content, stats=stats_text)
@app.route('/cover/<int:book_id>')
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 = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} - Домашняя библиотека</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f8f9fa;
color: #333;
line-height: 1.8;
}
.read-header {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.read-nav {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.read-nav a {
color: #3498db;
text-decoration: none;
padding: 8px 16px;
background: #f0f0f0;
border-radius: 20px;
font-size: 14px;
transition: background 0.3s;
}
.read-nav a:hover {
background: #e0e0e0;
}
.read-nav .disabled {
color: #999;
background: #f5f5f5;
cursor: not-allowed;
pointer-events: none;
}
.chapter-title {
text-align: center;
font-size: 22px;
font-weight: 600;
margin: 20px 0;
color: #2c3e50;
}
.chapter-content {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.chapter-content h2 {
font-size: 20px;
margin: 20px 0 10px;
color: #2c3e50;
}
.chapter-content p {
margin: 10px 0;
text-indent: 1.5em;
}
.chapter-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 15px auto;
display: block;
}
.read-footer {
margin-top: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="read-header">
<div class="read-nav">
{% if prev_section is not none %}
<a href="/read/{{ book_id }}/{{ prev_section }}">← Предыдущая глава</a>
{% else %}
<span class="disabled">← Предыдущая глава</span>
{% endif %}
<span style="color: #999;">{{ book_title }}</span>
<a href="/book/{{ book_id }}">↑ К оглавлению</a>
</div>
<div class="chapter-title">{{ section_title }}</div>
</div>
<div class="chapter-content">
{{ content|safe }}
</div>
<div class="read-footer">
{% if next_section is not none %}
<a href="/read/{{ book_id }}/{{ next_section }}" style="display: inline-block; padding: 12px 30px; background: #3498db; color: white; text-decoration: none; border-radius: 25px;">Следующая глава →</a>
{% else %}
<span style="display: inline-block; padding: 12px 30px; background: #f0f0f0; color: #999; border-radius: 25px;">Конец книги</span>
{% endif %}
</div>
</body>
</html>
'''
@app.route('/read/<int:book_id>/<int:section_index>')
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='<p class="empty">Книга не найдена</p>',
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='<p class="empty">Не удалось загрузить книгу</p>',
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='<p class="empty">Глава не найдена</p>',
stats=stats_text
)
section_html = get_section_html(tree, section_index)
if section_html is None:
return render_template_string(
HTML_TEMPLATE, query='',
content='<p class="empty">Не удалось загрузить главу</p>',
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/<int:book_id>')
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/<author_hash>')
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)