Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@

# Debug mode (verbose logging)
DEBUG=false

# Optional override for persistent app data directory (SQLite cache.db lives here)
# MYBOOKSHELFAI_DATA_DIR=/absolute/path/to/mybookshelfai-data
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,5 @@ node_modules/
data/
*.db
backups/
/frontend/src-tauri/binaries/*
!/frontend/src-tauri/binaries/.gitkeep
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ FRONTEND_PORT ?= 5173
PYTHON ?= python3
FRONTEND_DIR := $(CURDIR)/frontend

.PHONY: help dev website backend frontend stop
.PHONY: help dev website backend frontend stop desktop-dev desktop-build

# Install JS deps when missing or after package-lock.json changes.
$(FRONTEND_DIR)/node_modules/.bin/vite: $(FRONTEND_DIR)/package-lock.json
Expand All @@ -18,6 +18,8 @@ help:
@echo " make backend – API only"
@echo " make frontend – Vite only"
@echo " make stop – kill anything listening on those ports"
@echo " make desktop-dev – Linux desktop app dev mode (Tauri)"
@echo " make desktop-build – Linux desktop build (Tauri)"
@echo "Override Python: make PYTHON=.venv/bin/python dev"

# Run both servers in one shell so INT/TERM kills all background jobs and frees ports.
Expand All @@ -36,6 +38,12 @@ backend:
frontend: $(FRONTEND_DIR)/node_modules/.bin/vite
cd "$(FRONTEND_DIR)" && npm run dev

desktop-dev: $(FRONTEND_DIR)/node_modules/.bin/vite
cd "$(FRONTEND_DIR)" && npm run desktop:dev

desktop-build: $(FRONTEND_DIR)/node_modules/.bin/vite
cd "$(FRONTEND_DIR)" && npm run desktop:build

stop:
@for p in $(BACKEND_PORT) $(FRONTEND_PORT); do \
if command -v fuser >/dev/null 2>&1; then \
Expand Down
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@
[![CI](https://github.com/GoKitiky/MyBookshelfAI/actions/workflows/ci.yml/badge.svg)](https://github.com/GoKitiky/MyBookshelfAI/actions/workflows/ci.yml)

**AI-powered personal book recommender. Bring your own API key.**
**Stop guessing your next read. Use your own notes to get recommendations that actually fit your taste.**

Import your reading notes as Markdown files, get AI-powered enrichment (genres, themes, mood, complexity), a reader profile built from your library, and personalized book recommendations.
MyBookshelfAI turns scattered Markdown book notes into a personal reading brain: clean library, AI enrichment, reader profile, and ranked recommendations.

**Why this project:** keep notes and taste in one place, run everything locally (or in Docker), and plug in any OpenAI-compatible API—no vendor lock-in for how you host the app.
## The pain it solves

**Limits:** you need your own LLM API key for enrichment and recommendations; data stays on your machine (SQLite). This is not a hosted SaaS.
- **"I have notes everywhere, but no system."**
Import your `.md` files and get one searchable library.
- **"Most recommendation apps feel random."**
Suggestions are based on *your* reading profile, not generic trends. AI extracts genres, themes, mood, and complexity from each note.
- **"I do not want platform lock-in."**
Bring your own API key, keep data local in SQLite, run locally or in Docker.

## Download Linux Desktop (fast)

- **Latest Linux builds:** [Open Downloads Page](https://github.com/GoKitiky/MyBookshelfAI/releases/latest)
- **Formats planned for quick install:** AppImage and `.deb`

**Limits:** you need your own LLM API key for enrichment and recommendations. Data stays on your machine (SQLite). This is not a hosted SaaS.

## Screenshots

Expand Down Expand Up @@ -92,6 +105,37 @@ Then open <http://localhost:5173>:
2. **Import** your `.md` book notes.
3. **Enrich** → **Build profile** → **Get recommendations**.

### Linux desktop (Tauri)

This repo also supports a Linux desktop build where the backend is started
automatically as a Tauri sidecar (no separate `uvicorn` command).

Prerequisites:

- Rust toolchain (`rustup`, `cargo`)
- Linux Tauri system dependencies (WebKitGTK, GTK3, libayatana-appindicator)
- Ubuntu/Debian example: `sudo apt install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf`
- PyInstaller for the backend sidecar build (`pip install pyinstaller`)

Desktop dev/build commands:

```bash
cd frontend
npm ci
npm run desktop:dev
# or: npm run desktop:build
```

Equivalent Make targets from repo root:

```bash
make desktop-dev
# or: make desktop-build
```

Desktop runtime data is stored in the app data directory resolved by Tauri.
You can override it with `MYBOOKSHELFAI_DATA_DIR` when needed.

## Markdown File Format

Each book is one `.md` file. Title and author are extracted from the filename:
Expand Down
9 changes: 6 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncIterator

from fastapi import Depends, FastAPI, HTTPException
Expand All @@ -18,6 +17,7 @@
from app.services.cache import init_cache
from app.services.demo_seed import clear_demo_books
from app.services.library_db import init_books_table, init_reading_lists_table
from app.services.runtime_paths import ensure_data_dir
from app.services.settings_db import init_settings_table, seed_from_env

logging.basicConfig(
Expand All @@ -28,7 +28,7 @@

@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
Path("data").mkdir(exist_ok=True)
ensure_data_dir()
init_cache()
init_books_table()
init_reading_lists_table()
Expand All @@ -46,7 +46,10 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:

app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
allow_origin_regex=(
r"^(https?://(localhost|127\.0\.0\.1)(:\d+)?|"
r"(tauri|app)://localhost|https?://tauri\.localhost)$"
),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down
5 changes: 3 additions & 2 deletions app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
import sqlite3
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Any

DB_PATH = Path("data/cache.db")
from app.services.runtime_paths import get_cache_db_path

DB_PATH = get_cache_db_path()

# Default TTLs per namespace (hours)
_DEFAULT_TTL: dict[str, int] = {
Expand Down
4 changes: 2 additions & 2 deletions app/services/library_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
import sqlite3
from collections.abc import Sequence
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal

from app.models import Book
from app.services.runtime_paths import get_cache_db_path

ReadingListKind = Literal["planned", "blacklist"]

DB_PATH = Path("data/cache.db")
DB_PATH = get_cache_db_path()


class BookIdentityConflictError(Exception):
Expand Down
29 changes: 29 additions & 0 deletions app/services/runtime_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Runtime path helpers for local, container, and desktop environments."""

from __future__ import annotations

import os
from pathlib import Path

DATA_DIR_ENV = "MYBOOKSHELFAI_DATA_DIR"
DEFAULT_DATA_DIR = Path("data")


def get_data_dir() -> Path:
"""Return the directory used for persistent app data."""
raw_override = os.environ.get(DATA_DIR_ENV, "").strip()
if not raw_override:
return DEFAULT_DATA_DIR
return Path(raw_override).expanduser()


def ensure_data_dir() -> Path:
"""Create and return the persistent app data directory."""
data_dir = get_data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
return data_dir


def get_cache_db_path() -> Path:
"""Return the absolute/relative path to the SQLite cache database file."""
return get_data_dir() / "cache.db"
5 changes: 3 additions & 2 deletions app/services/settings_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

import sqlite3
from datetime import datetime, timezone
from pathlib import Path

DB_PATH = Path("data/cache.db")
from app.services.runtime_paths import get_cache_db_path

DB_PATH = get_cache_db_path()

SETTING_LLM_API_KEY = "llm_api_key"
SETTING_LLM_BASE_URL = "llm_base_url"
Expand Down
58 changes: 58 additions & 0 deletions desktop/backend_launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Desktop backend launcher entrypoint for the Tauri sidecar binary."""

from __future__ import annotations

import argparse
import os
import signal
import sys
from pathlib import Path

import uvicorn

ROOT_DIR = Path(__file__).resolve().parent.parent
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))

from app.main import app
from app.services.runtime_paths import DATA_DIR_ENV

DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8315
DEFAULT_LOG_LEVEL = "info"


def parse_args() -> argparse.Namespace:
"""Parse sidecar launch parameters."""
parser = argparse.ArgumentParser(description="MyBookshelfAI desktop backend")
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--log-level", default=DEFAULT_LOG_LEVEL)
parser.add_argument("--data-dir", type=Path, default=None)
return parser.parse_args()


def main() -> None:
args = parse_args()
if args.data_dir is not None:
os.environ[DATA_DIR_ENV] = str(args.data_dir.expanduser())

config = uvicorn.Config(
app,
host=args.host,
port=args.port,
log_level=args.log_level,
reload=False,
)
server = uvicorn.Server(config=config)

def _handle_shutdown(_signum: int, _frame: object | None) -> None:
server.should_exit = True

signal.signal(signal.SIGINT, _handle_shutdown)
signal.signal(signal.SIGTERM, _handle_shutdown)
server.run()


if __name__ == "__main__":
main()
Loading
Loading