From 04138d36b17518fe4c5a122d535881b94b196b2b Mon Sep 17 00:00:00 2001 From: Astakhov Peter Date: Sat, 10 Oct 2026 20:41:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/Dockerfile | 9 + .devcontainer/devcontainer.json | 18 + app/__pycache__/author_utils.cpython-311.pyc | Bin 0 -> 6352 bytes app/__pycache__/book_utils.cpython-311.pyc | Bin 0 -> 11229 bytes app/__pycache__/db_utils.cpython-311.pyc | Bin 0 -> 2256 bytes app/__pycache__/opds_server.cpython-311.pyc | Bin 0 -> 56345 bytes app/__pycache__/series_utils.cpython-311.pyc | Bin 0 -> 2881 bytes app/author_utils.py | 150 ++ app/book_utils.py | 247 +++ app/db_utils.py | 48 + app/genre_utils.py | 82 + app/opds_server.py | 1324 +++++++++++++ app/series_utils.py | 68 + data/genres.xml | 1866 +++++++++--------- 14 files changed, 2879 insertions(+), 933 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 app/__pycache__/author_utils.cpython-311.pyc create mode 100644 app/__pycache__/book_utils.cpython-311.pyc create mode 100644 app/__pycache__/db_utils.cpython-311.pyc create mode 100644 app/__pycache__/opds_server.cpython-311.pyc create mode 100644 app/__pycache__/series_utils.cpython-311.pyc create mode 100644 app/author_utils.py create mode 100644 app/book_utils.py create mode 100644 app/db_utils.py create mode 100644 app/genre_utils.py create mode 100644 app/opds_server.py create mode 100644 app/series_utils.py 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 0000000000000000000000000000000000000000..3311f7a71458b53a9aca9e70297503de8250f954 GIT binary patch literal 6352 zcmd^DUu@gP89z#t_1BJN*G{wi=@qMCtCO7u=>BC{u`E^=C5oKc4z{^T2uh?KrLyFa z)RMZ+Cfg|@XU6Of1)2>7mJLJNA`eT`8ui*PecH>ONFWb}um=M}2lP!r@|352ccf0F zEU7k7V8BM3pUAuKJKi0?-|v3MciP(n2#)U-{&8t{7efCcH`T*sT|9aTF5W^Cnm`gK zxw71}Yr@4LDJb#RxCuA>df=Cr+ODAqkK_g0Ci%d6B|li76c|I1P~k;rM1NbquYYJP z>Yo_PSpN`yRqTxHovL|f)UOk$u$cFliY3a zzf+wB#GzbI(=?FpGTQ1UTxyg<1Rv zW(j7b)a(c##r8N*tqp&7+` zmcu;}KOG;(hhBJrl|JG63O!9Gp3Pr2e_2)r)Iv(tluT}F!1ky^QG6&uXEiHpnyj>V z9nVv@bc;!6y&|vCoHh0+{7c+_;br~qps}K_4(fjq2WO8CPMq&|#IPy+(jGg)*esf3 zV0G{D3PD5JD_@b*1x+sdCS@&sQOst`o^&>^$`QWYHlxUsnJZ;JozLaUZlHv6n^ zs=++{CAd?<@HmISAo)viM{;&MxN9mCKwNB<70nRnM92=Mf~#;4I@=gYHic#3=XHu+;*jZLYDLl$mYB@7ss%tpyi4erX4hn-^%igWY2MP98Bey)ETzrxapug1C7MtY?E=aNEgp;kS<_)cbwv6HJ}7}6O!P+NRaMfbp970IhyV5TywnS-UFRAkA_sd z4N)WUwKkwd&k<;@=`-8DqGx&?&-9XKE??fZxFWkpU*QK3)DYC5l>P>g{-6%jcmQ_h zYx+m9FSkJd*NFJ{20$kmi2_ozjFyGnVhVO%xv7FUMXyCkk|jYBHF{@(F;|#QHQ2nE zSRgX1>PNSqHpZ~k5k6;dqGUi>0;gEv&H-NAcbXl*Dj@{I8k7pGUfDQg+l=s)%@HWn ztwhR(%xEPwRGWA_92+}^lV=l&F`PI$Hcq`~Gc1TKnbwM5r(zv0Y>umZ>8Q14!rb@Q z)^pN!*_DxCtCrKs?bbqVehrv494%N~FgLw|I5jok_z2M-#r?Q8K5=L!uV%DNK4N!)5Aw2Y9~m4G?2Xsf2-^x!_NS{Iu>`VB8$S&ItP14) z%@SV{4B!6E>L%F(;elFs&_lw(RHzA|{rC@w-zR>Td^`E>_a5x~ zHiW-y`1a_&J)6$`aAz^`zoN?z!5eE#!uJTxVjCAT+Fci~@04|`{Mm$O>31axvFaWe zzJl45erv4NBaNX9G#A6l&dGOYShR5Hkasp9GAY)QcpcNkx?UrhYU}2DMID$(V~arz z#!4RnrE?cT(QEmd=zSiXUMG&N_8_eUnjO1!UyiCsNk%w@^dsUgdj&ztrv;(xlTrfx z21YmT^m7;8Wh-P;L}eo-7KQZhJwyRVMdlOVk?Fit$jUD%Lr?{&h}2~;6*tFmo9M8P x4sW1mbmxDiZ7PN%TMg~)e*l?>Mf3mw literal 0 HcmV?d00001 diff --git a/app/__pycache__/book_utils.cpython-311.pyc b/app/__pycache__/book_utils.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2029bb3297eb448393df2b669a43861196b15d57 GIT binary patch literal 11229 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 literal 0 HcmV?d00001 diff --git a/app/__pycache__/opds_server.cpython-311.pyc b/app/__pycache__/opds_server.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e09fe80c033c17f62812934f808eca250598828e GIT binary patch literal 56345 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 literal 0 HcmV?d00001 diff --git a/app/__pycache__/series_utils.cpython-311.pyc b/app/__pycache__/series_utils.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ccb1c926cb79d657664a1b6b7efd4503ddfc2904 GIT binary patch literal 2881 zcmdT`O-vhC5Ps`_W4pm55H+djliCOzfeGy;2ihtPrZp^fWsE8)WwrJ$#?9hI`=c}n zDnqCO6qU59ltX&Ri74XGBn>M4qxYWHH?-;@Qq@Cmj^Mv2B~VkYQ5S`{0OwhxM%|ou z5si8{A4o6fA40L9`UW^?-)h_1lgdr)=gKP9p1^GlSIXK$?UA-!S*U!0EA#NtHbJ*l zS<`;ew*DsXi&1(*fmfKkD#@axqdY~l*7CyL4cG1;AnqWgZf_kl8*rIx404~N=3KK# zIzQ{WS7%qVKya2>#JOfbF+r_^{YLG)h(e|2W4+w#5N*SbNFlJaJrANI=a+SGn1@5+2OFZ9IvK_Q`z?3}>)e8Cy@y-a%e1N`pD@yBumOlNu%8PlAc-?2w02a{Rced&^|-KQ?7-ebyW zmqZY+^HRI4=43@GmJ01=UAsGQcg(1iz$*$bolxA1hFSjjLfZL|-^45lc?tduw~c?K zJ?w<`x!tLK$9B$K=^XvI&0%iW=cF_p9(IY$#O=KY9*N*r_w%3fc~#-{zyz=4Cs{$z zy?H^D`ItxdnLfHFFP2KW8=6@6NxZBIitGVjlK*~d*L6|4CQq|@zKfln?y>-&Dn&t# zPv6kbkoLEfxf_fbQECKtA6#-7L>c|nctMPd2knUa557)oxx0`n_q~TPGF^@eDt4ij7#l=WKZOn%9kd` zR6YleS*qNe8L{DDu?gW46Y)eEJYt*Fae=KnfUjW-O>Z@H%!HQ|fSfF7i6vel;|YKj zvH(GNN(zHciV!=WskXr-og&&MCxQ&07T9m@U6*TiW+9d}_m6+hXt5bEE|f zGATNM-<6^wz?{j&1-4XB*@E$G{hB3pD)$^}G>Md93x$Kh!^S66L10X%wRPY&Z1Yr( zjZcemQ7MWggGpIcnG~giF=4>am{&Mw#HnGL9>VmMp{!ka!%2dFcBF}dR@sYO zT?=`Q${gyT#`+E-lU_OpA>M@R#Qq6F|1_b{nWa~NbG>JQbG>JQb3wGANaGs4LNBLQ zQ{}`VMr|z9M*pN@ltkJl=F|NQ!-;u@(F0tLF-C(ie2g(5kzO_udVmZ+qwDtrJyRm5 z3jEtrE9?UzkvsyT>ZT~_DY~qo%RA^*&H1VN&QncQ= 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 = ''' + + + + + + Домашняя библиотека + + + +
+ +
+
+
+ +
+ {{ content|safe }} +
+ + +''' + + +# ============ ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ============ + +def get_stats_text(): + """Возвращает текст статистики""" + stats = get_stats() + return (f"{stats['books']:,} книг · " + f"{stats['authors']:,} авторов · " + f"{stats['series']:,} серий").replace(',', ' ') + + +def make_xml(feed): + """Форматирует XML для OPDS""" + xml_str = parseString(tostring(feed)).toprettyxml() + return Response(xml_str, mimetype='application/atom+xml') + + +# ============ ВЕБ-МАРШРУТЫ ============ + +@app.route('/') +def index(): + stats_text = get_stats_text() + + content = ''' + +
+

💡 Подсказка

+

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

+

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

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

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

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

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

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

Авторы: {}

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

Авторы: {}

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

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

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

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

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

👤 {}

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

📚 Серии

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

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

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

Нет книг

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

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

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

📚 {}

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

Нет книг

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

Жанры

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

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

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

' + title_display + '

' + + if authors: + content += '

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

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

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

' + + if genres: + content += '

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

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

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

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

' + annotation + '

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

📋 Оглавление

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

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

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

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

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

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

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

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

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