1325 lines
46 KiB
Python
1325 lines
46 KiB
Python
#!/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)
|