Compare commits
178
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7c4c931ee | ||
|
|
bac84e24ec | ||
|
|
de8f71b2be | ||
|
|
1129fcae6f | ||
|
|
76c6826920 | ||
|
|
75b33fdae6 | ||
|
|
635a9439cb | ||
|
|
3b15766149 | ||
|
|
2a15effc3e | ||
|
|
78176c57a5 | ||
|
|
980aaee0d9 | ||
|
|
acfb298b74 | ||
|
|
ce3ab8bcbc | ||
|
|
6d44ae884c | ||
|
|
f6d98dd7cc | ||
|
|
e584200369 | ||
|
|
9bd23fc1d7 | ||
|
|
762181c123 | ||
|
|
d11a623f9f | ||
|
|
caaf27427e | ||
|
|
423a1c0bf7 | ||
|
|
b1d2ccc87c | ||
|
|
3170aa6b77 | ||
|
|
f75d68bf66 | ||
|
|
f37de6c07c | ||
|
|
cea8e64da2 | ||
|
|
307a43f6b0 | ||
|
|
d5018936a0 | ||
|
|
3a54dda5c5 | ||
|
|
3c2a504747 | ||
|
|
b0dc0c591e | ||
|
|
0f225c39b9 | ||
|
|
bc59159cde | ||
|
|
f57d64563b | ||
|
|
1f8c3c21ae | ||
|
|
5d22021e8e | ||
|
|
8e4412544d | ||
|
|
d764f820b2 | ||
|
|
39a2829cc8 | ||
|
|
795f10d2af | ||
|
|
44f38803dc | ||
|
|
b2b1804aa3 | ||
|
|
c82f20c3f2 | ||
|
|
5a76fed099 | ||
|
|
342c2f88b2 | ||
|
|
854a888306 | ||
|
|
a4b91393a6 | ||
|
|
0af2ee4951 | ||
|
|
d78a182f0d | ||
|
|
13260d8c1f | ||
|
|
6fcd7f4400 | ||
|
|
6e8986bea5 | ||
|
|
87da5c3cf5 | ||
|
|
fb9427a864 | ||
|
|
0af940c7b3 | ||
|
|
f8745da6b2 | ||
|
|
604a2458e9 | ||
|
|
666b72c4fd | ||
|
|
7221906d53 | ||
|
|
0a9cde0fc8 | ||
|
|
d3a510d2e0 | ||
|
|
a6f5d8d693 | ||
|
|
6a352a6afb | ||
|
|
e7a4f0f758 | ||
|
|
64d3e8d272 | ||
|
|
8e48aa4334 | ||
|
|
2fa3764df5 | ||
|
|
faef4d9fff | ||
|
|
a2fc2613b4 | ||
|
|
f23ee6e66a | ||
|
|
73fc609d7b | ||
|
|
5dca78789d | ||
|
|
37e092c2a8 | ||
|
|
4f7794767d | ||
|
|
d61abb1be1 | ||
|
|
1afc202ba9 | ||
|
|
5caebcfe45 | ||
|
|
b1e85dca79 | ||
|
|
b8dc4e87d5 | ||
|
|
0855dd7b3b | ||
|
|
d0460885ff | ||
|
|
ced90cd1f4 | ||
|
|
4b43bd04ba | ||
|
|
553a1dc19b | ||
|
|
f3cacd1b16 | ||
|
|
b829b7fe22 | ||
|
|
d42d09b8db | ||
|
|
71832bee24 | ||
|
|
504f145f64 | ||
|
|
46e0744802 | ||
|
|
a93be75408 | ||
|
|
dc07a2f19f | ||
|
|
fa38ca0d1b | ||
|
|
ae2e03d499 | ||
|
|
65c08fb523 | ||
|
|
0680e051c2 | ||
|
|
03156e7866 | ||
|
|
4f2e1e36bf | ||
|
|
36b2afeed7 | ||
|
|
b088ec97f6 | ||
|
|
a92739d210 | ||
|
|
f199918775 | ||
|
|
a3504f1ae5 | ||
|
|
355ae5f7a9 | ||
|
|
ddc14e3314 | ||
|
|
a57694b738 | ||
|
|
3bcae0abf5 | ||
|
|
cce7ad4907 | ||
|
|
c5cda015b3 | ||
|
|
b83e319a34 | ||
|
|
004b761381 | ||
|
|
9405afa2c5 | ||
|
|
d48404e800 | ||
|
|
ed68f92f4a | ||
|
|
607ce8ce3a | ||
|
|
ff0517d038 | ||
|
|
e14bbeabdd | ||
|
|
a9f8e23ddd | ||
|
|
540fb147d2 | ||
|
|
ffaa561cb7 | ||
|
|
4305c77df4 | ||
|
|
1da7765466 | ||
|
|
5da91b9c7c | ||
|
|
f87fc45377 | ||
|
|
5be6fec408 | ||
|
|
55a9ec00e1 | ||
|
|
c592c745c3 | ||
|
|
e5726e12be | ||
|
|
f589bedad5 | ||
|
|
27e9a654bf | ||
|
|
caf50ade31 | ||
|
|
dd1ddff08d | ||
|
|
1863fb3380 | ||
|
|
bc58103c62 | ||
|
|
37f84dd3ea | ||
|
|
2736409a79 | ||
|
|
94102af4d7 | ||
|
|
cb214b16b2 | ||
|
|
4fe36ba0a5 | ||
|
|
283b2f2ed7 | ||
|
|
f699899408 | ||
|
|
e87f481988 | ||
|
|
d68f72f21b | ||
|
|
d400377474 | ||
|
|
7cd88b6107 | ||
|
|
d38804e910 | ||
|
|
192c978a38 | ||
|
|
44ec2f496a | ||
|
|
d3ddecb840 | ||
|
|
da1732285f | ||
|
|
0269403a5d | ||
|
|
a4962a87b2 | ||
|
|
35c8ffe33e | ||
|
|
70d8ffd528 | ||
|
|
3a3fa8763e | ||
|
|
4119a5e38e | ||
|
|
86f44230c2 | ||
|
|
3c3c4e8bf5 | ||
|
|
133ca1fdaa | ||
|
|
c7f0eb406a | ||
|
|
2fdf894216 | ||
|
|
17f2dc3120 | ||
|
|
a06c85e72a | ||
|
|
e389df92c3 | ||
|
|
ad27902790 | ||
|
|
855ef161d8 | ||
|
|
a6700f73e0 | ||
|
|
8baecad379 | ||
|
|
9ccff320a1 | ||
|
|
d8d6334052 | ||
|
|
5b2d105609 | ||
|
|
18811cea1b | ||
|
|
3e73a582ba | ||
|
|
8aeae33167 | ||
|
|
3cecb04e8d | ||
|
|
6820208a36 | ||
|
|
77cbbf600b | ||
|
|
4e326dfc86 |
+4
-1
@@ -22,4 +22,7 @@ logs/
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env
|
||||
|
||||
# Media uploads (bind-mounted at runtime)
|
||||
uploads/
|
||||
+18
-1
@@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here
|
||||
# Generate with: openssl rand -hex 16
|
||||
DBPASS=your-secure-database-password-here
|
||||
|
||||
# Networking: change a port if it conflicts on your host
|
||||
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||
# DB_PORT=15432
|
||||
# App web port, host + container
|
||||
# SERVER_PORT=8765
|
||||
|
||||
# Deployment
|
||||
# External URL for device sync (must include protocol; defaults to http://localhost:8765)
|
||||
# Examples: https://bookhoard.example.com | http://192.168.1.10:8765
|
||||
# BASE_URL=https://bookhoard.example.com
|
||||
# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||
# COOKIE_SECURE=true
|
||||
# Pin or rollback a specific published image version (defaults to "latest")
|
||||
# IMAGE_TAG=1.0.0
|
||||
|
||||
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
||||
# Test Mode: WARNING - Only set to true for integration testing
|
||||
# TEST_MODE=true
|
||||
@@ -19,4 +34,6 @@ DBPASS=your-secure-database-password-here
|
||||
# Conversion Tool: Switch from kepubify to ebook-convert
|
||||
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
||||
# Conversion Cache TTL: Override default 24h
|
||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
||||
# System timezone (fallback for server-side time operations, defaults to UTC)
|
||||
# TZ=America/New_York
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Release
|
||||
|
||||
# Builds and publishes the Bookhoard container image to the Gitea container registry.
|
||||
# Triggered ONLY by a version tag push (pushing to main does nothing), so work-in-progress
|
||||
# commits never ship. Each release publishes two image tags: the version and "latest".
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.linuxhg.com
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
|
||||
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
||||
# pin or roll back by setting IMAGE_TAG in .env.
|
||||
tags: |
|
||||
git.linuxhg.com/bookhoard/bookhoard:${{ gitea.ref_name }}
|
||||
git.linuxhg.com/bookhoard/bookhoard:latest
|
||||
@@ -68,6 +68,9 @@ Thumbs.db
|
||||
# Uploads
|
||||
uploads/
|
||||
|
||||
# Vendored build artifacts (copied from node_modules at build time)
|
||||
web/static/vendor/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
+9
-6
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,14 +7,16 @@ WORKDIR /app
|
||||
RUN apk add --no-cache nodejs npm curl git
|
||||
|
||||
# Install Go tools (cached well)
|
||||
RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \
|
||||
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
|
||||
rm /tmp/sqlc.tar.gz
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
go install github.com/a-h/templ/cmd/templ@v0.3.1020
|
||||
|
||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
# Copy Go mod files (cached unless go.mod changes)
|
||||
COPY go.mod go.sum ./
|
||||
@@ -34,11 +36,12 @@ RUN npm run build:ts
|
||||
|
||||
# Build Go binary (cached unless Go files or generated code changes)
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
|
||||
|
||||
# Test runner stage - includes Go runtime and test dependencies
|
||||
# This stage is ONLY used for running tests, never deployed to production
|
||||
FROM golang:1.25-alpine AS test-runner
|
||||
FROM golang:1.26-alpine AS test-runner
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ ifneq (,$(wildcard ./.env))
|
||||
export
|
||||
endif
|
||||
|
||||
# Auto-detect container runtime: prefer docker, fall back to podman
|
||||
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||
|
||||
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@@ -44,23 +52,23 @@ test:
|
||||
# Run integration tests in containers (matches production environment)
|
||||
test-integration:
|
||||
@echo "Building test containers..."
|
||||
podman compose --profile tests build
|
||||
$(COMPOSE) --profile tests build
|
||||
@echo "Starting application containers..."
|
||||
podman compose up -d db app
|
||||
$(COMPOSE) up -d db app
|
||||
@echo "Waiting for services to be healthy..."
|
||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||
echo " Database not ready yet..."; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
echo " ✓ Database is ready"
|
||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
||||
echo " Application not ready yet..."; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
echo " ✓ Application is ready"
|
||||
@echo ""
|
||||
@echo "Running integration tests in container..."
|
||||
podman compose --profile tests run --rm tests
|
||||
$(COMPOSE) --profile tests run --rm tests
|
||||
@echo ""
|
||||
@echo "✅ Integration tests completed!"
|
||||
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
||||
@@ -71,76 +79,76 @@ test-all: test test-integration
|
||||
# Rebuild app container only (preserve DB, with cache)
|
||||
rebuild-app:
|
||||
@echo "Rebuilding app container (database stays running)..."
|
||||
podman compose up --build --force-recreate -d app
|
||||
$(COMPOSE) up --build --force-recreate -d app
|
||||
@echo "✓ App container rebuilt and restarted"
|
||||
|
||||
# Rebuild app container only (preserve DB, no cache)
|
||||
rebuild-app-force:
|
||||
@echo "Force rebuilding app container (database stays running, no cache)..."
|
||||
podman compose build --no-cache app
|
||||
podman compose up --force-recreate -d app
|
||||
$(COMPOSE) build --no-cache app
|
||||
$(COMPOSE) up --force-recreate -d app
|
||||
@echo "✓ App container rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (preserve DB, with cache)
|
||||
rebuild:
|
||||
@echo "Rebuilding all containers (database preserved)..."
|
||||
podman compose up --build --force-recreate -d
|
||||
$(COMPOSE) up --build --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (preserve DB, no cache)
|
||||
rebuild-force:
|
||||
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
||||
podman compose build --no-cache
|
||||
podman compose up --force-recreate -d
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (remove DB, no cache)
|
||||
rebuild-force-db:
|
||||
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
||||
podman compose down -v
|
||||
podman compose build --no-cache
|
||||
podman compose up --force-recreate -d
|
||||
$(COMPOSE) down -v
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Stop and remove containers
|
||||
clean:
|
||||
podman compose down -v
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Quick start (if already built)
|
||||
up:
|
||||
podman compose up -d
|
||||
$(COMPOSE) up -d
|
||||
|
||||
# Stop all containers (alias for clean)
|
||||
down:
|
||||
podman compose down
|
||||
$(COMPOSE) down
|
||||
|
||||
# Restart app container (preserves database)
|
||||
restart:
|
||||
@echo "Restarting app container (database stays running)..."
|
||||
podman compose restart app
|
||||
$(COMPOSE) restart app
|
||||
@echo "✓ App container restarted"
|
||||
|
||||
# Show container status
|
||||
ps:
|
||||
podman compose ps
|
||||
$(COMPOSE) ps
|
||||
|
||||
# Show container logs
|
||||
logs:
|
||||
podman compose logs -f
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
# Start containers with test mode enabled for manual testing
|
||||
test-env-up:
|
||||
@echo "Starting containers with test mode enabled..."
|
||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d
|
||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
|
||||
@echo "Waiting for services to be ready..."
|
||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||
@echo "✓ Test environment is ready!"
|
||||
@echo "Application available at http://localhost:8765"
|
||||
|
||||
# Stop test environment
|
||||
test-env-down:
|
||||
podman compose down -v
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Verify project guidelines compliance
|
||||
verify-guidelines:
|
||||
|
||||
@@ -1,777 +0,0 @@
|
||||
# Panel Detection Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Multi-tier panel detection system with fallback chain:
|
||||
**OpenCV → ML (COCO-SSD) → Grid → Manual Editor**
|
||||
|
||||
Designed for a constantly growing library - handles any comic style without custom training.
|
||||
|
||||
---
|
||||
|
||||
## Detection Pipeline
|
||||
|
||||
```
|
||||
1. OpenCV Edge Detection (Primary)
|
||||
├─ Fast, lightweight (~500KB lazy-loaded)
|
||||
├─ Works on 80% of comics with clear panel borders
|
||||
└─ Future-proof: works on unknown future comics
|
||||
|
||||
2. ML Detection (COCO-SSD Fallback)
|
||||
├─ Pre-trained on millions of diverse images
|
||||
├─ Handles irregular layouts
|
||||
└─ ~2MB (TensorFlow.js) + ~2MB (model), lazy-loaded
|
||||
|
||||
3. Grid Detection (Baseline)
|
||||
└─ Always works as final fallback
|
||||
|
||||
4. Manual Editor (Last Resort)
|
||||
└─ User manually draws panels
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
Add to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@techstark/opencv-js": "^4.12.0",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@tensorflow-models/coco-ssd": "^2.2.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bundle sizes:**
|
||||
- OpenCV.js: ~500KB (lazy-loaded)
|
||||
- TensorFlow.js: ~2MB (lazy-loaded)
|
||||
- COCO-SSD model: ~2MB (lazy-loaded, cached after first load)
|
||||
- **Total: ~4.5MB** (acceptable for modern networks)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
web/src/reader/comic/
|
||||
├── panel-detection.service.ts [NEW] - Main detection service with fallback chain
|
||||
├── panel-detection.opencv.ts [NEW] - OpenCV edge detection
|
||||
├── panel-detection.ml.ts [NEW] - COCO-SSD ML detection
|
||||
├── panel-detector.ts [MODIFY] - Add export for grid detection
|
||||
├── panel-editor.ts [MODIFY] - Add re-detect, connect to service
|
||||
├── page-cache.ts [OPTIONAL] - On-demand detection
|
||||
├── background-color.ts [KEEP]
|
||||
├── chapter-markers.ts [KEEP]
|
||||
├── page-order.ts [KEEP]
|
||||
├── page-scrubber.ts [KEEP]
|
||||
└── panel-gap.ts [KEEP]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Panel Detection Service (`panel-detection.service.ts`)
|
||||
|
||||
Create this file in `web/src/reader/comic/`:
|
||||
|
||||
```typescript
|
||||
// Main panel detection service with fallback chain
|
||||
// Priority: OpenCV → ML → Grid → Manual Editor
|
||||
|
||||
interface DetectionResult {
|
||||
panels: Panel[];
|
||||
method: "opencv" | "ml" | "grid" | "manual";
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
async function detectPanels(
|
||||
imageData: ImageData,
|
||||
allowManual: boolean = true
|
||||
): Promise<DetectionResult> {
|
||||
|
||||
// Tier 1: OpenCV Edge Detection
|
||||
try {
|
||||
const panels = await detectPanelsOpenCV(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "opencv", confidence: 0.85 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("OpenCV detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 2: ML Detection (COCO-SSD)
|
||||
try {
|
||||
const panels = await detectPanelsML(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "ml", confidence: 0.9 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("ML detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 3: Grid Detection (baseline)
|
||||
const panels = detectPanelsGrid(imageData);
|
||||
return { panels, method: "grid", confidence: 0.5 };
|
||||
}
|
||||
|
||||
function validatePanels(panels: Panel[], imageData: ImageData): boolean {
|
||||
// Must have at least 1 panel
|
||||
if (panels.length === 0) return false;
|
||||
|
||||
// Should not have too many panels (probably noise)
|
||||
if (panels.length > 30) return false;
|
||||
|
||||
// Panels should cover reasonable area (not all empty space)
|
||||
let totalArea = panels.reduce((sum, p) => sum + (p.width * p.height), 0);
|
||||
if (totalArea < 10 || totalArea > 100) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import detection methods from other files
|
||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]>;
|
||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]>;
|
||||
function detectPanelsGrid(imageData: ImageData, config?: { rows: number; cols: number }): Panel[];
|
||||
|
||||
export { detectPanels, DetectionResult, Panel };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. OpenCV Detection (`panel-detection.opencv.ts`)
|
||||
|
||||
Create this file in `web/src/reader/comic/`:
|
||||
|
||||
```typescript
|
||||
// OpenCV.js-based edge detection for panel boundaries
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let openCVLoaded = false;
|
||||
|
||||
async function loadOpenCV(): Promise<void> {
|
||||
if (openCVLoaded) return;
|
||||
|
||||
// OpenCV.js loads asynchronously and registers globally
|
||||
await import("@techstark/opencv-js");
|
||||
|
||||
// Wait for OpenCV to be ready
|
||||
return new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if ((window as any).cv && (window as any).cv.Mat) {
|
||||
openCVLoaded = true;
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 50);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadOpenCV();
|
||||
|
||||
const cv = (window as any).cv;
|
||||
|
||||
// Create matrices from ImageData
|
||||
const src = cv.matFromImageData(imageData);
|
||||
const gray = new cv.Mat();
|
||||
const blurred = new cv.Mat();
|
||||
const edges = new cv.Mat();
|
||||
const contours = new cv.Mat();
|
||||
const hierarchy = new cv.Mat();
|
||||
|
||||
try {
|
||||
// Convert to grayscale
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
|
||||
|
||||
// Apply Gaussian blur to reduce noise
|
||||
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT);
|
||||
|
||||
// Detect edges using Canny
|
||||
cv.Canny(blurred, edges, 50, 150, 3, false);
|
||||
|
||||
// Find contours
|
||||
cv.findContours(
|
||||
edges,
|
||||
contours,
|
||||
hierarchy,
|
||||
cv.RETR_EXTERNAL,
|
||||
cv.CHAIN_APPROX_SIMPLE
|
||||
);
|
||||
|
||||
// Convert contours to panels
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < contours.size(); i++) {
|
||||
const rect = cv.boundingRect(contours.get(i));
|
||||
const aspectRatio = rect.width / rect.height;
|
||||
|
||||
// Filter: reject very small or very thin contours
|
||||
const minSize = Math.min(imgWidth, imgHeight) * 0.05;
|
||||
if (rect.width < minSize || rect.height < minSize) continue;
|
||||
if (aspectRatio < 0.1 || aspectRatio > 10) continue;
|
||||
|
||||
panels.push({
|
||||
id: `opencv-panel-${i}`,
|
||||
x: (rect.x / imgWidth) * 100,
|
||||
y: (rect.y / imgHeight) * 100,
|
||||
width: (rect.width / imgWidth) * 100,
|
||||
height: (rect.height / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort panels by reading order (top-left to bottom-right)
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
// Reassign reading order after sorting
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
|
||||
} finally {
|
||||
// Clean up OpenCV matrices
|
||||
src.delete();
|
||||
gray.delete();
|
||||
blurred.delete();
|
||||
edges.delete();
|
||||
contours.delete();
|
||||
hierarchy.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export { detectPanelsOpenCV, loadOpenCV };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. ML Detection (`panel-detection.ml.ts`)
|
||||
|
||||
Create this file in `web/src/reader/comic/`:
|
||||
|
||||
```typescript
|
||||
// ML-based panel detection using COCO-SSD pre-trained model
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let model: any = null;
|
||||
let tfLoaded = false;
|
||||
|
||||
async function loadTF(): Promise<void> {
|
||||
if (tfLoaded) return;
|
||||
|
||||
// Load TensorFlow.js
|
||||
await import("@tensorflow/tfjs");
|
||||
tfLoaded = true;
|
||||
}
|
||||
|
||||
async function loadModel(): Promise<void> {
|
||||
if (model) return;
|
||||
|
||||
await loadTF();
|
||||
|
||||
// Load COCO-SSD model (pre-trained on millions of images)
|
||||
const cocoSsd = await import("@tensorflow-models/coco-ssd");
|
||||
model = await cocoSsd.load({
|
||||
base: "lite_mobilenet_v2", // Smaller, faster model
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadModel();
|
||||
|
||||
// Create HTMLCanvasElement to run model inference
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Run COCO-SSD model
|
||||
const predictions = await model.detect(canvas);
|
||||
|
||||
// Filter predictions to find rectangular regions (panels)
|
||||
// COCO-SSD detects common objects, we look for rectangular ones
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < predictions.length; i++) {
|
||||
const pred = predictions[i];
|
||||
|
||||
// COCO-SSD detects "book" and similar objects
|
||||
// We filter for reasonable panel-like detections
|
||||
const [x, y, w, h] = pred.bbox;
|
||||
const aspectRatio = w / h;
|
||||
|
||||
const isRectangular =
|
||||
aspectRatio > 0.3 && // Not too tall/thin
|
||||
aspectRatio < 5 && // Not too wide
|
||||
w > imgWidth * 0.05 && // Not too small
|
||||
h > imgHeight * 0.05;
|
||||
|
||||
if (isRectangular) {
|
||||
panels.push({
|
||||
id: `ml-panel-${i}`,
|
||||
x: (x / imgWidth) * 100,
|
||||
y: (y / imgHeight) * 100,
|
||||
width: (w / imgWidth) * 100,
|
||||
height: (h / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort panels by reading order
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export { detectPanelsML, loadModel };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Grid Detection (`panel-detector.ts` - Update)
|
||||
|
||||
Modify the existing `panel-detector.ts` to add the export at the end:
|
||||
|
||||
```typescript
|
||||
// Grid-based panel detection (fast, lightweight)
|
||||
// Keep as final fallback
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
interface GridConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
function detectPanelsGrid(
|
||||
imageData: ImageData,
|
||||
config: GridConfig = { rows: 3, cols: 3 },
|
||||
): Panel[] {
|
||||
const panels: Panel[] = [];
|
||||
const cellWidth = imageData.width / config.cols;
|
||||
const cellHeight = imageData.height / config.rows;
|
||||
|
||||
for (let y = 0; y < config.rows; y++) {
|
||||
for (let x = 0; x < config.cols; x++) {
|
||||
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
|
||||
|
||||
if (!isEmpty(cell)) {
|
||||
panels.push({
|
||||
id: `panel-${panels.length}`,
|
||||
x: (x / config.cols) * 100,
|
||||
y: (y / config.rows) * 100,
|
||||
width: (1 / config.cols) * 100,
|
||||
height: (1 / config.rows) * 100,
|
||||
reading_order: panels.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeAdjacentPanels(panels);
|
||||
}
|
||||
|
||||
function isEmpty(cellData: ImageData): boolean {
|
||||
let emptyPixels = 0;
|
||||
const totalPixels = cellData.width * cellData.height;
|
||||
const threshold = 0.95;
|
||||
|
||||
for (let i = 0; i < cellData.data.length; i += 4) {
|
||||
const r = cellData.data[i];
|
||||
const g = cellData.data[i + 1];
|
||||
const b = cellData.data[i + 2];
|
||||
const a = cellData.data[i + 3];
|
||||
|
||||
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
|
||||
emptyPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
return emptyPixels / totalPixels > threshold;
|
||||
}
|
||||
|
||||
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
|
||||
const merged: Panel[] = [];
|
||||
const used = new Set<number>();
|
||||
|
||||
for (let i = 0; i < panels.length; i++) {
|
||||
if (used.has(i)) continue;
|
||||
|
||||
let current = { ...panels[i] };
|
||||
used.add(i);
|
||||
|
||||
for (let j = i + 1; j < panels.length; j++) {
|
||||
if (used.has(j)) continue;
|
||||
if (isAdjacent(current, panels[j])) {
|
||||
current = mergePanels(current, panels[j]);
|
||||
used.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function extractCell(
|
||||
imageData: ImageData,
|
||||
gridX: number,
|
||||
gridY: number,
|
||||
cellWidth: number,
|
||||
cellHeight: number,
|
||||
): ImageData {
|
||||
const startX = Math.floor(gridX * cellWidth);
|
||||
const startY = Math.floor(gridY * cellHeight);
|
||||
const width = Math.floor(cellWidth);
|
||||
const height = Math.floor(cellHeight);
|
||||
|
||||
const cellData = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4;
|
||||
const destIdx = (y * width + x) * 4;
|
||||
cellData[destIdx] = imageData.data[srcIdx];
|
||||
cellData[destIdx + 1] = imageData.data[srcIdx + 1];
|
||||
cellData[destIdx + 2] = imageData.data[srcIdx + 2];
|
||||
cellData[destIdx + 3] = imageData.data[srcIdx + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(cellData, width, height);
|
||||
}
|
||||
|
||||
function isAdjacent(p1: Panel, p2: Panel): boolean {
|
||||
const tolerance = 5;
|
||||
if (Math.abs(p1.y - p2.y) < tolerance && Math.abs(p1.height - p2.height) < tolerance) {
|
||||
return Math.abs(p1.x + p1.width - p2.x) < tolerance || Math.abs(p2.x + p2.width - p1.x) < tolerance;
|
||||
}
|
||||
if (Math.abs(p1.x - p2.x) < tolerance && Math.abs(p1.width - p2.width) < tolerance) {
|
||||
return Math.abs(p1.y + p1.height - p2.y) < tolerance || Math.abs(p2.y + p2.height - p1.y) < tolerance;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mergePanels(p1: Panel, p2: Panel): Panel {
|
||||
const minX = Math.min(p1.x, p2.x);
|
||||
const minY = Math.min(p1.y, p2.y);
|
||||
const maxX = Math.max(p1.x + p1.width, p2.x + p2.width);
|
||||
const maxY = Math.max(p1.y + p1.height, p2.y + p2.height);
|
||||
|
||||
return {
|
||||
id: p1.id,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
reading_order: Math.min(p1.reading_order, p2.reading_order),
|
||||
};
|
||||
}
|
||||
|
||||
// ADD THIS EXPORT AT THE END OF THE FILE
|
||||
export { detectPanelsGrid, isEmpty, mergeAdjacentPanels, extractCell, isAdjacent, mergePanels };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Panel Editor Updates (`panel-editor.ts`)
|
||||
|
||||
Modify the existing `panel-editor.ts` to add imports and re-detect function:
|
||||
|
||||
```typescript
|
||||
// Manual panel editor for admins/power users
|
||||
|
||||
import { Alpine } from "../../alpine";
|
||||
import { apiPut } from "../../api";
|
||||
import { detectPanels, Panel } from "./panel-detection.service";
|
||||
|
||||
async function loadImageForPage(pageNumber: number): Promise<HTMLImageElement> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
if (!mediaItemId) {
|
||||
throw new Error("No mediaItemId found");
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => resolve();
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
function getCurrentPageNumber(): number {
|
||||
const Alpine = (window as any).Alpine;
|
||||
if (Alpine) {
|
||||
const readerEl = document.querySelector('[x-data="readerShell"]');
|
||||
if (readerEl) {
|
||||
const readerShell = Alpine.$data(readerEl);
|
||||
if (readerShell?.currentPage) {
|
||||
return readerShell.currentPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = document.getElementById("reader-content");
|
||||
const pageFromDataset = content?.dataset.currentPage;
|
||||
if (pageFromDataset) {
|
||||
return parseInt(pageFromDataset, 10);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
function loadPage(pageNumber: number): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }),
|
||||
);
|
||||
}
|
||||
|
||||
function openPanelEditor(pageNumber: number): void {
|
||||
const modal = document.getElementById("panel-editor-modal");
|
||||
modal?.classList.remove("hidden");
|
||||
|
||||
const canvas = document.getElementById("panel-editor-canvas") as HTMLCanvasElement;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
|
||||
loadImageForPage(pageNumber).then((image) => {
|
||||
canvas!.width = image.width;
|
||||
canvas!.height = image.height;
|
||||
ctx?.drawImage(image, 0, 0);
|
||||
|
||||
enablePanelDrawing(canvas!);
|
||||
});
|
||||
}
|
||||
|
||||
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
|
||||
let isDrawing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
isDrawing = true;
|
||||
startX = e.offsetX;
|
||||
startY = e.offsetY;
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
// Clear and redraw to show selection rectangle
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx?.drawImage(canvas, 0, 0);
|
||||
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
|
||||
});
|
||||
|
||||
canvas.addEventListener("mouseup", (e) => {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
const panel: Panel = {
|
||||
id: `manual-${Date.now()}`,
|
||||
x: (startX / canvas.width) * 100,
|
||||
y: (startY / canvas.height) * 100,
|
||||
width: ((e.offsetX - startX) / canvas.width) * 100,
|
||||
height: ((e.offsetY - startY) / canvas.height) * 100,
|
||||
reading_order: 0,
|
||||
};
|
||||
|
||||
saveManualPanel(panel);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveManualPanel(panel: Panel): Promise<void> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
const pageNumber = getCurrentPageNumber();
|
||||
|
||||
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
|
||||
detection_method: "manual",
|
||||
panels: [panel],
|
||||
});
|
||||
|
||||
loadPage(pageNumber);
|
||||
}
|
||||
|
||||
// Re-detect panels using detection service
|
||||
async function reDetectPanels(pageNumber: number): Promise<Panel[]> {
|
||||
const image = await loadImageForPage(pageNumber);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
// Alpine component
|
||||
Alpine.data("panelEditor", () => ({
|
||||
get isComicOrManga(): boolean {
|
||||
const libraryType = document.body.dataset.mediaType;
|
||||
return libraryType === "comic" || libraryType === "manga";
|
||||
},
|
||||
|
||||
openPanelEditor(pageNumber: number) {
|
||||
openPanelEditor(pageNumber);
|
||||
},
|
||||
|
||||
async reDetectPanels(pageNumber: number) {
|
||||
const panels = await reDetectPanels(pageNumber);
|
||||
return panels;
|
||||
}
|
||||
}));
|
||||
|
||||
export { openPanelEditor, reDetectPanels };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Page Cache Integration (`page-cache.ts` - Optional)
|
||||
|
||||
Optional: Add on-demand panel detection to page-cache.ts:
|
||||
|
||||
```typescript
|
||||
// Add this import at the top
|
||||
import { detectPanels } from "./panel-detection.service";
|
||||
|
||||
// Add to PageCacheState interface
|
||||
interface PageCacheState {
|
||||
cache: Map<number, HTMLImageElement>;
|
||||
loading: Set<number>;
|
||||
maxAhead: number;
|
||||
mediaItemId: string;
|
||||
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
|
||||
}
|
||||
|
||||
// Add this function
|
||||
async function detectPagePanels(
|
||||
state: PageCacheState,
|
||||
pageNumber: number
|
||||
): Promise<any[]> {
|
||||
// Check if already detected
|
||||
if (state.panelData?.has(pageNumber)) {
|
||||
return state.panelData.get(pageNumber)!.panels;
|
||||
}
|
||||
|
||||
// Get or create image
|
||||
let image: HTMLImageElement;
|
||||
if (state.cache.has(pageNumber)) {
|
||||
image = state.cache.get(pageNumber)!;
|
||||
} else {
|
||||
image = await loadComicPage(state, pageNumber);
|
||||
}
|
||||
|
||||
// Run detection on demand
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
if (!state.panelData) {
|
||||
state.panelData = new Map();
|
||||
}
|
||||
state.panelData.set(pageNumber, result);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
// Export the new function
|
||||
export { createPageCache, getCachedPage, loadComicPage, detectPagePanels };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Add dependencies to `package.json`** and run `npm install`
|
||||
2. **Create `panel-detection.service.ts`**
|
||||
3. **Create `panel-detection.opencv.ts`**
|
||||
4. **Create `panel-detection.ml.ts`**
|
||||
5. **Update `panel-detector.ts`** - add export statement (one line at the end)
|
||||
6. **Update `panel-editor.ts`** - add imports and re-detect function
|
||||
7. **(Optional) Update `page-cache.ts`** - add on-demand detection
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **User feedback loop:** Store user corrections to improve detection
|
||||
2. **Per-comic detection:** Different methods for different comic styles
|
||||
3. **Batch detection:** Pre-detect pages in background
|
||||
4. **Detection history:** Track which method works best per comic
|
||||
5. **Panel preview:** Show detected panels before entering panel view
|
||||
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/yourusername/bookhoard.git
|
||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
||||
cd bookhoard
|
||||
|
||||
# 2. Set up environment
|
||||
@@ -35,8 +35,10 @@ cp .env.example .env
|
||||
# DBPASS: openssl rand -hex 16
|
||||
# Edit .env with your generated values
|
||||
|
||||
# 3. Start the server
|
||||
podman-compose up --build -d # or: docker-compose up --build -d
|
||||
# 3. Pull images and start the server
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
||||
|
||||
# 4. Open your browser
|
||||
open http://localhost:8765
|
||||
|
||||
@@ -2,20 +2,20 @@ name: Bookhoard
|
||||
variables:
|
||||
- name: base_url
|
||||
value: http://localhost:8765
|
||||
- name: media_item_id
|
||||
value: 8bd13107-e1e5-4357-b893-bfc77f1e087c
|
||||
- name: fake_book_id
|
||||
value: 123e4567-e89b-12d3-a456-426614174000
|
||||
- name: user_id
|
||||
value: c51118f0-31fc-4c32-827d-517d6599bf21
|
||||
- name: highlight_id
|
||||
value: 660f9501-f29b-51d4-b716-446655440001
|
||||
- name: note_id
|
||||
value: 7710a602-g29b-61d4-c716-446655440002
|
||||
- name: ebook_library_id
|
||||
value: 0df0ea2b-1965-494a-a0a3-cce8536d5f28
|
||||
- name: job_id
|
||||
value: 709e0d8e-b866-496c-b260-a59ab8e2014c
|
||||
- secret: true
|
||||
name: media_item_id
|
||||
- secret: true
|
||||
name: fake_book_id
|
||||
- secret: true
|
||||
name: user_id
|
||||
- secret: true
|
||||
name: highlight_id
|
||||
- secret: true
|
||||
name: note_id
|
||||
- secret: true
|
||||
name: ebook_library_id
|
||||
- secret: true
|
||||
name: job_id
|
||||
- name: rating
|
||||
value: "5"
|
||||
- name: is_visible
|
||||
@@ -32,11 +32,11 @@ variables:
|
||||
name: kobo_device_token
|
||||
- secret: true
|
||||
name: other_device_id
|
||||
- name: collection_id
|
||||
value: 412c03c3-0843-4bd4-b764-cc9457bb9df2
|
||||
- name: library_folder_id
|
||||
value: da1f9d91-0c4c-40cc-a050-86f795dfc967
|
||||
- name: comic_library_id
|
||||
value: 7b6d0c8c-73dc-4346-804c-5f6e11c3d658
|
||||
- name: manga_library_id
|
||||
value: b134d16d-9668-4867-b50e-8350037f1a4a
|
||||
- secret: true
|
||||
name: collection_id
|
||||
- secret: true
|
||||
name: library_folder_id
|
||||
- secret: true
|
||||
name: comic_library_id
|
||||
- secret: true
|
||||
name: manga_library_id
|
||||
|
||||
+20
-3
@@ -62,32 +62,45 @@ func main() {
|
||||
// Create WebSocket connection manager
|
||||
connManager := sync.NewConnectionManager()
|
||||
|
||||
// Create sync queue processor
|
||||
progressService := sync.NewProgressService(queries, connManager)
|
||||
annotationService := sync.NewAnnotationService(queries, connManager)
|
||||
tombstonePurgerCancel := annotationService.StartTombstonePurger()
|
||||
defer tombstonePurgerCancel()
|
||||
|
||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
queueProcessor.SetAnnotationService(annotationService)
|
||||
|
||||
// Create library service
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
|
||||
// Sync Go AllowedExtensions into DB so API clients see correct extensions
|
||||
libraryService.SyncAllowedExtensions(context.Background())
|
||||
|
||||
// Create worker for background tasks
|
||||
worker := services.NewWorker(3, connManager)
|
||||
services.WorkerInstance = worker
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
koreaderHandler.SetProgressService(progressService)
|
||||
koreaderHandler.SetAnnotationService(annotationService)
|
||||
koreaderHandler.SetLibraryService(libraryService)
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||
|
||||
// Create conversion service for EPUB→KEPUB conversion
|
||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||
|
||||
// NEW: Create refactored handlers
|
||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||
dashboardService := services.NewDashboardService(queries)
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
mediaHandler.SetAnnotationService(annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||
|
||||
@@ -147,15 +160,19 @@ func main() {
|
||||
FiltersHandler: filtersHandler,
|
||||
DashboardHandler: dashboardHandler,
|
||||
DashboardService: dashboardService,
|
||||
SeriesHandler: seriesHandler,
|
||||
OPDSHandler: opdsHandler,
|
||||
Worker: worker,
|
||||
SystemSettingsHandler: systemSettingsHandler,
|
||||
SidecarHandler: sidecarHandler,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
JobsHandler: jobsHandler,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
LibraryService: libraryService,
|
||||
}
|
||||
|
||||
// Register all routes and get ebook handler
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -21,7 +22,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -32,12 +35,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ReadingStatsResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, result.TotalBooksRead, 0)
|
||||
assert.GreaterOrEqual(t, result.TotalPagesRead, 0)
|
||||
@@ -45,15 +51,17 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) {
|
||||
startDate := time.Now().AddDate(0, -2, 0).Format("2006-01-02")
|
||||
endDate := time.Now().Format("2006-01-02")
|
||||
startDate := time.Now().AddDate(0, -2, 0).Format("01-02-2006")
|
||||
endDate := time.Now().Format("01-02-2006")
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
@@ -64,7 +72,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -75,7 +85,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -86,12 +98,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should return zero values for empty history
|
||||
assert.Equal(t, 0.0, result["total_books_read"])
|
||||
@@ -108,7 +123,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -119,19 +136,19 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.DeviceUsageResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, result.Devices)
|
||||
assert.Equal(t, 0, len(result.Devices))
|
||||
})
|
||||
|
||||
t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) {
|
||||
// First create a device
|
||||
// First, create a device
|
||||
deviceReq := map[string]interface{}{
|
||||
"device_name": "Test Kobo",
|
||||
"device_type": "kobo",
|
||||
@@ -144,7 +161,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(deviceReqHTTP)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Now get device usage
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
||||
@@ -152,12 +171,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
devices, ok := result["devices"].([]interface{})
|
||||
assert.True(t, ok)
|
||||
@@ -171,12 +193,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "devices")
|
||||
|
||||
@@ -203,7 +228,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -214,12 +241,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.PopularBooksResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, result.Books)
|
||||
// Default limit is 10, but may be fewer if no reading history
|
||||
@@ -232,12 +262,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
assert.True(t, len(books) <= 5)
|
||||
@@ -249,20 +282,23 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should default to 10 on invalid limit
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
assert.True(t, len(books) <= 10)
|
||||
})
|
||||
|
||||
t.Run("GetPopularBooks_ResponseStructure", func(t *testing.T) {
|
||||
// First create a book and some reading history
|
||||
// First, create a book and some reading history
|
||||
bookID := createTestMediaItemID(t, setup)
|
||||
|
||||
// Create reading history for the book
|
||||
@@ -280,7 +316,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(historyHTTP)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Now get popular books
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||
@@ -288,12 +326,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
|
||||
@@ -315,12 +356,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
// Should return empty array if no reading history
|
||||
@@ -334,21 +378,24 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("ReadingStats_FutureDateRange", func(t *testing.T) {
|
||||
startDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
||||
endDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
|
||||
startDate := time.Now().AddDate(0, 0, 7).Format("01-02-2006")
|
||||
endDate := time.Now().AddDate(0, 0, 14).Format("01-02-2006")
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should succeed but return empty stats
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0.0, result["total_books_read"])
|
||||
})
|
||||
@@ -359,13 +406,16 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle limit=0 gracefully
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
assert.Equal(t, 0, len(books))
|
||||
@@ -377,7 +427,9 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle large limit
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -27,7 +28,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -47,12 +50,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "matches")
|
||||
assert.Contains(t, result, "action")
|
||||
@@ -67,7 +73,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -85,12 +93,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
var matches []interface{}
|
||||
if matchesIf, ok := result["matches"]; ok && matchesIf != nil {
|
||||
@@ -124,7 +135,9 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -142,12 +155,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0.0, result["total"])
|
||||
assert.Equal(t, 0.0, result["successful"])
|
||||
@@ -175,12 +191,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -221,12 +240,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 3.0, result["total"])
|
||||
results := result["results"].([]interface{})
|
||||
@@ -251,7 +273,9 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -267,12 +291,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "auto_linked")
|
||||
assert.Contains(t, result, "results")
|
||||
@@ -292,12 +319,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "auto_linked")
|
||||
})
|
||||
@@ -315,12 +345,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should succeed even with no books to link
|
||||
assert.Contains(t, result, "auto_linked")
|
||||
@@ -338,7 +371,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -350,7 +385,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -363,7 +400,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
@@ -378,7 +417,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Even when book not found, we expect 404
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
@@ -396,7 +437,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -409,12 +452,15 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "device_id")
|
||||
assert.Contains(t, result, "aliases")
|
||||
@@ -439,7 +485,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -462,7 +510,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -485,7 +535,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -505,7 +557,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -519,7 +573,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -535,7 +591,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -547,12 +605,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "matches")
|
||||
assert.Contains(t, result, "action")
|
||||
@@ -565,7 +626,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -577,12 +640,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "matches")
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -44,7 +45,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -61,7 +64,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -85,12 +90,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -122,10 +130,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Now try to add invalid book IDs
|
||||
@@ -145,12 +156,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
results := result["results"].([]interface{})
|
||||
firstResult := results[0].(map[string]interface{})
|
||||
@@ -171,10 +185,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Create a book
|
||||
@@ -197,12 +214,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -228,10 +248,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Create multiple books
|
||||
@@ -256,12 +279,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 3.0, result["total"])
|
||||
assert.True(t, result["added"].(float64) > 0)
|
||||
@@ -281,10 +307,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult1 map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult1)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult1)
|
||||
require.NoError(t, err)
|
||||
collectionID1 := collectionResult1["id"].(string)
|
||||
|
||||
collectionReq2 := map[string]interface{}{
|
||||
@@ -299,10 +328,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(collectionHTTP2)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult2 map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult2)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult2)
|
||||
require.NoError(t, err)
|
||||
collectionID2 := collectionResult2["id"].(string)
|
||||
|
||||
// Create books
|
||||
@@ -330,12 +362,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, 3.0, result["total"])
|
||||
@@ -355,10 +390,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Create a book
|
||||
@@ -381,7 +419,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Try to add same book again - create new request with fresh body
|
||||
addBody2, _ := json.Marshal(addReq)
|
||||
@@ -391,7 +431,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp2, err := client.Do(addHTTP2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
// Should handle duplicate gracefully (either succeed or return error)
|
||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
@@ -405,7 +447,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -112,7 +112,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 0, len(items), "Empty rules should return no matched items")
|
||||
@@ -139,7 +140,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 2, len(items), "Should return exactly 2 manually selected books")
|
||||
@@ -171,7 +173,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Greater(t, len(items), 0, "Should return books matching the genre rule")
|
||||
@@ -203,7 +206,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Greater(t, len(items), 0, "Should return books from rules and manual selection")
|
||||
@@ -233,7 +237,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.LessOrEqual(t, len(items), 2, "Should respect limit parameter")
|
||||
@@ -259,7 +264,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 1, len(items), "Should still return items when limit exceeds max")
|
||||
@@ -285,7 +291,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 1, len(items), "Limit 0 should default to 20 and still return matched items")
|
||||
@@ -312,7 +319,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 1, len(items), "Invalid book IDs should be skipped, valid ones included")
|
||||
@@ -340,7 +348,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 1, len(items), "Duplicate book IDs should result in unique items")
|
||||
|
||||
@@ -132,7 +132,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -152,7 +154,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -169,7 +173,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -190,7 +196,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -210,7 +218,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -227,7 +237,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -244,7 +256,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -262,7 +276,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -280,7 +296,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -298,7 +316,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -316,7 +336,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -334,7 +356,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -353,7 +377,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -371,7 +397,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -389,7 +417,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -439,7 +469,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -469,7 +501,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -500,7 +534,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -526,7 +562,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -567,7 +605,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -594,7 +634,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/media/"+mediaUUID, nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should require authentication")
|
||||
})
|
||||
@@ -610,7 +652,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -629,7 +673,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -719,7 +765,9 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
|
||||
+550
-257
@@ -1,289 +1,582 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConflictDetection_TriggeringConditions(t *testing.T) {
|
||||
t.Run("conflict detected when different devices sync within 5 minutes", func(t *testing.T) {
|
||||
conflictData := map[string]map[string]interface{}{
|
||||
"koreader": {
|
||||
"source": "koreader",
|
||||
"timestamp": "2026-01-30T20:10:00Z",
|
||||
"data": map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"chapter": 3,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
"source": "kobo",
|
||||
"timestamp": "2026-01-30T20:05:00Z",
|
||||
"data": map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
"page": 89,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(conflictData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
assert.Equal(t, "POST", req.Method)
|
||||
assert.Contains(t, string(body), "koreader")
|
||||
assert.Contains(t, string(body), "kobo")
|
||||
})
|
||||
|
||||
t.Run("no conflict when progress difference is less than 1%", func(t *testing.T) {
|
||||
progressData := map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
}
|
||||
|
||||
existingProgress := map[string]interface{}{
|
||||
"percentage": 0.451,
|
||||
}
|
||||
|
||||
diff := progressData["percentage"].(float64) - existingProgress["percentage"].(float64)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
assert.Less(t, diff, 0.01, "Should not trigger conflict for small differences")
|
||||
})
|
||||
|
||||
t.Run("no conflict when sync timestamps are more than 5 minutes apart", func(t *testing.T) {
|
||||
timestamp1 := "2026-01-30T20:00:00Z"
|
||||
timestamp2 := "2026-01-30T20:10:00Z"
|
||||
|
||||
var conflictDetected bool
|
||||
if timestamp2 > timestamp1 {
|
||||
conflictDetected = false
|
||||
}
|
||||
|
||||
assert.False(t, conflictDetected, "Should not trigger conflict for old syncs")
|
||||
})
|
||||
type conflictTestEnv struct {
|
||||
setup *TestServerSetup
|
||||
mediaID string
|
||||
userID pgtype.UUID
|
||||
mediaPGID pgtype.UUID
|
||||
}
|
||||
|
||||
func TestConflictResolution_ChoosingWinner(t *testing.T) {
|
||||
t.Run("resolve conflict by choosing koreader source", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
func setupConflictTest(t *testing.T) *conflictTestEnv {
|
||||
t.Helper()
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
"manual_data": nil,
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "More recent progress",
|
||||
}
|
||||
setup := setupTestServer(t)
|
||||
mediaID := createTestMediaItemID(t, setup)
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
assert.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
user, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "POST", req.Method)
|
||||
assert.Contains(t, req.URL.Path, conflictID.String())
|
||||
assert.Contains(t, string(body), "koreader")
|
||||
return &conflictTestEnv{
|
||||
setup: setup,
|
||||
mediaID: mediaID,
|
||||
userID: user.ID,
|
||||
mediaPGID: pgtype.UUID{Bytes: [16]byte(mediaUUID), Valid: true},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestConflict(t *testing.T, env *conflictTestEnv, conflictData map[string]interface{}) database.SyncConflicts {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
dataJSON, err := json.Marshal(conflictData)
|
||||
require.NoError(t, err)
|
||||
|
||||
conflict, err := env.setup.DB.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
|
||||
MediaItemID: env.mediaPGID,
|
||||
UserID: env.userID,
|
||||
ConflictType: "progress",
|
||||
ConflictData: dataJSON,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("resolve conflict with manual merge data", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
return conflict
|
||||
}
|
||||
|
||||
manualData := map[string]interface{}{
|
||||
func makeConflictData(koreaderPct, koboPct float64) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"koreader": map[string]interface{}{
|
||||
"source": "koreader",
|
||||
"timestamp": time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||
"data": map[string]interface{}{
|
||||
"percentage": koreaderPct,
|
||||
},
|
||||
},
|
||||
"kobo": map[string]interface{}{
|
||||
"source": "kobo",
|
||||
"timestamp": time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||
"data": map[string]interface{}{
|
||||
"percentage": koboPct,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictList_Empty(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ConflictListResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0, result.Total)
|
||||
assert.Empty(t, result.Conflicts)
|
||||
}
|
||||
|
||||
func TestConflictList_WithConflicts(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
|
||||
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts?status=all", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ConflictListResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, result.Total, 1)
|
||||
require.NotEmpty(t, result.Conflicts)
|
||||
|
||||
conflict := result.Conflicts[0]
|
||||
assert.Equal(t, "progress", conflict.ConflictType)
|
||||
assert.Equal(t, "unresolved", conflict.ResolutionStatus)
|
||||
assert.Contains(t, conflict.ConflictData, "koreader")
|
||||
assert.Contains(t, conflict.ConflictData, "kobo")
|
||||
}
|
||||
|
||||
func TestConflictList_UnresolvedCount(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
|
||||
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var result handlers.ConflictListResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, result.Unresolved, 1)
|
||||
}
|
||||
|
||||
func TestConflictGet_ByID(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var detail handlers.ConflictDetailResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&detail)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, conflictID, detail.ID)
|
||||
assert.Equal(t, env.mediaID, detail.MediaItemID)
|
||||
assert.Equal(t, "progress", detail.ConflictType)
|
||||
assert.Contains(t, detail.ConflictData, "koreader")
|
||||
assert.Contains(t, detail.ConflictData, "kobo")
|
||||
}
|
||||
|
||||
func TestConflictGet_NotFound(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictGet_InvalidID(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/not-a-uuid", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictResolve_ByKOReader(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.75, 0.30))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
"manual_data": nil,
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "More recent progress",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ConflictResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.ConflictResolved)
|
||||
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
|
||||
}
|
||||
|
||||
func TestConflictResolve_ByKobo(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.30, 0.75))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "kobo",
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "Higher progress",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ConflictResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.ConflictResolved)
|
||||
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
|
||||
}
|
||||
|
||||
func TestConflictResolve_WithManualData(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "manual",
|
||||
"manual_data": map[string]interface{}{
|
||||
"percentage": 0.43,
|
||||
"epubcfi": "epubcfi(/6/4/2:20)",
|
||||
"chapter": 3,
|
||||
"page": 90,
|
||||
}
|
||||
},
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "Custom merged position",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"winner": "manual",
|
||||
"manual_data": manualData,
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "Custom merged position",
|
||||
}
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ConflictResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.ConflictResolved)
|
||||
}
|
||||
|
||||
func TestConflictResolve_ManualWithoutData(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "manual",
|
||||
"manual_data": nil,
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictResolve_AlreadyResolved(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
"reason": "First resolution",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp2.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictResolve_InvalidWinner(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "nonexistent_source",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictResolve_NotFound(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictDelete(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req, _ := http.NewRequest("DELETE", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
req2, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictDelete_NotFound(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictDismissAllResolved(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/dismiss-all", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp2.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
deleted, ok := result["deleted"].(float64)
|
||||
assert.True(t, ok)
|
||||
assert.GreaterOrEqual(t, int(deleted), 1)
|
||||
}
|
||||
|
||||
func TestConflictEndpoints_RequireAuth(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("list conflicts requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("get conflict requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("resolve conflict requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer([]byte(`{}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
assert.Contains(t, string(body), "manual")
|
||||
assert.Contains(t, string(body), "0.43")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("error when winner is manual but no manual_data provided", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
t.Run("delete conflict requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"winner": "manual",
|
||||
"manual_data": nil,
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "Test",
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
assert.Contains(t, string(body), "manual")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConflictListing_Filtering(t *testing.T) {
|
||||
t.Run("list only unresolved conflicts", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/conflicts?status=unresolved", nil)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Contains(t, req.URL.Query().Get("status"), "unresolved")
|
||||
})
|
||||
|
||||
t.Run("list all conflicts regardless of status", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/conflicts?status=all", nil)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Contains(t, req.URL.Query().Get("status"), "all")
|
||||
})
|
||||
|
||||
t.Run("list only resolved conflicts", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/conflicts?status=user_resolved", nil)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Contains(t, req.URL.Query().Get("status"), "user_resolved")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConflictResponse_Structure(t *testing.T) {
|
||||
t.Run("conflict detail response includes all required fields", func(t *testing.T) {
|
||||
conflictResponse := map[string]interface{}{
|
||||
"id": "conflict-uuid-123",
|
||||
"media_item_id": "book-uuid-456",
|
||||
"media_item_title": "Test Book Title",
|
||||
"conflict_type": "progress",
|
||||
"resolution_status": "unresolved",
|
||||
"created_at": "2026-01-30T20:10:00Z",
|
||||
"conflict_data": map[string]interface{}{
|
||||
"koreader": map[string]interface{}{
|
||||
"source": "koreader",
|
||||
"data": map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
},
|
||||
},
|
||||
"kobo": map[string]interface{}{
|
||||
"source": "kobo",
|
||||
"data": map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(conflictResponse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
err = json.Unmarshal(body, &parsed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Contains(t, parsed, "id")
|
||||
assert.Contains(t, parsed, "media_item_id")
|
||||
assert.Contains(t, parsed, "conflict_data")
|
||||
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "koreader")
|
||||
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "kobo")
|
||||
})
|
||||
|
||||
t.Run("conflict list response includes summary counts", func(t *testing.T) {
|
||||
listResponse := map[string]interface{}{
|
||||
"conflicts": []interface{}{
|
||||
map[string]string{"id": "conflict-1", "resolution_status": "unresolved"},
|
||||
map[string]string{"id": "conflict-2", "resolution_status": "unresolved"},
|
||||
},
|
||||
"total": 2,
|
||||
"unresolved": 2,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(listResponse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
err = json.Unmarshal(body, &parsed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(2), parsed["total"])
|
||||
assert.Equal(t, float64(2), parsed["unresolved"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestConflictDeletion(t *testing.T) {
|
||||
t.Run("delete single conflict by ID", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/conflicts/"+conflictID.String(), nil)
|
||||
assert.Equal(t, "DELETE", req.Method)
|
||||
assert.Contains(t, req.URL.Path, conflictID.String())
|
||||
})
|
||||
|
||||
t.Run("dismiss all resolved conflicts", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/conflicts/dismiss-all", nil)
|
||||
assert.Equal(t, "POST", req.Method)
|
||||
assert.Contains(t, req.URL.Path, "dismiss-all")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConflictNotification_WebSocketBroadcast(t *testing.T) {
|
||||
t.Run("conflict detection notification", func(t *testing.T) {
|
||||
notification := map[string]interface{}{
|
||||
"type": "conflict",
|
||||
"timestamp": "2026-01-30T20:10:00Z",
|
||||
"data": map[string]interface{}{
|
||||
"book_id": "book-uuid-123",
|
||||
"notification_type": "detection",
|
||||
"conflict_id": "",
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(notification)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
err = json.Unmarshal(body, &parsed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
data := parsed["data"].(map[string]interface{})
|
||||
assert.Equal(t, "detection", data["notification_type"])
|
||||
})
|
||||
|
||||
t.Run("conflict resolved notification", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
|
||||
notification := map[string]interface{}{
|
||||
"type": "conflict",
|
||||
"timestamp": "2026-01-30T20:15:00Z",
|
||||
"data": map[string]interface{}{
|
||||
"book_id": "book-uuid-123",
|
||||
"notification_type": "resolved",
|
||||
"conflict_id": conflictID.String(),
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(notification)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
err = json.Unmarshal(body, &parsed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
data := parsed["data"].(map[string]interface{})
|
||||
assert.Equal(t, "resolved", data["notification_type"])
|
||||
assert.Equal(t, conflictID.String(), data["conflict_id"])
|
||||
t.Run("dismiss-all requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/dismiss-all", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -12,414 +13,506 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestConflictsBulkOperations tests bulk conflict resolution operations
|
||||
func TestConflictsBulkOperations(t *testing.T) {
|
||||
func TestBulkResolve_MostRecentStrategy(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
|
||||
|
||||
id1 := uuid.UUID(conflict1.ID.Bytes).String()
|
||||
id2 := uuid.UUID(conflict2.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{id1, id2},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, result.Total)
|
||||
assert.Equal(t, 2, result.Success)
|
||||
assert.Equal(t, 0, result.Failed)
|
||||
require.Len(t, result.Results, 2)
|
||||
|
||||
for _, r := range result.Results {
|
||||
assert.Equal(t, "success", r.Status)
|
||||
assert.Equal(t, "koreader", r.Winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkResolve_HighestProgressStrategy(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflictDataHighKobo := makeConflictData(0.30, 0.90)
|
||||
|
||||
conflict := createTestConflict(t, env, conflictDataHighKobo)
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{conflictID},
|
||||
Strategy: "highest_progress",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Total)
|
||||
assert.Equal(t, 1, result.Success)
|
||||
assert.Equal(t, 0, result.Failed)
|
||||
assert.Equal(t, "kobo", result.Results[0].Winner)
|
||||
}
|
||||
|
||||
func TestBulkResolve_ManualStrategy(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{conflictID},
|
||||
Strategy: "manual",
|
||||
WinningSource: "koreader",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Total)
|
||||
assert.Equal(t, 1, result.Success)
|
||||
assert.Equal(t, "koreader", result.Results[0].Winner)
|
||||
}
|
||||
|
||||
func TestBulkResolve_ManualStrategy_WithoutWinner(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{conflictID},
|
||||
Strategy: "manual",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
assert.Equal(t, "error", result.Results[0].Status)
|
||||
assert.Contains(t, result.Results[0].Error, "winning_source")
|
||||
}
|
||||
|
||||
func TestBulkResolve_ManualStrategy_InvalidWinner(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{conflictID},
|
||||
Strategy: "manual",
|
||||
WinningSource: "nonexistent_device",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
assert.Contains(t, result.Results[0].Error, "invalid winning source")
|
||||
}
|
||||
|
||||
func TestBulkResolve_ConflictNotFound(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkResolveConflicts_WithoutAuth", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
t.Run("BulkResolveConflicts_EmptyConflictIDs", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_InvalidConflictID", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{"invalid-uuid"},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
assert.Equal(t, 1, result.Total)
|
||||
assert.Equal(t, 0, result.Success)
|
||||
assert.Greater(t, result.Failed, 0)
|
||||
|
||||
firstResult := result.Results[0]
|
||||
assert.Equal(t, "error", firstResult.Status)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_InvalidStrategy", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "invalid_strategy",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Bulk operations return 200 OK with individual error results
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
assert.Greater(t, result.Total, 0)
|
||||
assert.Greater(t, result.Failed, 0)
|
||||
|
||||
firstResult := result.Results[0]
|
||||
assert.Equal(t, "error", firstResult.Status)
|
||||
// The error will be "conflict not found" since we're using a random UUID
|
||||
// The invalid strategy would be caught for valid conflict IDs
|
||||
assert.Contains(t, firstResult.Error, "conflict")
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_MostRecentStrategy", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String(), uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
assert.Equal(t, 2, result.Total)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_HighestProgressStrategy", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String(), uuid.New().String()},
|
||||
Strategy: "highest_progress",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
assert.Equal(t, 2, result.Total)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_ManualStrategy_WithoutWinner", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "manual",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_ManualStrategy_WithWinner", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "manual",
|
||||
WinningSource: "device",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_InvalidRequestBody", func(t *testing.T) {
|
||||
// Send invalid JSON
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json")))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
assert.Equal(t, 1, result.Total)
|
||||
assert.Equal(t, 0, result.Success)
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
assert.Contains(t, result.Results[0].Error, "conflict not found")
|
||||
}
|
||||
|
||||
// TestConflictsBulkDismiss tests bulk dismiss operations
|
||||
func TestConflictsBulkDismiss(t *testing.T) {
|
||||
func TestBulkResolve_EmptyConflictIDs(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkDismissConflicts_WithoutAuth", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{uuid.New().String()},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("BulkDismissConflicts_EmptyConflictIDs", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("BulkDismissConflicts_InvalidConflictID", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{"invalid-uuid", uuid.New().String()},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
assert.Contains(t, result, "success")
|
||||
assert.Contains(t, result, "failed")
|
||||
|
||||
results := result["results"].([]interface{})
|
||||
firstResult := results[0].(map[string]interface{})
|
||||
assert.Equal(t, "error", firstResult["status"])
|
||||
})
|
||||
|
||||
t.Run("BulkDismissConflicts_MultipleConflicts", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{
|
||||
uuid.New().String(),
|
||||
uuid.New().String(),
|
||||
uuid.New().String(),
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, float64(3), result["total"])
|
||||
})
|
||||
|
||||
t.Run("BulkDismissConflicts_InvalidRequestBody", func(t *testing.T) {
|
||||
// Send invalid JSON
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json")))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestConflictsBulkEscalate tests bulk escalate operations
|
||||
// NOTE: This test is commented out because the /api/conflicts/bulk-escalate endpoint
|
||||
// does not exist yet. It was planned in TEST_RELIABILITY_PLAN.md but never implemented.
|
||||
// Uncomment and update when the endpoint is added.
|
||||
/*
|
||||
func TestConflictsBulkEscalate(t *testing.T) {
|
||||
func TestBulkResolve_InvalidConflictID(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkEscalateConflicts_WithoutAuth", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{uuid.New().String()},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{"not-a-uuid"},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
t.Run("BulkEscalateConflicts_EmptyConflictIDs", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Allow queue processor to process the item before querying for conflicts
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
assert.Contains(t, result, "failed")
|
||||
|
||||
results := result["results"].([]interface{})
|
||||
firstResult := results[0].(map[string]interface{})
|
||||
assert.Equal(t, "error", firstResult["status"])
|
||||
})
|
||||
|
||||
t.Run("BulkEscalateConflicts_MultipleConflicts", func(t *testing.T) {
|
||||
conflictIDs := []string{
|
||||
uuid.New().String(),
|
||||
uuid.New().String(),
|
||||
}
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": conflictIDs,
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, float64(2), result["total"])
|
||||
|
||||
// NEW: Database verification - verify conflicts were escalated
|
||||
for _, conflictID := range conflictIDs {
|
||||
pgID, err := uuid.Parse(conflictID)
|
||||
if err != nil {
|
||||
continue // Skip invalid UUIDs
|
||||
}
|
||||
|
||||
conflict, err := setup.DB.GetSyncConflict(context.Background(), pgtype.UUID{Bytes: [16]byte(pgID), Valid: true})
|
||||
if err == nil {
|
||||
// If conflict exists, verify it was escalated
|
||||
assert.Equal(t, "escalated", conflict.ResolutionStatus.String, "Conflict should be escalated")
|
||||
}
|
||||
}
|
||||
})
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
assert.Contains(t, result.Results[0].Error, "invalid conflict ID")
|
||||
}
|
||||
|
||||
func TestBulkResolve_InvalidRequestBody(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json")))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBulkResolve_RequiresAuth(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBulkDismiss_RealConflicts(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
|
||||
|
||||
id1 := uuid.UUID(conflict1.ID.Bytes).String()
|
||||
id2 := uuid.UUID(conflict2.ID.Bytes).String()
|
||||
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{id1, id2},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(2), result["total"])
|
||||
assert.Equal(t, float64(2), result["success"])
|
||||
assert.Equal(t, float64(0), result["failed"])
|
||||
|
||||
results := result["results"].([]interface{})
|
||||
require.Len(t, results, 2)
|
||||
for _, r := range results {
|
||||
entry := r.(map[string]interface{})
|
||||
assert.Equal(t, "success", entry["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDismiss_NotFoundConflict(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{uuid.New().String()},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(1), result["total"])
|
||||
assert.Equal(t, float64(0), result["success"])
|
||||
assert.Equal(t, float64(1), result["failed"])
|
||||
}
|
||||
|
||||
func TestBulkDismiss_InvalidConflictID(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{"invalid-uuid"},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(1), result["total"])
|
||||
assert.Equal(t, float64(1), result["failed"])
|
||||
assert.Contains(t, result["results"].([]interface{})[0].(map[string]interface{})["error"], "invalid conflict ID")
|
||||
}
|
||||
|
||||
func TestBulkDismiss_EmptyConflictIDs(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBulkDismiss_InvalidRequestBody(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json")))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBulkDismiss_RequiresAuth(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{uuid.New().String()},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBulkResolve_MixedSuccessAndFailure(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
realID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{realID, uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, result.Total)
|
||||
assert.Equal(t, 1, result.Success)
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -40,7 +41,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -50,7 +53,7 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
|
||||
sections, ok := response["sections"].([]interface{})
|
||||
require.True(s.T(), ok, "sections should be an array")
|
||||
require.Len(s.T(), sections, 4, "Should have 4 system collections")
|
||||
require.Len(s.T(), sections, 5, "Should have 5 system collections")
|
||||
|
||||
// Verify response structure
|
||||
sectionMap := make(map[string]map[string]interface{})
|
||||
@@ -71,6 +74,7 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
assert.Contains(s.T(), sectionMap, "recently-added")
|
||||
assert.Contains(s.T(), sectionMap, "recently-read")
|
||||
assert.Contains(s.T(), sectionMap, "not-started")
|
||||
assert.Contains(s.T(), sectionMap, "continue-series")
|
||||
|
||||
// Verify continue-reading is a system collection
|
||||
continueReading := sectionMap["continue-reading"]
|
||||
@@ -87,7 +91,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -101,7 +107,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -112,7 +120,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_Unauthorized() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -136,7 +146,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Success() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -164,7 +176,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Unauthorized() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -184,7 +198,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName(
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -201,7 +217,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -209,7 +227,7 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
|
||||
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() {
|
||||
token := s.setup.Token
|
||||
|
||||
validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started"}
|
||||
validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started", "Continue Series"}
|
||||
|
||||
for _, collName := range validCollections {
|
||||
s.T().Run(collName, func(t *testing.T) {
|
||||
@@ -224,14 +242,16 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
assert.Contains(t, response, "message")
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -68,7 +69,9 @@ func TestUpdateUserMaxDevices(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected status code")
|
||||
|
||||
@@ -134,7 +137,9 @@ func TestUpdateUserMaxDevicesValidation(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected validation error")
|
||||
})
|
||||
@@ -166,7 +171,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -190,7 +197,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
@@ -221,7 +230,9 @@ func TestUpdateUserMaxDevicesNonExistentUser(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 500 or 404 depending on implementation
|
||||
assert.True(t, resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusNotFound)
|
||||
@@ -250,7 +261,9 @@ func TestUpdateUserMaxDevicesMissingUserID(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -271,12 +284,15 @@ func TestListUsersIncludesMaxDevices(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var users []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&users)
|
||||
err = json.NewDecoder(resp.Body).Decode(&users)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify max_devices and device_count fields are present in response
|
||||
if len(users) > 0 {
|
||||
@@ -306,7 +322,7 @@ func createAdminUser(t *testing.T, ts *httptest.Server, token string) {
|
||||
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Do(req)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// Helper function to create test user for max devices tests
|
||||
@@ -327,7 +343,9 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Check if user creation succeeded or already exists (409 Conflict)
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
@@ -343,10 +361,13 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
||||
|
||||
loginResp, err := client.Do(loginReq)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extract user_id from JWT or response
|
||||
// The access_token contains the user ID in the JWT claims
|
||||
@@ -392,7 +413,8 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check if user creation was successful
|
||||
if result["user"] == nil {
|
||||
@@ -422,10 +444,13 @@ func getAdminToken(t *testing.T, ts *httptest.Server, userID uuid.UUID) string {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Safe type assertion with check
|
||||
if accessToken, ok := result["access_token"].(string); ok {
|
||||
@@ -450,10 +475,13 @@ func loginTestUserByCredentials(t *testing.T, ts *httptest.Server, email, passwo
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Safe type assertion with check
|
||||
if accessToken, ok := result["access_token"].(string); ok {
|
||||
|
||||
@@ -147,10 +147,12 @@ func TestUpdateDevice(t *testing.T) {
|
||||
device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123")
|
||||
|
||||
// Update device
|
||||
syncEnabled := false
|
||||
syncFreq := int32(10)
|
||||
updateRequest := handlers.DeviceUpdateRequest{
|
||||
DeviceName: "Updated Device Name",
|
||||
SyncEnabled: new(false),
|
||||
SyncFrequencyMinutes: new(int32(10)),
|
||||
SyncEnabled: &syncEnabled,
|
||||
SyncFrequencyMinutes: &syncFreq,
|
||||
}
|
||||
updateBody, _ := json.Marshal(updateRequest)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -19,7 +20,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters?resource_type=media-items", nil)
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -30,12 +33,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var filters []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&filters)
|
||||
err = json.NewDecoder(resp.Body).Decode(&filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(filters))
|
||||
})
|
||||
|
||||
@@ -56,12 +62,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var filter map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&filter)
|
||||
err = json.NewDecoder(resp.Body).Decode(&filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "My Sci-Fi Books", filter["name"])
|
||||
assert.Equal(t, "media-items", filter["resource_type"])
|
||||
assert.NotEmpty(t, filter["id"])
|
||||
@@ -84,7 +93,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp1, err := client.Do(httpReq1)
|
||||
require.NoError(t, err)
|
||||
resp1.Body.Close()
|
||||
_ = resp1.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
|
||||
|
||||
@@ -96,7 +105,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp2, err := client.Do(httpReq2)
|
||||
require.NoError(t, err)
|
||||
resp2.Body.Close()
|
||||
_ = resp2.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
|
||||
})
|
||||
@@ -116,12 +125,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
createResp, err := client.Do(createHTTP)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
|
||||
// Update filter
|
||||
@@ -138,12 +150,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
updateResp, err := client.Do(updateHTTP)
|
||||
require.NoError(t, err)
|
||||
defer updateResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(updateResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
|
||||
var updatedFilter map[string]interface{}
|
||||
json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
|
||||
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", updatedFilter["name"])
|
||||
})
|
||||
|
||||
@@ -162,10 +177,13 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
createResp, err := client.Do(createHTTP)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
|
||||
// Delete filter
|
||||
@@ -174,7 +192,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
deleteResp, err := client.Do(deleteHTTP)
|
||||
require.NoError(t, err)
|
||||
deleteResp.Body.Close()
|
||||
_ = deleteResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode)
|
||||
})
|
||||
@@ -198,10 +216,13 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
createResp, err := client.Do(createHTTP)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
|
||||
// Admin user tries to delete regular user's filter
|
||||
@@ -210,7 +231,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
deleteResp, err := client.Do(deleteHTTP)
|
||||
require.NoError(t, err)
|
||||
deleteResp.Body.Close()
|
||||
_ = deleteResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode)
|
||||
})
|
||||
@@ -230,12 +251,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
|
||||
// Now retrieve the filter by ID
|
||||
@@ -244,12 +268,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
getResp, err := client.Do(getReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(getResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
var retrievedFilter map[string]interface{}
|
||||
json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
|
||||
err = json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Test Filter", retrievedFilter["name"])
|
||||
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
|
||||
assert.Equal(t, filterID, retrievedFilter["id"])
|
||||
@@ -260,7 +287,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil)
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -271,7 +300,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -283,7 +314,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
@@ -301,16 +334,21 @@ func TestSavedFilters(t *testing.T) {
|
||||
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
// Try to access with regular user (setup.RegularToken)
|
||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
getResp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(getResp.Body)
|
||||
// Should return 404 (not 403 - hide existence)
|
||||
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -31,10 +32,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
libResp, err := client.Do(libReq)
|
||||
require.NoError(t, err)
|
||||
defer libResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(libResp.Body)
|
||||
require.Equal(t, http.StatusCreated, libResp.StatusCode)
|
||||
var libResult map[string]interface{}
|
||||
json.NewDecoder(libResp.Body).Decode(&libResult)
|
||||
err = json.NewDecoder(libResp.Body).Decode(&libResult)
|
||||
require.NoError(t, err)
|
||||
libraryID := libResult["id"].(string)
|
||||
// Add folder to library
|
||||
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID)
|
||||
@@ -47,7 +51,9 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
folderHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
||||
folderResp, err := client.Do(folderHTTPReq)
|
||||
require.NoError(t, err)
|
||||
defer folderResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(folderResp.Body)
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder should be added to library")
|
||||
// Create 20 test files simultaneously
|
||||
for i := 0; i < 20; i++ {
|
||||
@@ -61,10 +67,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
scanHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
||||
scanResp, err := client.Do(scanHTTPReq)
|
||||
require.NoError(t, err)
|
||||
defer scanResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(scanResp.Body)
|
||||
require.Equal(t, http.StatusAccepted, scanResp.StatusCode, "Scan should be accepted")
|
||||
var scanResponse map[string]interface{}
|
||||
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
require.NoError(t, err)
|
||||
jobID, ok := scanResponse["job_id"].(string)
|
||||
require.True(t, ok, "job_id should be string")
|
||||
require.NotEmpty(t, jobID, "job_id should not be empty")
|
||||
@@ -81,16 +90,17 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
if statusResp.StatusCode == http.StatusNotFound {
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
break // Job completed
|
||||
}
|
||||
|
||||
var status map[string]interface{}
|
||||
json.NewDecoder(statusResp.Body).Decode(&status)
|
||||
statusResp.Body.Close()
|
||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||
require.NoError(t, err)
|
||||
_ = statusResp.Body.Close()
|
||||
|
||||
if status["status"] == "completed" || status["status"] == "failed" {
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -99,9 +109,12 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
itemsResp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer itemsResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(itemsResp.Body)
|
||||
var itemsResult map[string]interface{}
|
||||
json.NewDecoder(itemsResp.Body).Decode(&itemsResult)
|
||||
err = json.NewDecoder(itemsResp.Body).Decode(&itemsResult)
|
||||
require.NoError(t, err)
|
||||
items, ok := itemsResult["data"].([]interface{})
|
||||
if !ok || items == nil {
|
||||
items = []interface{}{} // Handle nil or wrong type
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -33,7 +34,9 @@ func TestJobsHandler_CreateJob(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
||||
@@ -65,7 +68,9 @@ func TestJobsHandler_CreateJob_InvalidType(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -88,7 +93,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
var createResponse map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
||||
@@ -103,7 +110,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
|
||||
|
||||
getResp, err := client.Do(getReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(getResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
@@ -128,7 +137,9 @@ func TestJobsHandler_GetJobStatus_NotFound(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
@@ -151,7 +162,9 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
var createResponse map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
||||
@@ -172,7 +185,7 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
|
||||
|
||||
var statusResponse map[string]interface{}
|
||||
err = json.NewDecoder(getResp.Body).Decode(&statusResponse)
|
||||
getResp.Body.Close()
|
||||
_ = getResp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
if statusResponse["status"] != nil {
|
||||
@@ -202,7 +215,9 @@ func TestJobsHandler_CreateJob_Unauthorized(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -218,7 +233,9 @@ func TestJobsHandler_GetJobStatus_Unauthorized(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -37,7 +38,9 @@ func TestKoboInitialization(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
@@ -65,7 +68,9 @@ func TestKoboLibrarySync(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
@@ -129,11 +134,14 @@ func TestKoboMarkupSync(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
@@ -182,11 +190,14 @@ func TestKoboBookmarkSync(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
@@ -224,11 +235,14 @@ func TestKoboAnalyticsGettests(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,13 +182,13 @@ func TestLibraryTypesResponse(t *testing.T) {
|
||||
"id": "test-id-2",
|
||||
"name": "comics",
|
||||
"description": "Comic book archives and image formats",
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".pdf"},
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||
},
|
||||
{
|
||||
"id": "test-id-3",
|
||||
"name": "manga",
|
||||
"description": "Manga files including archives and image folders",
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"},
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -556,7 +556,7 @@ func TestLibraryTypes(t *testing.T) {
|
||||
"id": uuid.New().String(),
|
||||
"name": "comics",
|
||||
"description": "Comic book archives and image formats",
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".pdf"},
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||
},
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -31,7 +32,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
|
||||
@@ -58,7 +61,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -76,12 +81,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -106,12 +114,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, 3.0, result["total"])
|
||||
@@ -130,7 +141,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -154,7 +167,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -179,7 +194,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -204,12 +221,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -246,12 +266,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, 2.0, result["total"])
|
||||
@@ -304,12 +327,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -352,12 +378,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
})
|
||||
@@ -371,7 +400,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -31,12 +32,15 @@ func createTestLibrary(t *testing.T, ts *httptest.Server, token, name string) st
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
return result["id"].(string)
|
||||
}
|
||||
@@ -164,7 +168,9 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Check if this is an invalid ISBN case that should return 422
|
||||
if tc.expected == "" && (tc.input == "---" || tc.input == " ") {
|
||||
@@ -174,7 +180,8 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// For valid ISBN responses, verify normalization worked correctly
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
@@ -209,7 +216,9 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
})
|
||||
@@ -232,10 +241,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(t, "9780306406157", response["isbn"])
|
||||
@@ -259,10 +271,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(t, "9780596009652", response["isbn"])
|
||||
@@ -296,7 +311,7 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// Small delay to allow database to commit before pagination queries
|
||||
@@ -309,12 +324,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
data := response["data"].([]interface{})
|
||||
|
||||
// Should get 2 items
|
||||
@@ -328,12 +346,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
data := response["data"].([]interface{})
|
||||
|
||||
// Should get 2 items starting from offset 2
|
||||
@@ -347,7 +368,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 400 Bad Request or handle it gracefully
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -360,7 +383,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 400 Bad Request or handle it gracefully
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -373,7 +398,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should be capped at maximum or return error
|
||||
// The application uses maxPaginationLimit = 1000
|
||||
@@ -404,7 +431,9 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should fail - library_id is required
|
||||
assert.NotEqual(t, http.StatusCreated, resp.StatusCode)
|
||||
@@ -431,12 +460,15 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// VerifyISBN was normalized
|
||||
assert.Equal(t, "9780306406157", response["isbn"])
|
||||
@@ -472,8 +504,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var createResponse map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&createResponse)
|
||||
resp.Body.Close()
|
||||
err = json.NewDecoder(resp.Body).Decode(&createResponse)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
mediaItemID := createResponse["id"].(string)
|
||||
|
||||
@@ -491,7 +524,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// OPDS endpoints require device authentication via devices.auth_token
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
@@ -37,7 +39,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 400 for invalid UUID
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
@@ -53,7 +57,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 200 with catalog (even if empty)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -69,7 +75,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 200 with catalog (even if empty)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -80,7 +88,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -95,7 +105,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 200 (even if empty results)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -106,7 +118,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -120,7 +134,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return navigation or 404
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
||||
@@ -131,7 +147,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -142,7 +160,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -156,7 +176,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// May return 404 if device/book not linked, or 500 for file not found
|
||||
// Should not return 400 (invalid IDs)
|
||||
@@ -168,7 +190,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -178,7 +202,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -192,7 +218,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// May return 404 if no cover, but not 400
|
||||
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
||||
@@ -204,7 +232,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -218,7 +248,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return formats list or 404
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
||||
@@ -245,7 +277,9 @@ func TestOPDSConversion(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should attempt conversion (may fail if file doesn't exist)
|
||||
// Important: Should not return 400 for invalid IDs
|
||||
@@ -262,7 +296,9 @@ func TestOPDSConversion(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should attempt to download original format
|
||||
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
||||
@@ -278,7 +314,9 @@ func TestOPDSConversion(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle gracefully (either 400 for unsupported format or 404/500)
|
||||
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600)
|
||||
@@ -300,7 +338,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return empty catalog, not error
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -316,7 +356,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle special characters
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -331,7 +373,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle empty query
|
||||
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
|
||||
@@ -371,7 +415,9 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
t.Logf("OPDS Search Status: %d", resp.StatusCode)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -108,8 +109,8 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
||||
{
|
||||
name: "Empty UUID",
|
||||
libraryID: "",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
description: "Should return 404 for empty ID",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Should return 400 for empty ID",
|
||||
},
|
||||
{
|
||||
name: "UUID with extra path traversal",
|
||||
@@ -139,7 +140,7 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/list", nil)
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/list", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -284,8 +285,8 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
||||
{
|
||||
name: "Empty UUID",
|
||||
libraryID: "",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
description: "Should return 404 for empty ID",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Should return 400 for empty ID",
|
||||
},
|
||||
{
|
||||
name: "UUID with extra path traversal",
|
||||
@@ -315,7 +316,7 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/stats", nil)
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/stats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -388,7 +389,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
||||
setup.Server.Config.Handler.ServeHTTP(rec1, req1)
|
||||
|
||||
var stats1 map[string]interface{}
|
||||
json.NewDecoder(rec1.Body).Decode(&stats1)
|
||||
err := json.NewDecoder(rec1.Body).Decode(&stats1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get stats for library 2
|
||||
req2 := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/stats", nil)
|
||||
@@ -398,7 +400,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
||||
setup.Server.Config.Handler.ServeHTTP(rec2, req2)
|
||||
|
||||
var stats2 map[string]interface{}
|
||||
json.NewDecoder(rec2.Body).Decode(&stats2)
|
||||
err = json.NewDecoder(rec2.Body).Decode(&stats2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both should have zero counts
|
||||
assert.Equal(t, float64(0), stats1["error_count"])
|
||||
@@ -424,7 +427,6 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
||||
{"Ebooks library", "ebooks"},
|
||||
{"Comics library", "comics"},
|
||||
{"Manga library", "manga"},
|
||||
{"Audiobooks library", "audiobooks"},
|
||||
}
|
||||
|
||||
for _, lt := range libraryTypes {
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func pFloat64(v float64) *float64 { return &v }
|
||||
func pStr(v string) *string { return &v }
|
||||
func pInt(v int) *int { return &v }
|
||||
func pInt64(v int64) *int64 { return &v }
|
||||
|
||||
func doReq(t *testing.T, method, url string, body interface{}, token string) *http.Response {
|
||||
t.Helper()
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
bodyReader = bytes.NewBuffer(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, resp *http.Response) map[string]interface{} {
|
||||
t.Helper()
|
||||
var result map[string]interface{}
|
||||
err := json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
func progressURL(serverURL, mediaItemID string) string {
|
||||
return serverURL + "/api/media-items/" + mediaItemID + "/progress"
|
||||
}
|
||||
|
||||
func getFloatField(t *testing.T, data map[string]interface{}, field string) float64 {
|
||||
t.Helper()
|
||||
val, ok := data[field]
|
||||
require.True(t, ok, "%s should be present in response", field)
|
||||
require.NotNil(t, val, "%s should not be null", field)
|
||||
f, ok := val.(float64)
|
||||
require.True(t, ok, "%s should be a number, got %T: %v", field, val, val)
|
||||
return f
|
||||
}
|
||||
|
||||
func getStringField(t *testing.T, data map[string]interface{}, field string) string {
|
||||
t.Helper()
|
||||
val, ok := data[field]
|
||||
require.True(t, ok, "%s should be present in response", field)
|
||||
require.NotNil(t, val, "%s should not be null", field)
|
||||
s, ok := val.(string)
|
||||
require.True(t, ok, "%s should be a string, got %T: %v", field, val, val)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestProgressWeb_AuthContexts(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
|
||||
t.Run("unauthenticated PUT returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
}, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated GET returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "GET", url, nil, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("regular user PUT succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.3,
|
||||
}, setup.RegularToken)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("regular user GET succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "GET", url, nil, setup.RegularToken)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("admin PUT succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.7,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("admin GET succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("invalid media item ID returns 400", func(t *testing.T) {
|
||||
badURL := setup.Server.URL + "/api/media-items/not-a-uuid/progress"
|
||||
resp := doReq(t, "GET", badURL, nil, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("nonexistent media item GET returns 200 with empty", func(t *testing.T) {
|
||||
fakeID := uuid.New().String()
|
||||
fakeURL := progressURL(setup.Server.URL, fakeID)
|
||||
resp := doReq(t, "GET", fakeURL, nil, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_MergePreservesFields(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
|
||||
t.Run("second PUT with only percentage preserves epubcfi and chapter from first", func(t *testing.T) {
|
||||
resp1 := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.3,
|
||||
"epubcfi": "epubcfi(/6/4/2:first)",
|
||||
"chapter": 2,
|
||||
}, setup.Token)
|
||||
defer resp1.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp1.StatusCode)
|
||||
|
||||
resp2 := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
}, setup.Token)
|
||||
defer resp2.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
|
||||
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer getResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
result := decodeJSON(t, getResp)
|
||||
pct := getFloatField(t, result, "percentage")
|
||||
assert.InDelta(t, 0.5, pct, 0.01)
|
||||
|
||||
epubcfi := getStringField(t, result, "epubcfi")
|
||||
assert.Equal(t, "epubcfi(/6/4/2:first)", epubcfi, "epubcfi should be preserved from first save")
|
||||
|
||||
chapter := getFloatField(t, result, "chapter")
|
||||
assert.Equal(t, float64(2), chapter, "chapter should be preserved from first save")
|
||||
})
|
||||
|
||||
t.Run("web save preserves koreader character_offset", func(t *testing.T) {
|
||||
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||
url2 := progressURL(setup.Server.URL, mediaItemID2)
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID2)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
charOffset := int64(15000)
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "koreader",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: pFloat64(0.45),
|
||||
CharacterOffset: &charOffset,
|
||||
Chapter: pInt(5),
|
||||
DeviceType: "koreader",
|
||||
DeviceName: "KOReader Test",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := doReq(t, "PUT", url2, map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
"epubcfi": "epubcfi(/6/4/2:10)",
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be preserved")
|
||||
assert.Equal(t, int64(15000), progress.CharacterOffset.Int64)
|
||||
assert.True(t, progress.Chapter.Valid, "chapter should be preserved")
|
||||
assert.Equal(t, int32(5), progress.Chapter.Int32)
|
||||
assert.InDelta(t, 0.5, progress.Percentage.Float64, 0.001)
|
||||
assert.Equal(t, "web", progress.LastSyncSource.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_EnrichmentComputesFields(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("character_offset computed from percentage when total_characters set", func(t *testing.T) {
|
||||
_, err := setup.DBPool.Exec(ctx, "UPDATE media_items SET total_characters = $1 WHERE id = $2", int64(200000), mediaUUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be computed from percentage")
|
||||
assert.Equal(t, int64(100000), progress.CharacterOffset.Int64)
|
||||
})
|
||||
|
||||
t.Run("GET returns enriched format_group and total_characters", func(t *testing.T) {
|
||||
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer getResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
result := decodeJSON(t, getResp)
|
||||
_, hasFormatGroup := result["format_group"]
|
||||
assert.True(t, hasFormatGroup, "format_group should be present in GET response")
|
||||
_, hasTotalChars := result["total_characters"]
|
||||
assert.True(t, hasTotalChars, "total_characters should be present in GET response")
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_ConflictDetection(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("different sources with >1% diff within 5min creates conflict record", func(t *testing.T) {
|
||||
pct1 := 0.3
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "koreader",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct1,
|
||||
DeviceType: "koreader",
|
||||
DeviceName: "KOReader",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pct2 := 0.6
|
||||
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct2,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: "Kobo",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001)
|
||||
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
||||
|
||||
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, conflicts, "conflict should be recorded in sync_conflicts table")
|
||||
assert.Equal(t, "progress", conflicts[0].ConflictType)
|
||||
assert.True(t, conflicts[0].ResolutionStatus.Valid)
|
||||
assert.Equal(t, "unresolved", conflicts[0].ResolutionStatus.String)
|
||||
})
|
||||
|
||||
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||
mediaUUID2, _ := uuid.Parse(mediaItemID2)
|
||||
|
||||
t.Run("same source rapid saves create no conflict", func(t *testing.T) {
|
||||
for _, pct := range []float64{0.1, 0.3, 0.5, 0.7} {
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "web",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct,
|
||||
DeviceType: "web",
|
||||
DeviceName: "Web",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, conflicts, "same-source saves should not create conflicts")
|
||||
})
|
||||
|
||||
mediaItemID3 := createTestMediaItemID(t, setup)
|
||||
mediaUUID3, _ := uuid.Parse(mediaItemID3)
|
||||
|
||||
t.Run("different sources with <1% diff creates no conflict", func(t *testing.T) {
|
||||
pct1 := 0.5
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "koreader",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct1,
|
||||
DeviceType: "koreader",
|
||||
DeviceName: "KOReader",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pct2 := 0.505
|
||||
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct2,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: "Kobo",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, conflicts, "small percentage diff should not create conflict")
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_KoboIntegration(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
|
||||
koboDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: "Test Kobo Progress",
|
||||
DeviceType: "kobo",
|
||||
DeviceIdentifier: "kobo-progress-test",
|
||||
AuthToken: koboDeviceToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err, "Should create kobo device")
|
||||
_ = device
|
||||
|
||||
t.Run("unauthenticated Kobo markup returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/kobo/invalid-token/markup", map[string]interface{}{
|
||||
"ReadingSync": []map[string]interface{}{
|
||||
{"ContentId": uuid.New().String(), "PercentRead": 50.0},
|
||||
},
|
||||
}, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
|
||||
t.Run("ReadingSync then last-read-place preserves percentage", func(t *testing.T) {
|
||||
readingSyncBody := map[string]interface{}{
|
||||
"ReadingSync": []map[string]interface{}{
|
||||
{
|
||||
"ContentId": mediaItemID,
|
||||
"PercentRead": 55.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), readingSyncBody, "")
|
||||
defer resp.Body.Close()
|
||||
io.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
bookmarkBody := map[string]interface{}{
|
||||
"BookmarkSync": []map[string]interface{}{
|
||||
{
|
||||
"ContentId": mediaItemID,
|
||||
"BookmarkId": "epubcfi(/6/4!/4/2/1:0)",
|
||||
"BookmarkType": "last-read-place",
|
||||
"Chapter": 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp2 := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
|
||||
defer resp2.Body.Close()
|
||||
io.ReadAll(resp2.Body)
|
||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Percentage.Valid)
|
||||
assert.InDelta(t, 0.55, progress.Percentage.Float64, 0.01, "percentage should still be 55% from ReadingSync")
|
||||
})
|
||||
|
||||
t.Run("Kobo last-read-place without prior ReadingSync sets epubcfi and chapter", func(t *testing.T) {
|
||||
newMediaID := createTestMediaItemID(t, setup)
|
||||
newMediaUUID, _ := uuid.Parse(newMediaID)
|
||||
|
||||
bookmarkBody := map[string]interface{}{
|
||||
"BookmarkSync": []map[string]interface{}{
|
||||
{
|
||||
"ContentId": newMediaID,
|
||||
"BookmarkId": "epubcfi(/6/14!/4/2/1:0)",
|
||||
"BookmarkType": "last-read-place",
|
||||
"Chapter": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
|
||||
defer resp.Body.Close()
|
||||
io.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: newMediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Epubcfi.Valid, "epubcfi should be set from last-read-place")
|
||||
assert.True(t, progress.Chapter.Valid, "chapter should be set from last-read-place")
|
||||
assert.Equal(t, int32(3), progress.Chapter.Int32)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_KOReaderIntegration(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
koreaderDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
_, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: "Test KOReader Progress",
|
||||
DeviceType: "koreader",
|
||||
DeviceIdentifier: "koreader-progress-test",
|
||||
AuthToken: koreaderDeviceToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err, "Should create koreader device")
|
||||
|
||||
t.Run("KOReader progress sync via HTTP", func(t *testing.T) {
|
||||
progressBody := map[string]interface{}{
|
||||
"books": []map[string]interface{}{
|
||||
{
|
||||
"file_path": "/tmp/test.epub",
|
||||
"percentage": 0.42,
|
||||
"chapter": 3,
|
||||
"device_info": map[string]interface{}{
|
||||
"device_model": "Test Device",
|
||||
"koreader_version": "1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(progressBody)
|
||||
req, err := http.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(bodyBytes))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+koreaderDeviceToken)
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
io.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
if err == nil {
|
||||
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated KOReader sync returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", map[string]interface{}{
|
||||
"books": []map[string]interface{}{
|
||||
{"file_path": "/tmp/test.epub", "percentage": 0.5},
|
||||
},
|
||||
}, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_DeleteProgress(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
|
||||
t.Run("DELETE removes progress", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.75,
|
||||
"epubcfi": "epubcfi(/6/4/2:20)",
|
||||
"chapter": 7,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
delResp := doReq(t, "DELETE", url, nil, setup.Token)
|
||||
defer delResp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, delResp.StatusCode)
|
||||
|
||||
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer getResp.Body.Close()
|
||||
result := decodeJSON(t, getResp)
|
||||
assert.Equal(t, float64(0), result["current_page"], "progress should be cleared after delete")
|
||||
})
|
||||
|
||||
t.Run("DELETE without auth returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "DELETE", url, nil, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_EdgeCases(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
|
||||
t.Run("PUT with empty body still succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("PUT percentage 0.0", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.0,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer getResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("PUT percentage 1.0", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 1.0,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("PUT with all fields then GET verifies each", func(t *testing.T) {
|
||||
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||
url2 := progressURL(setup.Server.URL, mediaItemID2)
|
||||
mediaUUID2, _ := uuid.Parse(mediaItemID2)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
resp := doReq(t, "PUT", url2, map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
"current_page": 84,
|
||||
"total_pages": 200,
|
||||
"epubcfi": "epubcfi(/6/4!/4/2/1:0)",
|
||||
"chapter": 3,
|
||||
"chapter_progress": 0.5,
|
||||
"character_offset": 15000,
|
||||
"reading_mode": "page",
|
||||
"zoom_level": 1.5,
|
||||
"scroll_position_x": 0.0,
|
||||
"scroll_position_y": 100.0,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Percentage.Valid)
|
||||
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
|
||||
assert.True(t, progress.Chapter.Valid)
|
||||
assert.Equal(t, int32(3), progress.Chapter.Int32)
|
||||
assert.True(t, progress.Epubcfi.Valid)
|
||||
assert.Equal(t, "epubcfi(/6/4!/4/2/1:0)", progress.Epubcfi.String)
|
||||
assert.True(t, progress.CurrentPage.Valid)
|
||||
assert.Equal(t, int32(84), progress.CurrentPage.Int32)
|
||||
assert.True(t, progress.TotalPages.Valid)
|
||||
assert.Equal(t, int32(200), progress.TotalPages.Int32)
|
||||
assert.True(t, progress.ReadingMode.Valid)
|
||||
assert.Equal(t, "page", progress.ReadingMode.String)
|
||||
assert.True(t, progress.ZoomLevel.Valid)
|
||||
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -24,7 +25,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -40,7 +43,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -58,12 +63,15 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
loginResp, err := client.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken, ok := loginResult["refresh_token"].(string)
|
||||
require.True(t, ok, "Should have refresh_token")
|
||||
@@ -79,12 +87,15 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
refreshResp, err := client.Do(refreshHTTP)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||
|
||||
var refreshResult map[string]interface{}
|
||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, refreshResult, "access_token")
|
||||
assert.NotEmpty(t, refreshResult["access_token"], "New access token should not be empty")
|
||||
@@ -102,7 +113,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -118,7 +131,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should still work or return appropriate error
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnsupportedMediaType)
|
||||
@@ -143,12 +158,15 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
||||
|
||||
loginResp, err := client.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken := loginResult["refresh_token"].(string)
|
||||
|
||||
@@ -163,7 +181,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
||||
|
||||
refreshResp1, err := client.Do(refreshHTTP1)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp1.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp1.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, refreshResp1.StatusCode)
|
||||
|
||||
@@ -173,7 +193,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
||||
|
||||
refreshResp2, err := client.Do(refreshHTTP2)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp2.Body)
|
||||
|
||||
// May return 401 if token reuse is detected, or 200 if not implemented
|
||||
// Either is acceptable depending on security requirements
|
||||
@@ -197,7 +219,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -213,7 +237,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -231,12 +257,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
loginResp, err := client.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken := loginResult["refresh_token"].(string)
|
||||
|
||||
@@ -251,12 +280,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
refreshResp, err := client.Do(refreshHTTP)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||
|
||||
var refreshResult map[string]interface{}
|
||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify response structure
|
||||
assert.Contains(t, refreshResult, "access_token")
|
||||
@@ -279,12 +311,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
loginResp, err := client.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken := loginResult["refresh_token"].(string)
|
||||
|
||||
@@ -299,12 +334,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
refreshResp, err := client.Do(refreshHTTP)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||
|
||||
var refreshResult map[string]interface{}
|
||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify access token is a string
|
||||
accessToken, ok := refreshResult["access_token"].(string)
|
||||
|
||||
@@ -25,8 +25,9 @@ func TestScanSettings_GetSettings(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
resp.Body.Close()
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Contains(t, response, "scan_poll_interval_seconds")
|
||||
assert.Contains(t, response, "auto_scan_enabled")
|
||||
@@ -63,8 +64,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
resp.Body.Close()
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Equal(t, float64(45), response["scan_poll_interval_seconds"])
|
||||
assert.Equal(t, true, response["auto_scan_enabled"])
|
||||
@@ -74,8 +76,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
||||
getResp, _ := client.Do(getReq)
|
||||
|
||||
var getResponse map[string]interface{}
|
||||
json.NewDecoder(getResp.Body).Decode(&getResponse)
|
||||
getResp.Body.Close()
|
||||
err = json.NewDecoder(getResp.Body).Decode(&getResponse)
|
||||
require.NoError(t, err)
|
||||
_ = getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, float64(45), getResponse["scan_poll_interval_seconds"])
|
||||
})
|
||||
@@ -99,8 +102,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
resp.Body.Close()
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Equal(t, false, response["auto_scan_enabled"])
|
||||
})
|
||||
|
||||
@@ -46,8 +46,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
require.Equal(s.T(), http.StatusCreated, createLibResp.StatusCode)
|
||||
|
||||
var createLibResponse map[string]interface{}
|
||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
createLibResp.Body.Close()
|
||||
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
require.NoError(s.T(), err)
|
||||
_ = createLibResp.Body.Close()
|
||||
|
||||
libraryID, ok := createLibResponse["id"].(string)
|
||||
require.True(s.T(), ok, "library_id should be string")
|
||||
@@ -64,7 +65,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Folder creation should succeed")
|
||||
|
||||
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
||||
@@ -78,7 +79,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
var scanResponse map[string]interface{}
|
||||
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
require.NoError(s.T(), err)
|
||||
scanResp.Body.Close()
|
||||
_ = scanResp.Body.Close()
|
||||
|
||||
jobID, ok := scanResponse["job_id"].(string)
|
||||
require.True(s.T(), ok, "job_id should be string")
|
||||
@@ -104,7 +105,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
if statusResp.StatusCode == http.StatusNotFound {
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
if gotProgressUpdate {
|
||||
break
|
||||
}
|
||||
@@ -113,7 +114,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
|
||||
var status map[string]interface{}
|
||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
if _, hasError := status["error"]; hasError {
|
||||
@@ -180,8 +181,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
var createLibResponse map[string]interface{}
|
||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
createLibResp.Body.Close()
|
||||
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
require.NoError(s.T(), err)
|
||||
_ = createLibResp.Body.Close()
|
||||
|
||||
libraryID, ok := createLibResponse["id"].(string)
|
||||
require.True(s.T(), ok, "library_id should be string")
|
||||
@@ -198,7 +200,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
|
||||
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
||||
scanReq, _ := http.NewRequest("POST", scanURL, nil)
|
||||
@@ -208,8 +210,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
var scanResponse map[string]interface{}
|
||||
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
scanResp.Body.Close()
|
||||
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
require.NoError(s.T(), err)
|
||||
_ = scanResp.Body.Close()
|
||||
|
||||
jobID, ok := scanResponse["job_id"].(string)
|
||||
require.True(s.T(), ok, "job_id should be string")
|
||||
@@ -231,14 +234,14 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
if statusResp.StatusCode == http.StatusNotFound {
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
break
|
||||
}
|
||||
|
||||
var status map[string]interface{}
|
||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||
require.NoError(s.T(), err)
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
|
||||
if _, hasError := status["error"]; hasError {
|
||||
continue
|
||||
|
||||
@@ -388,6 +388,7 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
||||
query string
|
||||
libraryID string
|
||||
expectedCount int
|
||||
expectedStatus int
|
||||
shouldContain string // Comma-separated list of book IDs to check
|
||||
}{
|
||||
{
|
||||
@@ -412,10 +413,10 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
||||
shouldContain: book2ID,
|
||||
},
|
||||
{
|
||||
name: "invalid library_id",
|
||||
query: "Harry",
|
||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
||||
expectedCount: 0,
|
||||
name: "invalid library_id",
|
||||
query: "Harry",
|
||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -431,16 +432,18 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Log response for debugging
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("ERROR %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
if tt.expectedStatus != 0 {
|
||||
require.Equal(t, tt.expectedStatus, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.expectedCount > 0 {
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -483,11 +486,14 @@ func createLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, na
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -509,10 +515,13 @@ func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *Te
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
return result["id"].(string)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -30,7 +31,7 @@ func TestUnifiedSearch(t *testing.T) {
|
||||
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
folderResp, err := client.Do(folderHTTP)
|
||||
require.NoError(t, err)
|
||||
folderResp.Body.Close()
|
||||
_ = folderResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
|
||||
|
||||
// Helper to create book with fields
|
||||
@@ -54,7 +55,9 @@ func TestUnifiedSearch(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -90,14 +93,14 @@ func TestUnifiedSearch(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover")
|
||||
})
|
||||
|
||||
t.Run("Missing library_id", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil)
|
||||
t.Run("Missing library_id searches all libraries", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/media-items/search?q=zzzznonexistent", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
|
||||
// library_id is now optional - searches all libraries when omitted
|
||||
// Returns 404 when no results match the search query
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results found")
|
||||
// library_id is optional - searches all libraries when omitted
|
||||
// Returns 404 when no results match
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results match")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type SeriesIntegrationTestSuite struct {
|
||||
suite.Suite
|
||||
setup *TestServerSetup
|
||||
token string
|
||||
libraryID string
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) SetupSuite() {
|
||||
s.setup = setupTestServer(s.T())
|
||||
s.token = s.setup.Token
|
||||
s.libraryID = createTestLibraryWithFolder(s.T(), s.setup.Server, s.token, "Test Series Library", false)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TearDownSuite() {
|
||||
s.setup.Close()
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "library_id required", body["error"])
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id=not-a-uuid", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "invalid library_id", body["error"])
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_EmptyLibrary() {
|
||||
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
series, ok := body["series"].([]interface{})
|
||||
require.True(s.T(), ok, "series should be an array")
|
||||
assert.Empty(s.T(), series, "empty library should have no series")
|
||||
|
||||
total, ok := body["total"].(float64)
|
||||
require.True(s.T(), ok, "total should be a number")
|
||||
assert.Equal(s.T(), float64(0), total)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_Unauthorized() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_PaginationParams() {
|
||||
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=5&offset=0", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
limit, ok := body["limit"].(float64)
|
||||
require.True(s.T(), ok)
|
||||
assert.Equal(s.T(), float64(5), limit)
|
||||
|
||||
offset, ok := body["offset"].(float64)
|
||||
require.True(s.T(), ok)
|
||||
assert.Equal(s.T(), float64(0), offset)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
|
||||
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=999", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
limit, ok := body["limit"].(float64)
|
||||
require.True(s.T(), ok)
|
||||
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "library_id required", body["error"])
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
|
||||
url := fmt.Sprintf("%s/api/series/books?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "name required", body["error"])
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() {
|
||||
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=Nonexistent+Series", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
books, ok := body["books"].([]interface{})
|
||||
require.True(s.T(), ok, "books should be an array")
|
||||
assert.Empty(s.T(), books, "nonexistent series should return empty books array")
|
||||
|
||||
assert.Equal(s.T(), "Nonexistent Series", body["name"])
|
||||
total, ok := body["total"].(float64)
|
||||
require.True(s.T(), ok)
|
||||
assert.Equal(s.T(), float64(0), total)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_Unauthorized() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id="+s.libraryID+"&name=Test", nil)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_SpecialCharactersInName() {
|
||||
seriesName := "Series: Book & Other (Vol. 1)"
|
||||
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=%s", s.setup.Server.URL, s.libraryID, url.QueryEscape(seriesName))
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() {
|
||||
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
assert.Contains(s.T(), body, "series", "response should contain 'series' key")
|
||||
assert.Contains(s.T(), body, "total", "response should contain 'total' key")
|
||||
assert.Contains(s.T(), body, "limit", "response should contain 'limit' key")
|
||||
assert.Contains(s.T(), body, "offset", "response should contain 'offset' key")
|
||||
|
||||
_, ok := body["series"].([]interface{})
|
||||
assert.True(s.T(), ok, "'series' should be an array")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() {
|
||||
reqBody := map[string]interface{}{
|
||||
"collection_name": "Continue Series",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Contains(s.T(), response, "message")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
|
||||
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
sections, ok := response["sections"].([]interface{})
|
||||
require.True(s.T(), ok, "sections should be an array")
|
||||
require.Len(s.T(), sections, 5, "Should have 5 system collections")
|
||||
|
||||
sectionIDs := make(map[string]bool)
|
||||
for _, sec := range sections {
|
||||
section := sec.(map[string]interface{})
|
||||
sectionIDs[section["id"].(string)] = true
|
||||
}
|
||||
|
||||
assert.Contains(s.T(), sectionIDs, "continue-series", "dashboard should include continue-series section")
|
||||
assert.Contains(s.T(), sectionIDs, "continue-reading")
|
||||
assert.Contains(s.T(), sectionIDs, "recently-added")
|
||||
assert.Contains(s.T(), sectionIDs, "recently-read")
|
||||
assert.Contains(s.T(), sectionIDs, "not-started")
|
||||
}
|
||||
|
||||
func TestSeriesIntegrationTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(SeriesIntegrationTestSuite))
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const baseTestURL = "http://localhost:8765/api"
|
||||
@@ -36,12 +37,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
t.Logf("Cleanup: No existing test user to delete (server not available)")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// If login succeeds, try to delete the user
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
if token, ok := result["access_token"].(string); ok && token != "" {
|
||||
// Delete the user using the token
|
||||
@@ -52,7 +56,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
delResp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer delResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(delResp.Body)
|
||||
if delResp.StatusCode == http.StatusNoContent {
|
||||
t.Logf("Cleanup: Deleted existing test user")
|
||||
} else {
|
||||
@@ -66,10 +72,13 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
|
||||
listResp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer listResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(listResp.Body)
|
||||
if listResp.StatusCode == http.StatusOK {
|
||||
var libsResult map[string]interface{}
|
||||
json.NewDecoder(listResp.Body).Decode(&libsResult)
|
||||
err = json.NewDecoder(listResp.Body).Decode(&libsResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
if data, ok := libsResult["data"].([]interface{}); ok {
|
||||
for _, lib := range data {
|
||||
@@ -80,7 +89,7 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
delLibResp, _ := client.Do(req)
|
||||
if delLibResp != nil {
|
||||
delLibResp.Body.Close()
|
||||
_ = delLibResp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +117,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
body, _ := json.Marshal(userReq)
|
||||
resp, err := http.Post(baseTestURL+"/auth/register", "application/json", bytes.NewBuffer(body))
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Accept 201 (Created) or 409 (Conflict if already exists from previous incomplete test run)
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
|
||||
@@ -116,7 +127,8 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// If we got 409, the user already exists, so we need to login to get the token
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
@@ -128,10 +140,13 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
body, _ := json.Marshal(loginReq)
|
||||
resp2, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||||
assert.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
json.NewDecoder(resp2.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp2.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if result["user"] != nil {
|
||||
@@ -155,12 +170,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
body, _ := json.Marshal(loginReq)
|
||||
resp, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, ok := result["access_token"].(string)
|
||||
assert.True(t, ok, "Should have access_token")
|
||||
@@ -185,7 +203,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
@@ -225,12 +245,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, getUploadPath(), result["folder_path"])
|
||||
|
||||
@@ -251,13 +274,16 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Accept 200 or 202
|
||||
assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "success", result["status"])
|
||||
|
||||
@@ -276,12 +302,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, ok := result["data"].([]interface{})
|
||||
assert.True(t, ok, "Data field should exist")
|
||||
|
||||
@@ -36,7 +36,9 @@ func TestSevenDaySession(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -81,12 +83,15 @@ func TestSevenDaySession(t *testing.T) {
|
||||
|
||||
loginResp, err := http.DefaultClient.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken, ok := loginResult["refresh_token"].(string)
|
||||
require.True(t, ok, "Should have refresh_token")
|
||||
@@ -103,12 +108,15 @@ func TestSevenDaySession(t *testing.T) {
|
||||
|
||||
refreshResp, err := http.DefaultClient.Do(refreshHTTP)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||
|
||||
var refreshResult map[string]interface{}
|
||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify ExpiresIn is 7 days
|
||||
expiresIn, ok := refreshResult["expires_in"].(float64)
|
||||
@@ -134,7 +142,9 @@ func TestSevenDaySession(t *testing.T) {
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var authResponse struct {
|
||||
Token string `json:"access_token"`
|
||||
@@ -230,7 +240,7 @@ func TestNoClientSideCookies(t *testing.T) {
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Accept both 201 (new user) and 409 (already exists from previous run)
|
||||
require.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict,
|
||||
@@ -253,7 +263,9 @@ func TestNoClientSideCookies(t *testing.T) {
|
||||
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
body, err := io.ReadAll(resp2.Body)
|
||||
require.NoError(t, err)
|
||||
@@ -277,7 +289,9 @@ func TestNoClientSideCookies(t *testing.T) {
|
||||
|
||||
loginResp, err := http.DefaultClient.Do(loginReq)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM media_items WHERE title LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM devices WHERE device_name LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test%'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-sync%'")
|
||||
dbPool.Close()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -30,7 +31,7 @@ func TestTagsFilter(t *testing.T) {
|
||||
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
folderResp, err := client.Do(folderHTTP)
|
||||
require.NoError(t, err)
|
||||
folderResp.Body.Close()
|
||||
_ = folderResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
|
||||
|
||||
// Helper to create book with tags
|
||||
@@ -50,7 +51,9 @@ func TestTagsFilter(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "Should create book")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
@@ -98,11 +99,14 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(libHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "Library creation should succeed")
|
||||
|
||||
var libResponse map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&libResponse)
|
||||
err = json.NewDecoder(resp.Body).Decode(&libResponse)
|
||||
require.NoError(t, err)
|
||||
libraryID := libResponse["id"].(string)
|
||||
|
||||
if withFolder {
|
||||
@@ -117,7 +121,9 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
||||
|
||||
folderResp, err := client.Do(folderHTTP)
|
||||
require.NoError(t, err)
|
||||
defer folderResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(folderResp.Body)
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder creation should succeed")
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -76,19 +77,21 @@ type DeviceTestData struct {
|
||||
|
||||
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
|
||||
type TestServerSetup struct {
|
||||
Server *httptest.Server
|
||||
DB *database.Queries
|
||||
DBPool *pgxpool.Pool
|
||||
Config *config.Config
|
||||
ConnManager *wsync.ConnectionManager
|
||||
QueueProcessor *wsync.SyncQueueProcessor
|
||||
CleanupCancel context.CancelFunc
|
||||
QueueCtx context.Context
|
||||
QueueCancel context.CancelFunc
|
||||
Token string
|
||||
RegularToken string
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
Server *httptest.Server
|
||||
DB *database.Queries
|
||||
DBPool *pgxpool.Pool
|
||||
Config *config.Config
|
||||
ConnManager *wsync.ConnectionManager
|
||||
QueueProcessor *wsync.SyncQueueProcessor
|
||||
ProgressService *wsync.ProgressService
|
||||
AnnotationService *wsync.AnnotationService
|
||||
CleanupCancel context.CancelFunc
|
||||
QueueCtx context.Context
|
||||
QueueCancel context.CancelFunc
|
||||
Token string
|
||||
RegularToken string
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Close cleans up all resources in the correct order
|
||||
@@ -284,6 +287,7 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
|
||||
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
{"continue-series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
@@ -315,12 +319,15 @@ func loginUserWithCredentials(t *testing.T, ts *httptest.Server, email, password
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "Failed to login")
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, ok := result["access_token"].(string)
|
||||
require.True(t, ok, "Should have access_token")
|
||||
@@ -448,17 +455,23 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
connManager := wsync.NewConnectionManager()
|
||||
cleanupCancel := connManager.StartCleanupTask()
|
||||
|
||||
// Create sync queue processor with cancellable context
|
||||
progressService := wsync.NewProgressService(queries, connManager)
|
||||
annotationService := wsync.NewAnnotationService(queries, connManager)
|
||||
|
||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
queueCtx, queueCancel := context.WithCancel(context.Background())
|
||||
go queueProcessor.Start(queueCtx)
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
koreaderHandler.SetProgressService(progressService)
|
||||
koreaderHandler.SetAnnotationService(annotationService)
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||
|
||||
// Create refactored handlers (matching main.go)
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
@@ -469,7 +482,10 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
dashboardService := services.NewDashboardService(queries)
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
mediaHandler.SetAnnotationService(annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
|
||||
// Create conversion service for OPDS
|
||||
@@ -514,14 +530,18 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
AnalyticsHandler: analyticsHandler,
|
||||
QueueHandler: queueHandler,
|
||||
SystemSettingsHandler: systemSettingsHandler,
|
||||
ProcessingIssuesHandler: processingIssuesHandler,
|
||||
CollectionHandler: collectionHandler,
|
||||
FiltersHandler: filtersHandler,
|
||||
DashboardHandler: dashboardHandler,
|
||||
DashboardService: dashboardService,
|
||||
SeriesHandler: seriesHandler,
|
||||
OPDSHandler: opdsHandler,
|
||||
JobsHandler: jobsHandler,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
}
|
||||
@@ -543,11 +563,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Delete ALL test users (any user with test email domains) to ensure clean state
|
||||
// This handles users created during tests that may have been promoted to admin, etc.
|
||||
// Delete transient test users but preserve the dev admin user
|
||||
// testuser@tests.bookhoard.internal is the shared dev admin — deleting it
|
||||
// triggers ON DELETE SET NULL on libraries.created_by_admin_id
|
||||
allUsers, err := queries.ListUsers(ctx)
|
||||
if err == nil {
|
||||
for _, user := range allUsers {
|
||||
if user.Email == "testuser@tests.bookhoard.internal" {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") {
|
||||
queries.DeleteUser(ctx, user.ID)
|
||||
}
|
||||
@@ -601,17 +625,18 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
// Create TestServerSetup struct with all resources
|
||||
setup := &TestServerSetup{
|
||||
Server: ts,
|
||||
DB: queries,
|
||||
DBPool: dbPool,
|
||||
Config: cfg,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
CleanupCancel: cleanupCancel,
|
||||
QueueCtx: queueCtx,
|
||||
QueueCancel: queueCancel,
|
||||
Token: adminToken,
|
||||
RegularToken: regularToken,
|
||||
Server: ts,
|
||||
DB: queries,
|
||||
DBPool: dbPool,
|
||||
Config: cfg,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
CleanupCancel: cleanupCancel,
|
||||
QueueCtx: queueCtx,
|
||||
QueueCancel: queueCancel,
|
||||
Token: adminToken,
|
||||
RegularToken: regularToken,
|
||||
}
|
||||
|
||||
// Register cleanup function to run automatically when test completes
|
||||
@@ -648,12 +673,15 @@ func loginWithCredentials(t *testing.T, ts *httptest.Server, email, password str
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "Failed to login")
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, ok := result["access_token"].(string)
|
||||
require.True(t, ok, "Should have access_token")
|
||||
@@ -698,12 +726,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var libResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&libResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&libResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
libData := libResult["id"].(string)
|
||||
|
||||
@@ -718,7 +749,9 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
||||
|
||||
folderResp, err := httpClient.Do(folderReqHTTP)
|
||||
require.NoError(t, err)
|
||||
defer folderResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(folderResp.Body)
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
|
||||
|
||||
mediaItemReq := map[string]interface{}{
|
||||
@@ -737,12 +770,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
||||
|
||||
resp2, err := httpClient.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
||||
|
||||
var mediaItemResult map[string]interface{}
|
||||
json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
|
||||
err = json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
mediaItemID := mediaItemResult["id"].(string)
|
||||
|
||||
@@ -771,6 +807,8 @@ func addFolderToLibrary(t *testing.T, setup *TestServerSetup, libraryID string,
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -83,7 +84,9 @@ func TestWebSocketDeviceAuth(t *testing.T) {
|
||||
require.NoError(t, err, "WebSocket connection with device token should succeed")
|
||||
defer ws.Close()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode, "Should upgrade to WebSocket")
|
||||
}
|
||||
// Read initial state message
|
||||
@@ -116,26 +119,23 @@ func TestWebSocketProgressBroadcast(t *testing.T) {
|
||||
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, _, _ = ws.ReadMessage()
|
||||
|
||||
// Update progress via HTTP API
|
||||
// Update progress via HTTP API to new media-item progress endpoint
|
||||
progressReq := map[string]interface{}{
|
||||
"source": "test",
|
||||
"location": map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
},
|
||||
"device_metadata": map[string]interface{}{
|
||||
"device_type": "web",
|
||||
},
|
||||
"percentage": 0.5,
|
||||
"epubcfi": "epubcfi(/6/4/2:10)",
|
||||
}
|
||||
body, _ := json.Marshal(progressReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/progress/"+mediaID, strings.NewReader(string(body)))
|
||||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+mediaID+"/progress", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -253,11 +253,14 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
||||
|
||||
collectionResp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer collectionResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(collectionResp.Body)
|
||||
require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Create a test book via API
|
||||
@@ -289,7 +292,9 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
||||
|
||||
addResp, err := client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer addResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(addResp.Body)
|
||||
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||
|
||||
// Admin should receive collection_updated message
|
||||
@@ -313,7 +318,7 @@ func connectWebSocketToServer(t *testing.T, serverURL string, token string) *web
|
||||
ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
require.NoError(t, err, "WebSocket connection should succeed")
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
require.NotNil(t, ws, "WebSocket connection should be established")
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
|
||||
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
||||
|
||||
var createLibResponse map[string]interface{}
|
||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
createLibResp.Body.Close()
|
||||
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
require.NoError(t, err)
|
||||
_ = createLibResp.Body.Close()
|
||||
|
||||
libraryID, ok := createLibResponse["id"].(string)
|
||||
require.True(t, ok)
|
||||
@@ -62,7 +63,7 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
// Create test files in the directory
|
||||
@@ -141,8 +142,9 @@ func TestWorker_SetFoldersJob(t *testing.T) {
|
||||
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
||||
|
||||
var createLibResponse map[string]interface{}
|
||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
createLibResp.Body.Close()
|
||||
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
require.NoError(t, err)
|
||||
_ = createLibResp.Body.Close()
|
||||
|
||||
libraryID, ok := createLibResponse["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS library_types (
|
||||
INSERT INTO library_types (name, description, allowed_extensions) VALUES
|
||||
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
|
||||
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
||||
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'])
|
||||
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif'])
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Create users table
|
||||
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
||||
max_devices INTEGER DEFAULT 10,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -47,7 +48,8 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
||||
-- Insert default system settings
|
||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide')
|
||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
|
||||
('default_timezone', 'UTC', 'System default timezone')
|
||||
ON CONFLICT (setting_key) DO NOTHING;
|
||||
|
||||
-- Create refresh_tokens table
|
||||
@@ -121,6 +123,7 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
google_books_id VARCHAR(100), -- Google Books identifier
|
||||
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
imported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
-- Universal Sync Format Detection
|
||||
format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
|
||||
@@ -242,6 +245,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
||||
character_offset BIGINT,
|
||||
epubcfi TEXT,
|
||||
context_text TEXT,
|
||||
chapter INTEGER,
|
||||
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
||||
viewport_x FLOAT DEFAULT 0,
|
||||
@@ -457,6 +461,7 @@ CREATE TABLE IF NOT EXISTS reading_history (
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
|
||||
CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
@@ -933,6 +938,7 @@ BEGIN
|
||||
percentage = (book_record->>'percentage')::FLOAT,
|
||||
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
||||
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
||||
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
|
||||
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
||||
chapter_progress = (book_record->>'percentage')::FLOAT,
|
||||
last_sync_device = 'koreader',
|
||||
@@ -951,6 +957,7 @@ BEGIN
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
context_text,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
last_sync_device,
|
||||
@@ -967,6 +974,7 @@ BEGIN
|
||||
(book_record->>'percentage')::FLOAT,
|
||||
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
||||
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
||||
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
|
||||
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
||||
(book_record->>'percentage')::FLOAT,
|
||||
'koreader',
|
||||
@@ -1304,3 +1312,48 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC MIGRATIONS
|
||||
-- Adds dedup_key, LWW timestamps, soft-delete,
|
||||
-- and device_sync_data to annotation tables.
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
||||
ON media_highlights (user_id, media_item_id, dedup_key)
|
||||
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
|
||||
ON media_notes (user_id, media_item_id, dedup_key)
|
||||
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
|
||||
ON media_bookmarks (user_id, media_item_id, dedup_key)
|
||||
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Development override — merged on top of docker-compose.yml (the base/prod file).
|
||||
# Activated by all `make` targets via:
|
||||
# COMPOSE = <runtime> compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||
#
|
||||
# What this adds over prod:
|
||||
# - Local image BUILDING (prod pulls a prebuilt image from the registry)
|
||||
# - The integration-tests service (dev only, gated behind the "tests" profile)
|
||||
# Everything else (env vars, volumes, ports, healthchecks) is inherited from the base file.
|
||||
services:
|
||||
# Build the app image locally instead of pulling from the registry
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
|
||||
# Integration Tests - runs against containerized app and db (dev only)
|
||||
tests:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: test-runner
|
||||
container_name: bookhoard_tests
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
COOKIE_SECURE: false
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||
|
||||
# Test Configuration
|
||||
TEST_MODE: "true"
|
||||
RATE_LIMIT_ENABLED: "false"
|
||||
REQUESTS_PER_MINUTE: 1000
|
||||
|
||||
# Conversion Service Configuration
|
||||
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
||||
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
||||
|
||||
# Test upload path (inside container)
|
||||
TEST_UPLOAD_PATH: /app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
app:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
profiles:
|
||||
- tests
|
||||
+19
-57
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
@@ -9,44 +7,48 @@ services:
|
||||
POSTGRES_DB: bookhoard
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DBPASS}
|
||||
COOKIE_SECURE: false # make true in production with HTTPS
|
||||
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
||||
PGPORT: ${DB_PORT:-5432}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/schema:/docker-entrypoint-initdb.d
|
||||
# Make other volumes as needed
|
||||
- ./uploads:/app/uploads
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# Bookhoard Application
|
||||
# In production this image is pulled from the Gitea container registry.
|
||||
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||
container_name: bookhoard
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8765
|
||||
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||
# IMPORTANT: Device sync requires full URL with protocol
|
||||
# Local: http://localhost:8765
|
||||
# Local network: http://192.168.1.X:8765
|
||||
# Domain: https://bookhoard.example.com
|
||||
BASE_URL: http://localhost:${SERVER_PORT}
|
||||
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
||||
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
|
||||
# Rate Limiting Configuration
|
||||
TEST_MODE: ${TEST_MODE:-false}
|
||||
@@ -57,8 +59,11 @@ services:
|
||||
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||
|
||||
# System timezone (fallback for server-side time operations)
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
- "8765:8765"
|
||||
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -66,55 +71,12 @@ services:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
|
||||
interval: 10s
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Integration Tests - runs against containerized app and db
|
||||
tests:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: test-runner
|
||||
container_name: bookhoard_tests
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
COOKIE_SECURE: false
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8765
|
||||
|
||||
# Test Configuration
|
||||
TEST_MODE: "true"
|
||||
RATE_LIMIT_ENABLED: "false"
|
||||
REQUESTS_PER_MINUTE: 1000
|
||||
|
||||
# Conversion Service Configuration
|
||||
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
||||
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
||||
|
||||
# Test upload path (inside container)
|
||||
TEST_UPLOAD_PATH: /app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
app:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
profiles:
|
||||
- tests
|
||||
|
||||
# Named Volumes
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -4,20 +4,20 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/ArcadiaLin/go-epub v0.1.1
|
||||
github.com/a-h/templ v0.3.1001
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/andybalholm/brotli v1.2.1
|
||||
github.com/bodgit/plumbing v1.3.0
|
||||
github.com/bodgit/windows v1.0.1
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/klauspost/compress v1.18.5
|
||||
github.com/labstack/echo-jwt/v5 v5.0.1
|
||||
github.com/labstack/echo/v5 v5.0.4
|
||||
github.com/labstack/echo/v5 v5.1.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/nwaples/rardecode v1.1.3
|
||||
github.com/pdfcpu/pdfcpu v0.11.1
|
||||
@@ -26,10 +26,10 @@ require (
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/yuin/goldmark v1.7.17
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/text v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -37,7 +37,7 @@ require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -45,21 +45,21 @@ require (
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||
github.com/hhrutter/tiff v1.0.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/xyproto/randomstring v1.2.0 // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
|
||||
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
|
||||
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -32,8 +32,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -50,14 +50,14 @@ github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
|
||||
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
||||
github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0=
|
||||
github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
@@ -68,12 +68,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo-jwt/v5 v5.0.1 h1:uIpCHCiDPN3jA8Jb47i4EViToUl1uypMiPvVAAgKpIw=
|
||||
github.com/labstack/echo-jwt/v5 v5.0.1/go.mod h1:kcHmJPzrVSEJa1FRheVoi9EJrBLLUqr1ntlil6uPe1Q=
|
||||
github.com/labstack/echo/v5 v5.0.4 h1:ll3I/O8BifjMztj9dD1vx/peZQv8cR2CTUdQK6QxGGc=
|
||||
github.com/labstack/echo/v5 v5.0.4/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/labstack/echo/v5 v5.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrBI=
|
||||
github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
@@ -110,22 +110,22 @@ github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o
|
||||
github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
|
||||
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
|
||||
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
|
||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg=
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -40,7 +41,7 @@ func (a *App) StartServer(addr string) error {
|
||||
|
||||
// Start HTTP server in background
|
||||
go func() {
|
||||
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
if err := a.server.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -55,6 +55,9 @@ func BenchmarkApp_Shutdown(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
app := New(e)
|
||||
app.Shutdown()
|
||||
err := app.Shutdown()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-31
@@ -155,40 +155,55 @@ type LibraryVisibility struct {
|
||||
}
|
||||
|
||||
type MediaBookmarks struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
||||
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
||||
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Position pgtype.Text `db:"position" json:"position"`
|
||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
||||
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
||||
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Position pgtype.Text `db:"position" json:"position"`
|
||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
||||
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaHighlights struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
SelectionText string `db:"selection_text" json:"selection_text"`
|
||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
||||
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
||||
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
||||
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
SelectionText string `db:"selection_text" json:"selection_text"`
|
||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
||||
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
||||
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
||||
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
NoteText pgtype.Text `db:"note_text" json:"note_text"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaItemFormats struct {
|
||||
@@ -239,6 +254,7 @@ type MediaItems struct {
|
||||
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
|
||||
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
ImportedAt pgtype.Timestamptz `db:"imported_at" json:"imported_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
FormatGroup string `db:"format_group" json:"format_group"`
|
||||
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
|
||||
@@ -303,6 +319,11 @@ type MediaNotes struct {
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaRatings struct {
|
||||
@@ -379,6 +400,7 @@ type ReadingProgress struct {
|
||||
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
||||
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
||||
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
||||
ContextText pgtype.Text `db:"context_text" json:"context_text"`
|
||||
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
||||
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
||||
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
||||
@@ -507,5 +529,6 @@ type Users struct {
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -23,15 +23,18 @@ type Querier interface {
|
||||
// Bulk update format group for all media items
|
||||
BulkUpdateFormatGroups(ctx context.Context) error
|
||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
||||
// Cleanup expired OPDS tokens
|
||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||
CleanupExpiredRefreshTokens(ctx context.Context) error
|
||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
// Count unlinked books for a device
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||
// COLLECTIONS QUERIES
|
||||
// Create collection
|
||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
||||
@@ -53,8 +56,10 @@ type Querier interface {
|
||||
// Libraries queries
|
||||
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
||||
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
||||
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
|
||||
// Media Highlights queries
|
||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||
// Media Items queries
|
||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||
// MEDIA ITEM FORMATS QUERIES
|
||||
@@ -62,6 +67,7 @@ type Querier interface {
|
||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||
// Media Notes queries
|
||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
||||
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
||||
// OPDS TOKENS QUERIES
|
||||
// Create OPDS token
|
||||
@@ -124,10 +130,15 @@ type Querier interface {
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||
// ============================================
|
||||
// ANNOTATION SERVE QUERIES
|
||||
// ============================================
|
||||
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
|
||||
// Get all system config
|
||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||
// Get collection
|
||||
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
||||
// Get collection items
|
||||
@@ -141,6 +152,7 @@ type Querier interface {
|
||||
GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error)
|
||||
// Smart section queries (for system collections)
|
||||
GetContinueReadingItems(ctx context.Context, arg GetContinueReadingItemsParams) ([]MediaItems, error)
|
||||
GetContinueSeriesItems(ctx context.Context, arg GetContinueSeriesItemsParams) ([]GetContinueSeriesItemsRow, error)
|
||||
// ============================================
|
||||
// CAROUSEL-STYLE DASHBOARD
|
||||
// ============================================
|
||||
@@ -166,7 +178,11 @@ type Querier interface {
|
||||
// Get device shelf mappings
|
||||
GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error)
|
||||
GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, error)
|
||||
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
||||
GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error)
|
||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
|
||||
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
||||
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
||||
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
|
||||
@@ -177,6 +193,7 @@ type Querier interface {
|
||||
GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error)
|
||||
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
|
||||
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
|
||||
GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error)
|
||||
GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
|
||||
GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([]MediaItems, error)
|
||||
GetLibraryType(ctx context.Context, id pgtype.UUID) (LibraryTypes, error)
|
||||
@@ -188,8 +205,17 @@ type Querier interface {
|
||||
// LIBRARY WITH TYPE INFO QUERIES
|
||||
// ============================================================================
|
||||
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
|
||||
GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (bookmarks)
|
||||
// ============================================
|
||||
GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error)
|
||||
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
|
||||
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (highlights)
|
||||
// ============================================
|
||||
GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error)
|
||||
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
|
||||
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
||||
@@ -212,6 +238,10 @@ type Querier interface {
|
||||
// Get media item formats
|
||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (notes)
|
||||
// ============================================
|
||||
GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error)
|
||||
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||
@@ -235,6 +265,8 @@ type Querier interface {
|
||||
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
||||
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
|
||||
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
|
||||
GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]MediaItems, error)
|
||||
GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error)
|
||||
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
|
||||
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
|
||||
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
|
||||
@@ -246,6 +278,8 @@ type Querier interface {
|
||||
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
|
||||
// System Settings queries
|
||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||
GetSystemTimezone(ctx context.Context) (string, error)
|
||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||
// Get universal progress for a book
|
||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||
// Get unlinked book by ContentId
|
||||
@@ -269,13 +303,16 @@ type Querier interface {
|
||||
// Get user reading history for analytics
|
||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||
// Check if book is in collection
|
||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||
IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error)
|
||||
// Link unlinked book to media item
|
||||
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
||||
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
||||
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
||||
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||
@@ -289,8 +326,13 @@ type Querier interface {
|
||||
// List unresolved unlinked books with pagination
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
// Query media items by multiple identifiers with confidence scoring
|
||||
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
||||
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
||||
ReassignMediaItems(ctx context.Context, arg ReassignMediaItemsParams) error
|
||||
// Remove book from collection
|
||||
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
||||
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
||||
@@ -316,6 +358,13 @@ type Querier interface {
|
||||
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||
// Set system config
|
||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
|
||||
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
|
||||
// Update collection
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
@@ -339,7 +388,9 @@ type Querier interface {
|
||||
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
||||
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
|
||||
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
|
||||
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
||||
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||
// Update media item format
|
||||
@@ -357,6 +408,7 @@ type Querier interface {
|
||||
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
||||
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
||||
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
|
||||
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||
@@ -370,6 +422,7 @@ type Querier interface {
|
||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||
|
||||
+2009
-86
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ SELECT
|
||||
u.max_devices,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.timezone,
|
||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||
FROM users u
|
||||
WHERE u.id = $1;
|
||||
@@ -60,6 +61,9 @@ SELECT * FROM library_types WHERE id = $1;
|
||||
-- name: GetLibraryTypeByName :one
|
||||
SELECT * FROM library_types WHERE name = $1;
|
||||
|
||||
-- name: SyncLibraryTypeExtensions :exec
|
||||
UPDATE library_types SET allowed_extensions = $2 WHERE name = $1;
|
||||
|
||||
-- Libraries queries
|
||||
-- name: CreateLibrary :one
|
||||
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
|
||||
@@ -104,6 +108,13 @@ SELECT lf.library_id, l.* FROM library_folders lf
|
||||
JOIN libraries l ON lf.library_id = l.id
|
||||
WHERE lf.folder_path = $1;
|
||||
|
||||
-- name: GetLibraryByFolderPathPrefix :one
|
||||
SELECT lf.library_id, lf.folder_path, l.* FROM library_folders lf
|
||||
JOIN libraries l ON lf.library_id = l.id
|
||||
WHERE $1 LIKE lf.folder_path || '%'
|
||||
ORDER BY LENGTH(lf.folder_path) DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- Library Visibility queries
|
||||
-- name: SetLibraryVisibility :one
|
||||
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
||||
@@ -128,8 +139,8 @@ ORDER BY l.created_at ASC;
|
||||
|
||||
-- Media Items queries
|
||||
-- name: CreateMediaItem :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43)
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetMediaItem :one
|
||||
@@ -248,6 +259,20 @@ UPDATE media_items SET
|
||||
goodreads_id = $21,
|
||||
openlibrary_id = $22,
|
||||
google_books_id = $23,
|
||||
manga_type = $24,
|
||||
reading_direction = $25,
|
||||
series_count = $26,
|
||||
volume = $27,
|
||||
imprint = $28,
|
||||
age_rating = $29,
|
||||
web_url = $30,
|
||||
metadata_notes = $31,
|
||||
community_rating = $32,
|
||||
story_arc = $33,
|
||||
is_black_and_white = $34,
|
||||
alternate_info = $35,
|
||||
scan_information = $36,
|
||||
summary = $37,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
@@ -300,9 +325,36 @@ RETURNING *;
|
||||
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
|
||||
RETURNING id, email, username, role;
|
||||
|
||||
-- name: UpdateUserTimezone :exec
|
||||
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: GetSystemTimezone :one
|
||||
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
||||
|
||||
-- name: CountUserDevices :one
|
||||
SELECT COUNT(*) FROM devices WHERE user_id = $1;
|
||||
|
||||
-- name: GetFirstAdminExclude :one
|
||||
SELECT id, email, username, theme, first_name, last_name, role, max_devices, created_at, updated_at FROM users
|
||||
WHERE role = 'admin' AND id != $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetFirstAdmin :one
|
||||
SELECT id FROM users
|
||||
WHERE role = 'admin'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CountAdmins :one
|
||||
SELECT COUNT(*) FROM users WHERE role = 'admin';
|
||||
|
||||
-- name: ReassignLibraries :exec
|
||||
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
||||
|
||||
-- name: ReassignMediaItems :exec
|
||||
UPDATE media_items SET added_by_admin_id = $2 WHERE added_by_admin_id = $1;
|
||||
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1;
|
||||
|
||||
@@ -644,7 +696,7 @@ RETURNING *;
|
||||
SELECT * FROM media_notes WHERE id = $1;
|
||||
|
||||
-- name: GetMediaNotes :many
|
||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateMediaNote :one
|
||||
UPDATE media_notes SET
|
||||
@@ -667,7 +719,7 @@ RETURNING *;
|
||||
SELECT * FROM media_highlights WHERE id = $1;
|
||||
|
||||
-- name: GetMediaHighlights :many
|
||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateMediaHighlight :one
|
||||
UPDATE media_highlights SET
|
||||
@@ -683,6 +735,251 @@ RETURNING *;
|
||||
-- name: DeleteMediaHighlight :exec
|
||||
DELETE FROM media_highlights WHERE id = $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC QUERIES (highlights)
|
||||
-- ============================================
|
||||
|
||||
-- name: GetMediaHighlightByDedupKey :one
|
||||
SELECT * FROM media_highlights
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CreateMediaHighlightFull :one
|
||||
INSERT INTO media_highlights (
|
||||
media_item_id, user_id, selection_text,
|
||||
start_position, end_position, color, note_text,
|
||||
percentage_start, percentage_end,
|
||||
epubcfi_start, epubcfi_end,
|
||||
chapter_reference,
|
||||
dedup_key, last_modified_at, last_modified_source,
|
||||
device_sync_data
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateMediaHighlightForSync :one
|
||||
UPDATE media_highlights SET
|
||||
selection_text = $2,
|
||||
start_position = $3,
|
||||
end_position = $4,
|
||||
color = $5,
|
||||
note_text = $6,
|
||||
percentage_start = $7,
|
||||
percentage_end = $8,
|
||||
epubcfi_start = $9,
|
||||
epubcfi_end = $10,
|
||||
chapter_reference = $11,
|
||||
last_modified_at = $12,
|
||||
last_modified_source = $13,
|
||||
device_sync_data = $14,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaHighlightByDedupKey :exec
|
||||
UPDATE media_highlights SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||
|
||||
-- name: TombstoneMediaHighlightByID :exec
|
||||
UPDATE media_highlights SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: PurgeExpiredHighlightTombstones :exec
|
||||
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC QUERIES (notes)
|
||||
-- ============================================
|
||||
|
||||
-- name: GetMediaNoteByDedupKey :one
|
||||
SELECT * FROM media_notes
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CreateMediaNoteFull :one
|
||||
INSERT INTO media_notes (
|
||||
media_item_id, user_id, content, position,
|
||||
percentage_location, character_start, character_end,
|
||||
epubcfi_location, chapter_reference, paragraph_reference,
|
||||
dedup_key, last_modified_at, last_modified_source,
|
||||
device_sync_data
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateMediaNoteForSync :one
|
||||
UPDATE media_notes SET
|
||||
content = $2,
|
||||
position = $3,
|
||||
percentage_location = $4,
|
||||
character_start = $5,
|
||||
character_end = $6,
|
||||
epubcfi_location = $7,
|
||||
chapter_reference = $8,
|
||||
paragraph_reference = $9,
|
||||
last_modified_at = $10,
|
||||
last_modified_source = $11,
|
||||
device_sync_data = $12,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaNoteByDedupKey :exec
|
||||
UPDATE media_notes SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||
|
||||
-- name: TombstoneMediaNoteByID :exec
|
||||
UPDATE media_notes SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: PurgeExpiredNoteTombstones :exec
|
||||
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC QUERIES (bookmarks)
|
||||
-- ============================================
|
||||
|
||||
-- name: GetMediaBookmarkByDedupKey :one
|
||||
SELECT * FROM media_bookmarks
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CreateMediaBookmarkFull :one
|
||||
INSERT INTO media_bookmarks (
|
||||
media_item_id, user_id, page_number, chapter_number,
|
||||
cfi_position, title, position, notes,
|
||||
percentage_location, epubcfi_location, chapter_reference,
|
||||
dedup_key, last_modified_at, last_modified_source,
|
||||
device_sync_data
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateMediaBookmarkForSync :one
|
||||
UPDATE media_bookmarks SET
|
||||
page_number = $2,
|
||||
chapter_number = $3,
|
||||
cfi_position = $4,
|
||||
title = $5,
|
||||
position = $6,
|
||||
notes = $7,
|
||||
percentage_location = $8,
|
||||
epubcfi_location = $9,
|
||||
chapter_reference = $10,
|
||||
last_modified_at = $11,
|
||||
last_modified_source = $12,
|
||||
device_sync_data = $13,
|
||||
created_at = created_at
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaBookmarkByDedupKey :exec
|
||||
UPDATE media_bookmarks SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||
|
||||
-- name: TombstoneMediaBookmarkByID :exec
|
||||
UPDATE media_bookmarks SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: PurgeExpiredBookmarkTombstones :exec
|
||||
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SERVE QUERIES
|
||||
-- ============================================
|
||||
|
||||
-- name: GetActiveAnnotationsForBook :many
|
||||
SELECT
|
||||
mh.id,
|
||||
mh.selection_text,
|
||||
mh.start_position,
|
||||
mh.end_position,
|
||||
mh.color,
|
||||
mh.created_at,
|
||||
mh.updated_at,
|
||||
'highlight' as annotation_type,
|
||||
mh.percentage_start,
|
||||
mh.percentage_end,
|
||||
mh.epubcfi_start,
|
||||
mh.epubcfi_end,
|
||||
mh.note_text,
|
||||
mh.dedup_key,
|
||||
mh.last_modified_at,
|
||||
mh.last_modified_source
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
mn.content,
|
||||
mn.position,
|
||||
NULL as end_position,
|
||||
NULL as color,
|
||||
mn.created_at,
|
||||
mn.updated_at,
|
||||
'note' as annotation_type,
|
||||
mn.percentage_location as percentage_start,
|
||||
NULL as percentage_end,
|
||||
mn.epubcfi_location as epubcfi_start,
|
||||
NULL as epubcfi_end,
|
||||
NULL as note_text,
|
||||
mn.dedup_key,
|
||||
mn.last_modified_at,
|
||||
mn.last_modified_source
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetTombstonedAnnotationsForBook :many
|
||||
SELECT
|
||||
mh.id,
|
||||
mh.dedup_key,
|
||||
'highlight' as annotation_type,
|
||||
mh.device_sync_data,
|
||||
mh.deleted_at
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
mn.dedup_key,
|
||||
'note' as annotation_type,
|
||||
mn.device_sync_data,
|
||||
mn.deleted_at
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||
UNION ALL
|
||||
SELECT
|
||||
mb.id,
|
||||
mb.dedup_key,
|
||||
'bookmark' as annotation_type,
|
||||
mb.device_sync_data,
|
||||
mb.deleted_at
|
||||
FROM media_bookmarks mb
|
||||
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
||||
ORDER BY deleted_at DESC;
|
||||
|
||||
-- Refresh Tokens queries
|
||||
-- name: CreateRefreshToken :one
|
||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||
@@ -744,6 +1041,7 @@ SELECT
|
||||
rp.percentage,
|
||||
rp.character_offset,
|
||||
rp.epubcfi,
|
||||
rp.context_text,
|
||||
rp.chapter,
|
||||
rp.chapter_progress,
|
||||
rp.viewport_x,
|
||||
@@ -776,6 +1074,7 @@ INSERT INTO reading_progress (
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
context_text,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
viewport_x,
|
||||
@@ -793,13 +1092,14 @@ INSERT INTO reading_progress (
|
||||
last_read_at
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
|
||||
)
|
||||
ON CONFLICT (media_item_id, user_id)
|
||||
DO UPDATE SET
|
||||
percentage = EXCLUDED.percentage,
|
||||
character_offset = EXCLUDED.character_offset,
|
||||
epubcfi = EXCLUDED.epubcfi,
|
||||
context_text = EXCLUDED.context_text,
|
||||
chapter = EXCLUDED.chapter,
|
||||
chapter_progress = EXCLUDED.chapter_progress,
|
||||
viewport_x = EXCLUDED.viewport_x,
|
||||
@@ -1071,6 +1371,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateAutoResolvedSyncConflict :one
|
||||
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
|
||||
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSyncConflict :one
|
||||
SELECT * FROM sync_conflicts WHERE id = $1;
|
||||
|
||||
@@ -1099,19 +1404,28 @@ RETURNING *;
|
||||
-- name: DeleteSyncConflict :exec
|
||||
DELETE FROM sync_conflicts WHERE id = $1;
|
||||
|
||||
-- name: ListAllConflictsByUserAndStatus :many
|
||||
SELECT sc.*, mi.title, mi.author
|
||||
FROM sync_conflicts sc
|
||||
JOIN media_items mi ON sc.media_item_id = mi.id
|
||||
WHERE sc.user_id = $1 AND sc.resolution_status = $2
|
||||
ORDER BY sc.created_at DESC;
|
||||
-- name: ListAllConflictsByUserAndStatus :many
|
||||
SELECT sc.*, mi.title, mi.author
|
||||
FROM sync_conflicts sc
|
||||
JOIN media_items mi ON sc.media_item_id = mi.id
|
||||
WHERE sc.user_id = $1 AND sc.resolution_status = $2
|
||||
ORDER BY sc.created_at DESC;
|
||||
|
||||
-- name: ListConflictsByUser :many
|
||||
SELECT sc.*, mi.title, mi.author
|
||||
FROM sync_conflicts sc
|
||||
JOIN media_items mi ON sc.media_item_id = mi.id
|
||||
WHERE sc.user_id = $1
|
||||
ORDER BY sc.created_at DESC;
|
||||
-- name: ListConflictsByUser :many
|
||||
SELECT sc.*, mi.title, mi.author
|
||||
FROM sync_conflicts sc
|
||||
JOIN media_items mi ON sc.media_item_id = mi.id
|
||||
WHERE sc.user_id = $1
|
||||
ORDER BY sc.created_at DESC;
|
||||
|
||||
-- name: HasRecentConflictResolution :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM sync_conflicts
|
||||
WHERE media_item_id = $1
|
||||
AND user_id = $2
|
||||
AND resolution_status != 'unresolved'
|
||||
AND resolved_at > NOW() - INTERVAL '10 minutes'
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- KOREADER SYNC PROTOCOL
|
||||
@@ -1161,7 +1475,7 @@ SELECT
|
||||
mh.epubcfi_start,
|
||||
mh.epubcfi_end
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
@@ -1177,7 +1491,7 @@ SELECT
|
||||
mn.epubcfi_location as epubcfi_start,
|
||||
NULL as epubcfi_end
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateDeviceSyncTimestamp :one
|
||||
@@ -1216,7 +1530,7 @@ WHERE lv.user_id = $1
|
||||
ORDER BY mi.title ASC
|
||||
LIMIT 1000;
|
||||
|
||||
-- name: CheckForProgressConflicts :one
|
||||
-- name: CheckForProgressConflicts :one
|
||||
SELECT COUNT(*) as conflict_count
|
||||
FROM reading_progress
|
||||
WHERE media_item_id = $1
|
||||
@@ -1781,7 +2095,7 @@ LIMIT $1 OFFSET $2;
|
||||
-- Dashboard preferences queries
|
||||
-- name: GetDashboardPreferences :one
|
||||
SELECT * FROM user_dashboard_preferences
|
||||
WHERE user_id = $1 AND library_id = $2;
|
||||
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
|
||||
|
||||
-- name: UpsertDashboardPreferences :one
|
||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||||
@@ -1845,59 +2159,112 @@ SELECT mi.* FROM media_items mi
|
||||
INNER JOIN (
|
||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||
FROM reading_progress
|
||||
WHERE user_id = $2
|
||||
WHERE user_id = sqlc.narg('user_id')
|
||||
AND percentage > 0
|
||||
AND percentage < 1
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) rp ON rp.media_item_id = mi.id
|
||||
WHERE mi.library_id = $1
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
ORDER BY rp.last_read_at DESC
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetRecentlyAddedItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE mi.library_id = $1
|
||||
ORDER BY mi.created_at DESC
|
||||
LIMIT $2;
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetRecentlyReadItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
INNER JOIN (
|
||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||
FROM reading_progress
|
||||
WHERE user_id = $2
|
||||
WHERE user_id = sqlc.narg('user_id')
|
||||
AND percentage >= 1
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) rp ON rp.media_item_id = mi.id
|
||||
WHERE mi.library_id = $1
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
ORDER BY rp.last_read_at DESC
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetNotStartedItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE mi.library_id = $1
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM reading_progress rp
|
||||
WHERE rp.media_item_id = mi.id
|
||||
AND rp.user_id = $2
|
||||
AND rp.user_id = sqlc.narg('user_id')
|
||||
AND rp.percentage > 0
|
||||
)
|
||||
ORDER BY mi.created_at DESC
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetCollectionItemsForDashboard :many
|
||||
SELECT mi.*, ci.excluded FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND mi.library_id = $2
|
||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
ORDER BY ci.added_at DESC
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetLibraryItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE mi.library_id = $1
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
-- name: GetDistinctSeries :many
|
||||
SELECT series, COUNT(*) as book_count,
|
||||
MAX(series_count) as total_in_series,
|
||||
MAX(created_at) as last_entry_at
|
||||
FROM media_items
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != ''
|
||||
GROUP BY series
|
||||
ORDER BY MAX(created_at) DESC
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT series)::int
|
||||
FROM media_items
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != '';
|
||||
|
||||
-- name: GetSeriesCovers :many
|
||||
SELECT cover_image_path, library_id
|
||||
FROM media_items
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
|
||||
ORDER BY series_number ASC NULLS LAST
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetSeriesBooks :many
|
||||
SELECT * FROM media_items
|
||||
WHERE series = sqlc.narg('series')
|
||||
ORDER BY series_number ASC NULLS LAST;
|
||||
|
||||
-- name: GetContinueSeriesItems :many
|
||||
WITH user_series_progress AS (
|
||||
SELECT mi.series,
|
||||
MAX(mi.series_number) as max_read_number,
|
||||
MAX(rp.last_read_at) as last_read_at
|
||||
FROM reading_progress rp
|
||||
JOIN media_items mi ON mi.id = rp.media_item_id
|
||||
WHERE rp.user_id = sqlc.narg('user_id')
|
||||
AND rp.percentage > 0
|
||||
AND mi.series IS NOT NULL AND mi.series != ''
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
GROUP BY mi.series
|
||||
),
|
||||
next_books AS (
|
||||
SELECT DISTINCT ON (mi.series) mi.*,
|
||||
usp.last_read_at
|
||||
FROM media_items mi
|
||||
JOIN user_series_progress usp ON mi.series = usp.series
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
||||
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
||||
)
|
||||
SELECT * FROM next_books
|
||||
ORDER BY last_read_at DESC NULLS LAST
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetSavedFilters :many
|
||||
SELECT * FROM saved_filters
|
||||
WHERE user_id = @user_id AND resource_type = @resource_type
|
||||
@@ -1995,9 +2362,12 @@ RETURNING *;
|
||||
|
||||
-- name: GetMediaBookmarks :many
|
||||
SELECT * FROM media_bookmarks
|
||||
WHERE media_item_id = $1 AND user_id = $2
|
||||
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetMediaBookmark :one
|
||||
SELECT * FROM media_bookmarks WHERE id = $1;
|
||||
|
||||
-- name: CreateMediaBookmark :one
|
||||
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
@@ -2093,3 +2463,8 @@ SELECT
|
||||
FROM libraries l
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE l.id = $1;
|
||||
|
||||
-- name: GetBooksByTag :many
|
||||
SELECT * FROM media_items
|
||||
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
||||
ORDER BY title ASC;
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
highlighting "github.com/yuin/goldmark-highlighting"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
|
||||
"bookhoard/templates"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type DocsHandler struct {
|
||||
@@ -77,7 +79,7 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
|
||||
// Convert markdown to HTML
|
||||
var buf bytes.Buffer
|
||||
context := parser.NewContext()
|
||||
if err := h.markdown.Convert([]byte(content), &buf, parser.WithContext(context)); err != nil {
|
||||
if err := h.markdown.Convert(content, &buf, parser.WithContext(context)); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert markdown: %w", err)
|
||||
}
|
||||
|
||||
@@ -169,7 +171,7 @@ func (h *DocsHandler) generateBreadcrumb(docPath string) []templates.BreadcrumbI
|
||||
// Don't add the last part (current page)
|
||||
if i < len(parts)-1 {
|
||||
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
|
||||
Title: strings.Title(strings.ReplaceAll(part, "-", " ")),
|
||||
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
|
||||
URL: "/docs" + path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"bookhoard/templates"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// BuildNavigation creates the navigation structure from the docs filesystem
|
||||
@@ -100,7 +103,7 @@ func (h *DocsHandler) getDocTitle(docPath string) string {
|
||||
filename := parts[len(parts)-1]
|
||||
|
||||
// Convert to title case
|
||||
title = strings.Title(strings.ReplaceAll(filename, "-", " "))
|
||||
title = cases.Title(language.English).String(strings.ReplaceAll(filename, "-", " "))
|
||||
|
||||
// Handle special cases
|
||||
switch filename {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -74,18 +75,18 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
||||
endDate := c.QueryParam("end_date")
|
||||
|
||||
if startDate == "" {
|
||||
startDate = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
|
||||
startDate = time.Now().AddDate(0, -1, 0).Format("01-02-2006")
|
||||
}
|
||||
if endDate == "" {
|
||||
endDate = time.Now().Format("2006-01-02")
|
||||
endDate = time.Now().Format("01-02-2006")
|
||||
}
|
||||
|
||||
startTime, err := time.Parse("2006-01-02", startDate)
|
||||
startTime, err := time.Parse("01-02-2006", startDate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
|
||||
}
|
||||
|
||||
endTime, err := time.Parse("2006-01-02", endDate)
|
||||
endTime, err := time.Parse("01-02-2006", endDate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
|
||||
}
|
||||
@@ -98,7 +99,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
||||
CreatedAt_2: pgtype.Timestamptz{Time: endTime, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
||||
longestSession = int(minutes)
|
||||
}
|
||||
|
||||
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
|
||||
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
|
||||
if dailyMap[dateKey] == nil {
|
||||
dailyMap[dateKey] = &DailyReading{
|
||||
Date: dateKey,
|
||||
@@ -140,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
||||
|
||||
if entry.PagesRead.Valid {
|
||||
totalPages += int(entry.PagesRead.Int32)
|
||||
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
|
||||
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
|
||||
if dailyMap[dateKey] != nil {
|
||||
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
|
||||
}
|
||||
@@ -211,7 +212,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
||||
ctx := context.Background()
|
||||
|
||||
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get device usage")
|
||||
}
|
||||
|
||||
@@ -221,7 +222,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
||||
lastSync := ""
|
||||
if u.LastSync != nil {
|
||||
if t, ok := u.LastSync.(time.Time); ok {
|
||||
lastSync = t.Format("2006-01-02 15:04:05")
|
||||
lastSync = t.Format("01-02-2006 03:04:05 PM")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +264,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
||||
Limit: limitInt,
|
||||
})
|
||||
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get popular books")
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
||||
lastRead := ""
|
||||
if book.LastRead != nil {
|
||||
if t, ok := book.LastRead.(time.Time); ok {
|
||||
lastRead = t.Format("2006-01-02 15:04:05")
|
||||
lastRead = t.Format("01-02-2006 03:04:05 PM")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+69
-22
@@ -6,7 +6,9 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/middleware"
|
||||
"bookhoard/internal/setupstatus"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -81,20 +83,22 @@ type UserProfile struct {
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type AdminUpdateUserRequest struct {
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register
|
||||
@@ -187,7 +191,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
}
|
||||
|
||||
var userRole string
|
||||
if len(users) == 0 {
|
||||
if !adminExists {
|
||||
userRole = "admin"
|
||||
} else {
|
||||
userRole = req.Role
|
||||
@@ -229,6 +233,10 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// A new user may have changed the admin count (e.g. first user becomes
|
||||
// admin), so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
|
||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
||||
@@ -261,7 +269,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
_, refreshToken, err := h.CreateRefreshToken(uuid.UUID(user.ID.Bytes))
|
||||
_, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes)
|
||||
if err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
||||
@@ -407,7 +415,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
_, refreshToken, err := h.CreateRefreshToken(uuid.UUID(user.ID.Bytes))
|
||||
_, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes)
|
||||
if err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
||||
@@ -495,7 +503,7 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
||||
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
|
||||
} else {
|
||||
// Self-edit mode
|
||||
targetUserUUID = currentUser.ID
|
||||
@@ -545,6 +553,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Role changes can affect the admin count, so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
}
|
||||
|
||||
// Update username (if provided)
|
||||
@@ -563,6 +574,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Update timezone
|
||||
if req.Timezone != "" {
|
||||
if _, err := time.LoadLocation(req.Timezone); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid timezone",
|
||||
})
|
||||
}
|
||||
err := h.db.UpdateUserTimezone(c.Request().Context(), database.UpdateUserTimezoneParams{
|
||||
ID: targetUserUUID,
|
||||
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update email (if provided)
|
||||
if req.Email != "" {
|
||||
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
|
||||
@@ -759,16 +786,16 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
||||
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
|
||||
} else {
|
||||
// Self-change mode
|
||||
targetUserUUID = currentUser.ID
|
||||
}
|
||||
|
||||
type PasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password,omitempty"`
|
||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
|
||||
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
|
||||
}
|
||||
|
||||
var req PasswordRequest
|
||||
@@ -839,7 +866,7 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
||||
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
|
||||
} else {
|
||||
// Self-deletion mode
|
||||
targetUserUUID = currentUser.ID
|
||||
@@ -884,10 +911,26 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete the last admin account"})
|
||||
}
|
||||
|
||||
// If deleting an admin, reassign their libraries and media items to another admin
|
||||
// before deletion to prevent ON DELETE SET NULL from orphaning ownership
|
||||
if targetUserRole == "admin" {
|
||||
successor, err := h.db.GetFirstAdminExclude(c.Request().Context(), targetUserUUID)
|
||||
if err == nil {
|
||||
_ = h.db.ReassignLibraries(c.Request().Context(), database.ReassignLibrariesParams{
|
||||
CreatedByAdminID: targetUserUUID,
|
||||
CreatedByAdminID_2: successor.ID,
|
||||
})
|
||||
_ = h.db.ReassignMediaItems(c.Request().Context(), database.ReassignMediaItemsParams{
|
||||
AddedByAdminID: targetUserUUID,
|
||||
AddedByAdminID_2: successor.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user (this will cascade to delete all related data)
|
||||
err = h.db.DeleteUser(c.Request().Context(), targetUserUUID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusNotFound, `<div class="text-red-500">User not found</div>`)
|
||||
}
|
||||
@@ -899,6 +942,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Deletion may have changed the admin count, so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
|
||||
// Create success message based on context
|
||||
var message string
|
||||
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
||||
@@ -940,7 +986,7 @@ func (h *AuthHandler) UpdateUserMaxDevices(c *echo.Context) error {
|
||||
MaxDevices: pgtype.Int4{Int32: req.MaxDevices, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -996,6 +1042,7 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
|
||||
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -137,6 +138,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
includeAuto := c.QueryParam("include_auto") == "true"
|
||||
sortBy := c.QueryParam("sort_by")
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
collections, err := h.GetCollectionsData(c)
|
||||
if err != nil {
|
||||
@@ -151,6 +153,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
Icon string `json:"icon"`
|
||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
BookCount int `json:"book_count"`
|
||||
}
|
||||
|
||||
response := make([]CollectionResponse, 0, len(collections))
|
||||
@@ -158,14 +161,35 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
bookCount := 0
|
||||
if libraryID != "" {
|
||||
libUUID, libErr := uuid.Parse(libraryID)
|
||||
if libErr == nil {
|
||||
items, countErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||
})
|
||||
if countErr == nil {
|
||||
for _, item := range items {
|
||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||
bookCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = append(response, CollectionResponse{
|
||||
ID: uuid.UUID(col.ID.Bytes),
|
||||
ID: col.ID.Bytes,
|
||||
Name: col.Name,
|
||||
Description: textToString(col.Description),
|
||||
Color: textToString(col.Color),
|
||||
Icon: textToString(col.Icon),
|
||||
AutoAssignRules: json.RawMessage(col.AutoAssignRules),
|
||||
AutoAssignRules: col.AutoAssignRules,
|
||||
CreatedAt: col.CreatedAt.Time.String(),
|
||||
BookCount: bookCount,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,24 +220,92 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||
}
|
||||
|
||||
books, err := h.GetCollectionBooksData(c, collectionID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
libraryID := c.QueryParam("library_id")
|
||||
var bookList []BookInfo
|
||||
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
parsed, parseErr := uuid.Parse(libraryID)
|
||||
if parseErr != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
bookList := make([]BookInfo, 0, len(books))
|
||||
for _, book := range books {
|
||||
bookList = append(bookList, BookInfo{
|
||||
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
|
||||
})
|
||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
dashboardSvc := services.NewDashboardService(h.db)
|
||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||
if secErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
||||
}
|
||||
for _, section := range sections {
|
||||
if section.CollectionID == collectionID {
|
||||
bookCards := make([]BookInfo, len(section.Items))
|
||||
for i, item := range section.Items {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookCards[i] = BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
}
|
||||
}
|
||||
bookList = bookCards
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if libUUID.Valid {
|
||||
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
LibraryID: libUUID,
|
||||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||
})
|
||||
if collErr != nil {
|
||||
bookList = []BookInfo{}
|
||||
} else {
|
||||
var validItems []database.GetCollectionItemsForDashboardRow
|
||||
for _, item := range collItems {
|
||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||
validItems = append(validItems, item)
|
||||
}
|
||||
}
|
||||
bookCards := make([]BookInfo, len(validItems))
|
||||
for i, item := range validItems {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookCards[i] = BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
}
|
||||
}
|
||||
bookList = bookCards
|
||||
}
|
||||
} else {
|
||||
books, booksErr := h.GetCollectionBooksData(c, collectionID)
|
||||
if booksErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()})
|
||||
}
|
||||
bookList = make([]BookInfo, 0, len(books))
|
||||
for _, book := range books {
|
||||
bookList = append(bookList, BookInfo{
|
||||
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var viewSettings map[string]interface{}
|
||||
if len(collection.ViewSettings) > 0 {
|
||||
json.Unmarshal(collection.ViewSettings, &viewSettings)
|
||||
err := json.Unmarshal(collection.ViewSettings, &viewSettings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
@@ -459,8 +551,8 @@ func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error {
|
||||
response := make([]MappingResponse, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
response = append(response, MappingResponse{
|
||||
ID: uuid.UUID(m.ID.Bytes),
|
||||
CollectionID: uuid.UUID(m.CollectionID.Bytes),
|
||||
ID: m.ID.Bytes,
|
||||
CollectionID: m.CollectionID.Bytes,
|
||||
CollectionName: m.CollectionName,
|
||||
DeviceShelfName: textToString(m.DeviceShelfName),
|
||||
SyncDirection: textToString(m.SyncDirection),
|
||||
@@ -585,7 +677,7 @@ func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
|
||||
response := make([]CollectionResponse, 0, len(collections))
|
||||
for _, col := range collections {
|
||||
response = append(response, CollectionResponse{
|
||||
ID: uuid.UUID(col.ID.Bytes),
|
||||
ID: col.ID.Bytes,
|
||||
Name: col.Name,
|
||||
Description: textToString(col.Description),
|
||||
Color: textToString(col.Color),
|
||||
@@ -891,7 +983,7 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
||||
|
||||
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
|
||||
+174
-20
@@ -5,6 +5,7 @@ import (
|
||||
wsync "bookhoard/internal/sync"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -27,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
|
||||
}
|
||||
|
||||
type ConflictResolutionRequest struct {
|
||||
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"`
|
||||
Winner string `json:"winner" validate:"required"`
|
||||
ManualData map[string]interface{} `json:"manual_data"`
|
||||
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
||||
Reason string `json:"reason"`
|
||||
@@ -83,7 +84,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
|
||||
conflicts, err = h.db.ListSyncConflictsByUser(ctx, user.ID)
|
||||
}
|
||||
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
@@ -150,7 +151,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
|
||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
||||
@@ -214,7 +215,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
||||
@@ -224,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||
}
|
||||
|
||||
if conflict.ResolutionStatus.String != "unresolved" {
|
||||
if conflict.ResolutionStatus.String == "user_resolved" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
||||
}
|
||||
|
||||
@@ -234,8 +235,10 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
winnerData := map[string]interface{}{}
|
||||
winnerSource := req.Winner
|
||||
if req.Winner == "manual" {
|
||||
winnerData = req.ManualData
|
||||
winnerSource = "manual"
|
||||
} else {
|
||||
source, ok := conflictData[req.Winner]
|
||||
if !ok {
|
||||
@@ -250,11 +253,17 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
if conflict.ConflictType == "progress" {
|
||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerData); err == nil {
|
||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
|
||||
appliedTo["progress"] = true
|
||||
}
|
||||
}
|
||||
|
||||
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
|
||||
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
|
||||
appliedTo["annotations"] = true
|
||||
}
|
||||
}
|
||||
|
||||
resolutionData := map[string]interface{}{
|
||||
"winner": req.Winner,
|
||||
"applied_to": appliedTo,
|
||||
@@ -285,14 +294,14 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
||||
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||
ctx := context.Background()
|
||||
|
||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -316,8 +325,12 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
|
||||
}
|
||||
|
||||
currentPage := existingProgress.CurrentPage
|
||||
totalPages := existingProgress.TotalPages
|
||||
var currentPage pgtype.Int4
|
||||
var totalPages pgtype.Int4
|
||||
if err == nil {
|
||||
currentPage = existingProgress.CurrentPage
|
||||
totalPages = existingProgress.TotalPages
|
||||
}
|
||||
|
||||
if p, ok := data["page"].(float64); ok {
|
||||
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
|
||||
@@ -337,7 +350,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "manual", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||
ViewportY: pgtype.Float8{},
|
||||
ScrollPositionX: pgtype.Float8{},
|
||||
ScrollPositionY: pgtype.Float8{},
|
||||
@@ -349,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
dedupKey, _ := winnerData["dedup_key"].(string)
|
||||
if dedupKey == "" {
|
||||
return errors.New("missing dedup_key in winner data")
|
||||
}
|
||||
|
||||
switch conflictType {
|
||||
case "annotation_highlight":
|
||||
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
case "annotation_bookmark":
|
||||
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
case "annotation_note":
|
||||
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
default:
|
||||
return errors.New("unknown annotation conflict type")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: existing.SelectionText,
|
||||
StartPosition: existing.StartPosition,
|
||||
EndPosition: existing.EndPosition,
|
||||
Color: existing.Color,
|
||||
NoteText: existing.NoteText,
|
||||
PercentageStart: existing.PercentageStart,
|
||||
PercentageEnd: existing.PercentageEnd,
|
||||
EpubcfiStart: existing.EpubcfiStart,
|
||||
EpubcfiEnd: existing.EpubcfiEnd,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["selection_text"].(string); ok {
|
||||
params.SelectionText = v
|
||||
}
|
||||
if v, ok := data["color"].(string); ok {
|
||||
params.Color = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["note_text"].(string); ok {
|
||||
params.NoteText = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["start_position"].(string); ok {
|
||||
params.StartPosition = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["end_position"].(string); ok {
|
||||
params.EndPosition = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: existing.PageNumber,
|
||||
ChapterNumber: existing.ChapterNumber,
|
||||
CfiPosition: existing.CfiPosition,
|
||||
Title: existing.Title,
|
||||
Position: existing.Position,
|
||||
Notes: existing.Notes,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["title"].(string); ok {
|
||||
params.Title = v
|
||||
}
|
||||
if v, ok := data["notes"].(string); ok {
|
||||
params.Notes = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaNoteForSyncParams{
|
||||
ID: existing.ID,
|
||||
Content: existing.Content,
|
||||
Position: existing.Position,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
CharacterStart: existing.CharacterStart,
|
||||
CharacterEnd: existing.CharacterEnd,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
ParagraphReference: existing.ParagraphReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["content"].(string); ok {
|
||||
params.Content = v
|
||||
}
|
||||
if v, ok := data["position"].(string); ok {
|
||||
params.Position = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
||||
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
||||
if err != nil {
|
||||
@@ -376,7 +526,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
||||
@@ -396,7 +546,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
||||
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
|
||||
conflicts, err := h.db.ListSyncConflictsByUser(context.Background(), user.ID)
|
||||
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
|
||||
}
|
||||
@@ -546,7 +696,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil {
|
||||
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
|
||||
results = append(results, ConflictResult{
|
||||
ConflictID: conflictIDStr,
|
||||
Status: "error",
|
||||
@@ -630,14 +780,14 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
|
||||
return highestSource, highestData
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
||||
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||
ctx := context.Background()
|
||||
|
||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -661,8 +811,12 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
|
||||
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
|
||||
}
|
||||
|
||||
currentPage := existingProgress.CurrentPage
|
||||
totalPages := existingProgress.TotalPages
|
||||
var currentPage pgtype.Int4
|
||||
var totalPages pgtype.Int4
|
||||
if err == nil {
|
||||
currentPage = existingProgress.CurrentPage
|
||||
totalPages = existingProgress.TotalPages
|
||||
}
|
||||
|
||||
if p, ok := data["page"].(float64); ok {
|
||||
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
|
||||
@@ -681,8 +835,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
|
||||
CharacterOffset: characterOffset,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||
ViewportY: pgtype.Float8{},
|
||||
ScrollPositionX: pgtype.Float8{},
|
||||
ScrollPositionY: pgtype.Float8{},
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetMostRecentSource_KOReaderMoreRecent(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Timestamp: time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
"page": float64(89),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getMostRecentSource(conflictData)
|
||||
assert.Equal(t, "koreader", source)
|
||||
assert.Equal(t, 0.45, data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetMostRecentSource_KoboMoreRecent(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Timestamp: time.Date(2026, 1, 30, 20, 15, 0, 0, time.UTC),
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getMostRecentSource(conflictData)
|
||||
assert.Equal(t, "kobo", source)
|
||||
assert.Equal(t, 0.42, data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetMostRecentSource_SingleSource(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Timestamp: time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.50,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getMostRecentSource(conflictData)
|
||||
assert.Equal(t, "koreader", source)
|
||||
assert.Equal(t, 0.50, data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetMostRecentSource_SameTimestamp(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
ts := time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC)
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Timestamp: ts,
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Timestamp: ts,
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, _ := handler.getMostRecentSource(conflictData)
|
||||
assert.Contains(t, []string{"koreader", "kobo"}, source)
|
||||
}
|
||||
|
||||
func TestGetMostRecentSource_EmptyData(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{}
|
||||
|
||||
source, data := handler.getMostRecentSource(conflictData)
|
||||
assert.Equal(t, "", source)
|
||||
assert.Nil(t, data)
|
||||
}
|
||||
|
||||
func TestGetHighestProgressSource_KOReaderHigher(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.75,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getHighestProgressSource(conflictData)
|
||||
assert.Equal(t, "koreader", source)
|
||||
assert.Equal(t, 0.75, data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetHighestProgressSource_KoboHigher(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.30,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.90,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getHighestProgressSource(conflictData)
|
||||
assert.Equal(t, "kobo", source)
|
||||
assert.Equal(t, 0.90, data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetHighestProgressSource_SingleSource(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.50,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getHighestProgressSource(conflictData)
|
||||
assert.Equal(t, "koreader", source)
|
||||
assert.Equal(t, 0.50, data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetHighestProgressSource_EmptyData(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{}
|
||||
|
||||
source, data := handler.getHighestProgressSource(conflictData)
|
||||
assert.Equal(t, "", source)
|
||||
assert.Nil(t, data)
|
||||
}
|
||||
|
||||
func TestGetHighestProgressSource_NoPercentageField(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Data: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getHighestProgressSource(conflictData)
|
||||
assert.Equal(t, "", source)
|
||||
assert.Nil(t, data)
|
||||
}
|
||||
|
||||
func TestGetHighestProgressSource_MixedPercentageTypes(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.60,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": float64(0.80),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getHighestProgressSource(conflictData)
|
||||
assert.Equal(t, "kobo", source)
|
||||
assert.Equal(t, float64(0.80), data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetHighestProgressSource_BothZero(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.0,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getHighestProgressSource(conflictData)
|
||||
assert.NotEmpty(t, source)
|
||||
assert.NotNil(t, data)
|
||||
assert.Equal(t, 0.0, data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetHighestProgressSource_ThreeWayConflict(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.92,
|
||||
},
|
||||
},
|
||||
"web": {
|
||||
Source: "web",
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.70,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getHighestProgressSource(conflictData)
|
||||
assert.Equal(t, "kobo", source)
|
||||
assert.Equal(t, 0.92, data["percentage"])
|
||||
}
|
||||
|
||||
func TestGetMostRecentSource_ThreeWayConflict(t *testing.T) {
|
||||
handler := &ConflictHandler{}
|
||||
|
||||
conflictData := map[string]ConflictSourceData{
|
||||
"koreader": {
|
||||
Source: "koreader",
|
||||
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
Source: "kobo",
|
||||
Timestamp: time.Date(2026, 1, 30, 20, 20, 0, 0, time.UTC),
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.92,
|
||||
},
|
||||
},
|
||||
"web": {
|
||||
Source: "web",
|
||||
Timestamp: time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||
Data: map[string]interface{}{
|
||||
"percentage": 0.70,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source, data := handler.getMostRecentSource(conflictData)
|
||||
assert.Equal(t, "kobo", source)
|
||||
assert.Equal(t, 0.92, data["percentage"])
|
||||
}
|
||||
@@ -30,12 +30,13 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||||
}
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
parsed, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||
@@ -133,6 +134,7 @@ func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
|
||||
"Recently Added": true,
|
||||
"Recently Read": true,
|
||||
"Not Started": true,
|
||||
"Continue Series": true,
|
||||
}
|
||||
if !validCollections[req.CollectionName] {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
|
||||
@@ -201,14 +203,15 @@ func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
parsed, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||
if err != nil {
|
||||
// Return default preferences instead of 404 when none exist
|
||||
|
||||
@@ -100,6 +100,10 @@ type PendingRegistration struct {
|
||||
UserID uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
Approved bool
|
||||
AuthToken string
|
||||
DeviceID [16]byte
|
||||
SyncEndpoints map[string]string
|
||||
}
|
||||
|
||||
var pendingRegistrations = make(map[string]*PendingRegistration)
|
||||
@@ -173,60 +177,21 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||
}
|
||||
|
||||
if registration.UserID == (uuid.UUID{}) {
|
||||
if registration.Approved {
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
Status: "pending",
|
||||
Message: "awaiting user approval",
|
||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||
Status: "approved",
|
||||
AuthToken: registration.AuthToken,
|
||||
DeviceID: registration.DeviceID,
|
||||
SyncEndpoints: registration.SyncEndpoints,
|
||||
})
|
||||
}
|
||||
|
||||
authToken, err := generateDeviceToken()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||
}
|
||||
|
||||
userUUID := registration.UserID
|
||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||
|
||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||
|
||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequencyMinutes: syncFreq,
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||
}
|
||||
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
|
||||
syncEndpoints := map[string]string{}
|
||||
switch registration.DeviceType {
|
||||
case "koreader":
|
||||
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
||||
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
||||
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
||||
case "kobo":
|
||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
Status: "approved",
|
||||
AuthToken: authToken,
|
||||
DeviceID: device.ID.Bytes,
|
||||
SyncEndpoints: syncEndpoints,
|
||||
Status: "pending",
|
||||
Message: "awaiting user approval",
|
||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,8 +222,8 @@ func (h *DeviceHandler) ListDevices(c *echo.Context) error {
|
||||
ID: device.ID.Bytes,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceType: device.DeviceType,
|
||||
LastSync: (*time.Time)(&device.LastSync.Time),
|
||||
LastSeen: (*time.Time)(&device.LastSeen.Time),
|
||||
LastSync: &device.LastSync.Time,
|
||||
LastSeen: &device.LastSeen.Time,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequency: syncFreq,
|
||||
@@ -301,8 +266,8 @@ func (h *DeviceHandler) GetDevicesData(c *echo.Context) ([]DeviceInfo, error) {
|
||||
ID: device.ID.Bytes,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceType: device.DeviceType,
|
||||
LastSync: (*time.Time)(&device.LastSync.Time),
|
||||
LastSeen: (*time.Time)(&device.LastSeen.Time),
|
||||
LastSync: &device.LastSync.Time,
|
||||
LastSeen: &device.LastSeen.Time,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequency: syncFreq,
|
||||
@@ -353,8 +318,8 @@ func (h *DeviceHandler) GetDevice(c *echo.Context) error {
|
||||
ID: device.ID.Bytes,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceType: device.DeviceType,
|
||||
LastSync: (*time.Time)(&device.LastSync.Time),
|
||||
LastSeen: (*time.Time)(&device.LastSeen.Time),
|
||||
LastSync: &device.LastSync.Time,
|
||||
LastSeen: &device.LastSeen.Time,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequency: syncFreq,
|
||||
@@ -447,8 +412,8 @@ func (h *DeviceHandler) UpdateDevice(c *echo.Context) error {
|
||||
ID: updatedDevice.ID.Bytes,
|
||||
DeviceName: updatedDevice.DeviceName,
|
||||
DeviceType: updatedDevice.DeviceType,
|
||||
LastSync: (*time.Time)(&updatedDevice.LastSync.Time),
|
||||
LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time),
|
||||
LastSync: &updatedDevice.LastSync.Time,
|
||||
LastSeen: &updatedDevice.LastSeen.Time,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequency: syncFreq, // Now correctly returns the updated value
|
||||
@@ -566,8 +531,8 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
|
||||
ID: updatedDevice.ID.Bytes,
|
||||
DeviceName: updatedDevice.DeviceName,
|
||||
DeviceType: updatedDevice.DeviceType,
|
||||
LastSync: (*time.Time)(&updatedDevice.LastSync.Time),
|
||||
LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time),
|
||||
LastSync: &updatedDevice.LastSync.Time,
|
||||
LastSeen: &updatedDevice.LastSeen.Time,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequency: syncFreq,
|
||||
@@ -597,14 +562,63 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||
}
|
||||
|
||||
if registration.Approved {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device already approved",
|
||||
"device_name": registration.DeviceName,
|
||||
"device_type": registration.DeviceType,
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
|
||||
authToken, err := generateDeviceToken()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||
}
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||
|
||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequencyMinutes: syncFreq,
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||
}
|
||||
|
||||
syncEndpoints := map[string]string{}
|
||||
switch registration.DeviceType {
|
||||
case "koreader":
|
||||
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
||||
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
||||
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
||||
case "kobo":
|
||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||
}
|
||||
|
||||
registration.UserID = userUUID
|
||||
registration.Approved = true
|
||||
registration.AuthToken = authToken
|
||||
registration.DeviceID = device.ID.Bytes
|
||||
registration.SyncEndpoints = syncEndpoints
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device approved successfully",
|
||||
"device_name": registration.DeviceName,
|
||||
"device_type": registration.DeviceType,
|
||||
"registration_id": registrationID,
|
||||
"approved": true, // Fixed: Add confirmation field for test compatibility
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -624,15 +638,9 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registrations := []map[string]interface{}{}
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||
if reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
"registration_id": reg.RegistrationID,
|
||||
"device_name": reg.DeviceName,
|
||||
@@ -640,7 +648,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
||||
"device_identifier": reg.DeviceIdentifier,
|
||||
"expires_at": reg.ExpiresAt,
|
||||
"created_at": reg.CreatedAt,
|
||||
"is_approved": reg.UserID != (uuid.UUID{}),
|
||||
"is_approved": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (h *FiltersHandler) GetSavedFilters(c *echo.Context) error {
|
||||
ID: uuid.Must(uuid.FromBytes(f.ID.Bytes[:])),
|
||||
Name: f.Name,
|
||||
ResourceType: f.ResourceType,
|
||||
Filters: json.RawMessage(f.Filters), // Return JSONB as-is
|
||||
Filters: f.Filters,
|
||||
CreatedAt: f.CreatedAt.Time.Format(time.RFC3339),
|
||||
UpdatedAt: f.UpdatedAt.Time.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
+406
-148
@@ -3,10 +3,9 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -18,14 +17,29 @@ import (
|
||||
)
|
||||
|
||||
type KoboHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
libraryService LibraryPathResolver
|
||||
}
|
||||
|
||||
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
||||
return &KoboHandler{db: db, connManager: connManager}
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
h.progressSvc = svc
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
h.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||
h.libraryService = svc
|
||||
}
|
||||
|
||||
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
||||
// Enhanced Kobo Sync - ContentId Mapping Logic
|
||||
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
||||
@@ -33,7 +47,7 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
|
||||
catalog, err := h.db.GetDeviceCatalogByKoboContentId(ctx.Request().Context(), contentId)
|
||||
if err == nil && catalog.ID.Valid {
|
||||
// Found! Use canonical Bookhoard UUID
|
||||
return uuid.UUID(catalog.BookhoardUuid.Bytes), nil, "catalog_match"
|
||||
return catalog.BookhoardUuid.Bytes, nil, "catalog_match"
|
||||
}
|
||||
|
||||
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
|
||||
@@ -52,7 +66,7 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
|
||||
DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
DeliveryMethod: pgtype.Text{String: "sync", Valid: true},
|
||||
})
|
||||
return uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match"
|
||||
return mediaItem.ID.Bytes, nil, "sha256_match"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +189,6 @@ func looksLikeSHA256(s string) bool {
|
||||
return matched
|
||||
}
|
||||
|
||||
// calculateFileSHA256 calculates SHA-256 hash of file path
|
||||
func calculateFileSHA256(filePath string) string {
|
||||
hash := sha256.Sum256([]byte(filePath))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
type KoboDeviceInfo struct {
|
||||
DeviceID string `json:"DeviceId"`
|
||||
Model string `json:"Model"`
|
||||
@@ -243,9 +251,16 @@ type KoboInitResponse struct {
|
||||
}
|
||||
|
||||
type KoboSyncStatus struct {
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
|
||||
}
|
||||
|
||||
type KoboDeletedAnnotation struct {
|
||||
ContentId string `json:"ContentId"`
|
||||
BookmarkId string `json:"BookmarkId"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
|
||||
type KoboServerSyncData struct {
|
||||
@@ -303,12 +318,13 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
||||
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
||||
pagesRemaining = new(int(progress.TotalPages.Int32 - progress.CurrentPage.Int32))
|
||||
remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
|
||||
pagesRemaining = &remaining
|
||||
}
|
||||
}
|
||||
|
||||
bookmarkCount := 0
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -400,41 +416,58 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
markupsSynced := 0
|
||||
bookmarksSynced := 0
|
||||
unlinkedBooks := 0
|
||||
processedBooks := make(map[pgtype.UUID]string)
|
||||
|
||||
for _, readingSync := range req.ReadingSync {
|
||||
// Use ContentId mapping with fallback logic
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||
if err != nil || bookhoardUUID == uuid.Nil {
|
||||
// Unlinked book detected
|
||||
unlinkedBooks++
|
||||
// TODO: Create unlinked book entry for manual resolution
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
|
||||
processedBooks[pgMediaUUID] = readingSync.ContentId
|
||||
percentage := readingSync.PercentRead / 100.0
|
||||
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||
})
|
||||
// Kobo only sends a percentage. For fixed-layout & comic formats the page
|
||||
// index is the canonical locator, so derive it from the known page count.
|
||||
var currentPage, totalPages *int
|
||||
if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil {
|
||||
if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
||||
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||
total := int(mediaItem.PageCount.Int32)
|
||||
page := wsync.PercentageToPage(percentage, total)
|
||||
currentPage = &page
|
||||
totalPages = &total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
markupsSynced++
|
||||
|
||||
h.connManager.BroadcastProgressUpdate(
|
||||
bookhoardUUID,
|
||||
percentage,
|
||||
wsync.SourceDevice{
|
||||
ID: uuid.UUID(userID).String(),
|
||||
Name: device.DeviceName,
|
||||
Type: "kobo",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,29 +480,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
|
||||
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: bookmarkSync.BookmarkId,
|
||||
EndPosition: bookmarkSync.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
NoteText: bookmarkSync.BookmarkTitle,
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmarkSync.BookmarkText,
|
||||
Position: bookmarkSync.BookmarkId,
|
||||
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "last-read-place":
|
||||
if bookmarkSync.BookmarkId != "" {
|
||||
@@ -480,15 +556,51 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
epubcfi = strings.TrimSuffix(epubcfi, ")")
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
|
||||
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
|
||||
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||
})
|
||||
chapter := bookmarkSync.Chapter
|
||||
chapterProgress := 0.5
|
||||
|
||||
var convertedCFI *string
|
||||
var contextText *string
|
||||
|
||||
if epubcfi != "" && h.libraryService != nil {
|
||||
mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID)
|
||||
if mErr == nil {
|
||||
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||
if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive {
|
||||
convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if convertedCFI != nil {
|
||||
epubcfi = *convertedCFI
|
||||
}
|
||||
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Epubcfi: &epubcfi,
|
||||
ContextText: contextText,
|
||||
Chapter: &chapter,
|
||||
ChapterProgress: &chapterProgress,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: false,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
|
||||
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
|
||||
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to store last-read-place: %v", err)
|
||||
}
|
||||
@@ -510,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
BookmarksSynced: bookmarksSynced,
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
||||
for mediaItemID, contentId := range processedBooks {
|
||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
DeletedAt: cutoff,
|
||||
})
|
||||
for _, ts := range tombstones {
|
||||
var dd map[string]interface{}
|
||||
if len(ts.DeviceSyncData) > 0 {
|
||||
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||
}
|
||||
bookmarkID, _ := dd["bookmark_id"].(string)
|
||||
if bookmarkID == "" {
|
||||
continue
|
||||
}
|
||||
response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{
|
||||
ContentId: contentId,
|
||||
BookmarkId: bookmarkID,
|
||||
Type: ts.AnnotationType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include unlinked books count if any
|
||||
if unlinkedBooks > 0 {
|
||||
// For now, just log it. In production, this should trigger an alert
|
||||
@@ -552,25 +690,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: bookmarkSync.BookmarkId,
|
||||
EndPosition: bookmarkSync.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
NoteText: bookmarkSync.BookmarkTitle,
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmarkSync.BookmarkText,
|
||||
Position: bookmarkSync.BookmarkId,
|
||||
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -605,34 +785,33 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
|
||||
}
|
||||
|
||||
for _, test := range req {
|
||||
// Use ContentId mapping with fallback logic
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
||||
if err != nil || bookhoardUUID == uuid.Nil {
|
||||
// Unlinked book - skip
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
percentage := test.PercentRead / 100.0
|
||||
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
h.connManager.BroadcastProgressUpdate(
|
||||
bookhoardUUID,
|
||||
percentage,
|
||||
wsync.SourceDevice{
|
||||
ID: uuid.UUID(userID).String(),
|
||||
Name: device.DeviceName,
|
||||
Type: "kobo",
|
||||
},
|
||||
)
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,20 +827,6 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func parseKoboDeviceHeader(c *echo.Context) (KoboDeviceInfo, error) {
|
||||
deviceHeader := c.Request().Header.Get("x-kobo-device")
|
||||
if deviceHeader == "" {
|
||||
return KoboDeviceInfo{}, fmt.Errorf("missing x-kobo-device header")
|
||||
}
|
||||
|
||||
var device KoboDeviceInfo
|
||||
if err := json.Unmarshal([]byte(deviceHeader), &device); err != nil {
|
||||
return KoboDeviceInfo{}, fmt.Errorf("invalid device header format")
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
@@ -682,24 +847,34 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
highlightsSent := 0
|
||||
|
||||
for _, syncData := range req {
|
||||
// Use ContentId mapping with fallback logic
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
||||
if err != nil || bookhoardUUID == uuid.Nil {
|
||||
// Unlinked book - skip
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
|
||||
percentage := syncData.PercentRead / 100.0
|
||||
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true},
|
||||
})
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "bookhoard",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: false,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
booksSynced++
|
||||
@@ -707,36 +882,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
|
||||
for _, bookmark := range syncData.Bookmarks {
|
||||
if bookmark.BookmarkType == "bookmark" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmark.BookmarkText,
|
||||
Position: bookmark.BookmarkId,
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSent++
|
||||
}
|
||||
} else if bookmark.BookmarkType == "annotation" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: bookmark.BookmarkId,
|
||||
EndPosition: bookmark.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, highlight := range syncData.Highlights {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: highlight.BookmarkId,
|
||||
EndPosition: highlight.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,3 +971,43 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
HighlightsSent: highlightsSent,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) {
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
if epubPath == "" {
|
||||
log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||
FormatType: "kepub",
|
||||
})
|
||||
if err != nil || !kepubFormat.FilePath.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String)
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "")
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var cfi *string
|
||||
if result.CFI != "" {
|
||||
cfi = &result.CFI
|
||||
log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision)
|
||||
}
|
||||
|
||||
var ctx *string
|
||||
if result.ExtractedContext != "" {
|
||||
ctx = &result.ExtractedContext
|
||||
}
|
||||
|
||||
return cfi, ctx
|
||||
}
|
||||
|
||||
+557
-201
@@ -3,27 +3,64 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type KOReaderHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
queue *wsync.SyncQueueProcessor
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
queue *wsync.SyncQueueProcessor
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
libraryService LibraryPathResolver
|
||||
}
|
||||
|
||||
type LibraryPathResolver interface {
|
||||
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
|
||||
}
|
||||
|
||||
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
||||
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
h.progressSvc = svc
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
h.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) {
|
||||
if pos0 == "" || h.libraryService == nil {
|
||||
return "", ""
|
||||
}
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil || epubPath == "" {
|
||||
return "", ""
|
||||
}
|
||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
return startLoc.CFI, endLoc.CFI
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||
h.libraryService = svc
|
||||
}
|
||||
|
||||
type KOReaderProgressRequest struct {
|
||||
LibraryID *string `json:"library_id,omitempty"`
|
||||
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
||||
@@ -43,11 +80,12 @@ type KOReaderBookProgress struct {
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderDeviceInfo struct {
|
||||
@@ -96,11 +134,18 @@ type KOReaderNote struct {
|
||||
}
|
||||
|
||||
type KOReaderSyncResponse struct {
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
DeviceUpdated bool `json:"device_updated"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
DeviceUpdated bool `json:"device_updated"`
|
||||
}
|
||||
|
||||
type KOReaderBookSyncResult struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
BookUUID string `json:"book_uuid"`
|
||||
Synced bool `json:"synced"`
|
||||
}
|
||||
|
||||
type KOReaderConflict struct {
|
||||
@@ -121,19 +166,22 @@ type KOReaderMetadata struct {
|
||||
}
|
||||
|
||||
type KOReaderProgressData struct {
|
||||
Percentage float64 `json:"percentage"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderAnnotations struct {
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
|
||||
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderLibraryResponse struct {
|
||||
@@ -184,17 +232,28 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
||||
|
||||
booksSynced := 0
|
||||
conflicts := []KOReaderConflict{}
|
||||
bookResults := []KOReaderBookSyncResult{}
|
||||
|
||||
for _, book := range req.Books {
|
||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
||||
if !mediaItemID.Valid {
|
||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||
SHA256: book.SHA256,
|
||||
Synced: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
err := h.updateProgressForBook(c, pgUserID, mediaItemID, book)
|
||||
if err == nil {
|
||||
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
|
||||
synced := err == nil
|
||||
if synced {
|
||||
booksSynced++
|
||||
}
|
||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||
SHA256: book.SHA256,
|
||||
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
||||
Synced: synced,
|
||||
})
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||
@@ -208,6 +267,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
||||
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
||||
SyncStatus: "accepted",
|
||||
BooksSynced: booksSynced,
|
||||
BookResults: bookResults,
|
||||
Conflicts: conflicts,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
DeviceUpdated: true,
|
||||
@@ -217,6 +277,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
||||
SyncStatus: "completed",
|
||||
BooksSynced: booksSynced,
|
||||
BookResults: bookResults,
|
||||
Conflicts: conflicts,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
DeviceUpdated: true,
|
||||
@@ -343,17 +404,29 @@ func (h *KOReaderHandler) createDeviceFileAlias(c *echo.Context, deviceID pgtype
|
||||
|
||||
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
|
||||
booksEnqueued := 0
|
||||
bookResults := []KOReaderBookSyncResult{}
|
||||
|
||||
for _, book := range req.Books {
|
||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
||||
if !mediaItemID.Valid {
|
||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||
SHA256: book.SHA256,
|
||||
Synced: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
||||
if err == nil {
|
||||
synced := err == nil
|
||||
if synced {
|
||||
booksEnqueued++
|
||||
}
|
||||
h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book)
|
||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||
SHA256: book.SHA256,
|
||||
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
||||
Synced: synced,
|
||||
})
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||
@@ -363,11 +436,11 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusAccepted, map[string]interface{}{
|
||||
"sync_status": "checkpoint_enqueued",
|
||||
"books_enqueued": booksEnqueued,
|
||||
"message": "Sync will be processed in the background",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
||||
SyncStatus: "checkpoint_enqueued",
|
||||
BooksSynced: booksEnqueued,
|
||||
BookResults: bookResults,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -382,6 +455,7 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
||||
UserID: userID,
|
||||
Percentage: book.Percentage,
|
||||
Epubcfi: book.Epubcfi,
|
||||
ContextText: book.ContextText,
|
||||
Chapter: book.Chapter,
|
||||
Character: book.Character,
|
||||
Page: book.Page,
|
||||
@@ -393,68 +467,200 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
||||
return h.queue.EnqueueProgress(update)
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
})
|
||||
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
return err
|
||||
func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
|
||||
if h.annotationSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hasExistingProgress := err != pgx.ErrNoRows
|
||||
conflictDetected := false
|
||||
for _, hl := range book.Highlights {
|
||||
startPos := hl.Pos0
|
||||
endPos := hl.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||
|
||||
if hasExistingProgress && existingProgress.LastSyncSource.Valid {
|
||||
if existingProgress.LastSyncSource.String != "koreader" && existingProgress.LastSyncTimestamp.Valid {
|
||||
timeDiff := time.Since(existingProgress.LastSyncTimestamp.Time)
|
||||
if timeDiff < 5*time.Minute {
|
||||
percentageDiff := book.Percentage - existingProgress.Percentage.Float64
|
||||
if percentageDiff < 0 {
|
||||
percentageDiff = -percentageDiff
|
||||
}
|
||||
if percentageDiff > 0.01 {
|
||||
conflictDetected = true
|
||||
pctStart := 0.0
|
||||
if hl.Percentage != nil {
|
||||
pctStart = *hl.Percentage
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": hl.Datetime,
|
||||
"pos0": hl.Pos0,
|
||||
"pos1": hl.Pos1,
|
||||
"page": hl.Page,
|
||||
})
|
||||
|
||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: hl.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: hl.Color,
|
||||
NoteText: hl.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
}
|
||||
|
||||
for _, note := range book.Notes {
|
||||
startPos := note.Pos0
|
||||
endPos := note.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||
|
||||
pctStart := 0.0
|
||||
if note.Percentage != nil {
|
||||
pctStart = *note.Percentage
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
"pos0": note.Pos0,
|
||||
"pos1": note.Pos1,
|
||||
"page": note.Page,
|
||||
})
|
||||
|
||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: note.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
NoteText: note.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
}
|
||||
|
||||
for _, bookmark := range book.Bookmarks {
|
||||
position := ""
|
||||
if bookmark.Pos0 != "" {
|
||||
position = bookmark.Pos0
|
||||
} else if bookmark.Page > 0 {
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": bookmark.Datetime,
|
||||
"pos0": bookmark.Pos0,
|
||||
"page": bookmark.Page,
|
||||
})
|
||||
|
||||
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
Title: bookmark.Text,
|
||||
Position: position,
|
||||
ChapterNumber: int32(bookmark.Chapter),
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
deviceInfo := book.DeviceInfo
|
||||
deviceModel := deviceInfo.DeviceModel
|
||||
if deviceModel == "" {
|
||||
deviceModel = "KOReader Device"
|
||||
}
|
||||
|
||||
if h.progressSvc != nil {
|
||||
epubcfi := book.Epubcfi
|
||||
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
||||
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
|
||||
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
|
||||
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
||||
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
|
||||
// comic archives) has no extractable text, so CRE→CFI conversion
|
||||
// cannot succeed. The page index (page/total_pages) is the
|
||||
// canonical locator. Keep the incoming xpointer for device-native
|
||||
// restore; the web reader restores by page.
|
||||
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
|
||||
} else if h.libraryService == nil {
|
||||
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
|
||||
} else {
|
||||
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if resolveErr != nil {
|
||||
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
|
||||
} else if epubPath == "" {
|
||||
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
|
||||
} else {
|
||||
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
|
||||
converter := wsync.NewCFIConverter(epubPath)
|
||||
pct := 0.0
|
||||
if book.Percentage >= 0 {
|
||||
pct = book.Percentage
|
||||
}
|
||||
contextText := ""
|
||||
if book.ContextText != nil {
|
||||
contextText = *book.ContextText
|
||||
}
|
||||
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
|
||||
if convErr != nil {
|
||||
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
|
||||
} else if result != nil {
|
||||
if result.EPUBCFI != "" {
|
||||
convertedCFI := result.EPUBCFI
|
||||
epubcfi = &convertedCFI
|
||||
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
|
||||
} else if result.Href != "" {
|
||||
convertedHref := result.Href
|
||||
epubcfi = &convertedHref
|
||||
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
|
||||
} else {
|
||||
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveReq := wsync.SaveProgressRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
Source: "koreader",
|
||||
DeviceID: deviceID,
|
||||
Percentage: &book.Percentage,
|
||||
Epubcfi: epubcfi,
|
||||
ContextText: book.ContextText,
|
||||
Chapter: book.Chapter,
|
||||
CharacterOffset: book.Character,
|
||||
CurrentPage: book.Page,
|
||||
TotalPages: book.TotalPages,
|
||||
DeviceType: "koreader",
|
||||
DeviceName: deviceModel,
|
||||
Broadcast: true,
|
||||
}
|
||||
|
||||
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||
return nil
|
||||
}
|
||||
|
||||
var epubcfi pgtype.Text
|
||||
var chapter pgtype.Int4
|
||||
var characterOffset pgtype.Int8
|
||||
var currentPage pgtype.Int4
|
||||
var totalPages pgtype.Int4
|
||||
|
||||
if book.Epubcfi != nil {
|
||||
epubcfi = pgtype.Text{String: *book.Epubcfi, Valid: true}
|
||||
}
|
||||
if book.Chapter != nil {
|
||||
chapter = pgtype.Int4{Int32: int32(*book.Chapter), Valid: true}
|
||||
}
|
||||
if book.Character != nil {
|
||||
characterOffset = pgtype.Int8{Int64: *book.Character, Valid: true}
|
||||
}
|
||||
if book.Page != nil {
|
||||
currentPage = pgtype.Int4{Int32: int32(*book.Page), Valid: true}
|
||||
}
|
||||
if book.TotalPages != nil {
|
||||
totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||
Epubcfi: epubcfi,
|
||||
Chapter: chapter,
|
||||
Epubcfi: textPtrToPgText(book.Epubcfi),
|
||||
Chapter: intPtrToPgInt4(book.Chapter),
|
||||
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||
CharacterOffset: characterOffset,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
CharacterOffset: int64PtrToPgInt8(book.Character),
|
||||
CurrentPage: intPtrToPgInt4(book.Page),
|
||||
TotalPages: intPtrToPgInt4(book.TotalPages),
|
||||
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
|
||||
ViewportY: pgtype.Float8{},
|
||||
@@ -464,97 +670,44 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.U
|
||||
ReadingMode: pgtype.Text{},
|
||||
ZoomLevel: pgtype.Float8{},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if conflictDetected {
|
||||
koreaderData := map[string]interface{}{
|
||||
"source": "koreader",
|
||||
"timestamp": time.Now(),
|
||||
"data": map[string]interface{}{
|
||||
"percentage": book.Percentage,
|
||||
},
|
||||
}
|
||||
if book.Epubcfi != nil {
|
||||
koreaderData["data"].(map[string]interface{})["epubcfi"] = *book.Epubcfi
|
||||
}
|
||||
if book.Chapter != nil {
|
||||
koreaderData["data"].(map[string]interface{})["chapter"] = *book.Chapter
|
||||
}
|
||||
if book.Character != nil {
|
||||
koreaderData["data"].(map[string]interface{})["character"] = *book.Character
|
||||
}
|
||||
if book.Page != nil {
|
||||
koreaderData["data"].(map[string]interface{})["page"] = *book.Page
|
||||
}
|
||||
if book.TotalPages != nil {
|
||||
koreaderData["data"].(map[string]interface{})["total_pages"] = *book.TotalPages
|
||||
}
|
||||
|
||||
existingData := map[string]interface{}{
|
||||
"source": existingProgress.LastSyncSource.String,
|
||||
"timestamp": existingProgress.LastSyncTimestamp.Time,
|
||||
"data": map[string]interface{}{
|
||||
"percentage": existingProgress.Percentage.Float64,
|
||||
},
|
||||
}
|
||||
if existingProgress.Epubcfi.Valid {
|
||||
existingData["data"].(map[string]interface{})["epubcfi"] = existingProgress.Epubcfi.String
|
||||
}
|
||||
if existingProgress.Chapter.Valid {
|
||||
existingData["data"].(map[string]interface{})["chapter"] = existingProgress.Chapter.Int32
|
||||
}
|
||||
if existingProgress.CharacterOffset.Valid {
|
||||
existingData["data"].(map[string]interface{})["character"] = existingProgress.CharacterOffset.Int64
|
||||
}
|
||||
if existingProgress.CurrentPage.Valid {
|
||||
existingData["data"].(map[string]interface{})["page"] = existingProgress.CurrentPage.Int32
|
||||
}
|
||||
if existingProgress.TotalPages.Valid {
|
||||
existingData["data"].(map[string]interface{})["total_pages"] = existingProgress.TotalPages.Int32
|
||||
}
|
||||
|
||||
conflictData := map[string]interface{}{
|
||||
"koreader": koreaderData,
|
||||
"existing": existingData,
|
||||
}
|
||||
conflictDataJSON, _ := json.Marshal(conflictData)
|
||||
|
||||
_, err := h.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
ConflictType: "progress",
|
||||
ConflictData: conflictDataJSON,
|
||||
})
|
||||
if err == nil {
|
||||
h.connManager.BroadcastConflictNotification(
|
||||
mediaItemID.Bytes,
|
||||
"detection",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
deviceInfo := book.DeviceInfo
|
||||
if deviceInfo.DeviceModel == "" {
|
||||
deviceInfo.DeviceModel = "KOReader Device"
|
||||
}
|
||||
|
||||
h.connManager.BroadcastProgressUpdate(
|
||||
uuid.UUID(mediaItemID.Bytes),
|
||||
mediaItemID.Bytes,
|
||||
book.Percentage,
|
||||
wsync.SourceDevice{
|
||||
ID: uuid.UUID(userID.Bytes).String(),
|
||||
Name: deviceInfo.DeviceModel,
|
||||
ID: uuid.UUID(deviceID.Bytes).String(),
|
||||
Name: deviceModel,
|
||||
Type: "koreader",
|
||||
},
|
||||
)
|
||||
|
||||
_, err = h.db.UpdateDeviceLastSync(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: false})
|
||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func textPtrToPgText(s *string) pgtype.Text {
|
||||
if s != nil {
|
||||
return pgtype.Text{String: *s, Valid: true}
|
||||
}
|
||||
return pgtype.Text{}
|
||||
}
|
||||
|
||||
func intPtrToPgInt4(i *int) pgtype.Int4 {
|
||||
if i != nil {
|
||||
return pgtype.Int4{Int32: int32(*i), Valid: true}
|
||||
}
|
||||
return pgtype.Int4{}
|
||||
}
|
||||
|
||||
func int64PtrToPgInt8(i *int64) pgtype.Int8 {
|
||||
if i != nil {
|
||||
return pgtype.Int8{Int64: *i, Valid: true}
|
||||
}
|
||||
return pgtype.Int8{}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
@@ -597,26 +750,42 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
Percentage: progress.Percentage.Float64,
|
||||
}
|
||||
|
||||
if progress.Epubcfi.Valid {
|
||||
progressData.Epubcfi = &progress.Epubcfi.String
|
||||
// CFI/xpointer are meaningless for image-based fixed-layout content; the
|
||||
// page index is the canonical locator. Only return them for reflowable docs.
|
||||
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||
isFixed := formatGroup == wsync.FormatGroupFixedLayout ||
|
||||
formatGroup == wsync.FormatGroupComicArchive
|
||||
|
||||
if !isFixed {
|
||||
if progress.Epubcfi.Valid {
|
||||
progressData.Epubcfi = &progress.Epubcfi.String
|
||||
}
|
||||
if progress.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) {
|
||||
h.convertCFIToXPointer(c, mediaItem, progress, &progressData)
|
||||
}
|
||||
}
|
||||
if progress.Chapter.Valid {
|
||||
progressData.Chapter = new(int(progress.Chapter.Int32))
|
||||
progress := int(progress.Chapter.Int32)
|
||||
progressData.Chapter = &progress
|
||||
}
|
||||
if progress.ChapterProgress.Valid {
|
||||
progressData.ChapterProgress = new(progress.ChapterProgress.Float64)
|
||||
progress := progress.ChapterProgress.Float64
|
||||
progressData.ChapterProgress = &progress
|
||||
}
|
||||
if progress.CharacterOffset.Valid {
|
||||
progressData.Character = new(int64(progress.CharacterOffset.Int64))
|
||||
progress := progress.CharacterOffset.Int64
|
||||
progressData.Character = &progress
|
||||
}
|
||||
if progress.CurrentPage.Valid {
|
||||
progressData.Page = new(int(progress.CurrentPage.Int32))
|
||||
progress := int(progress.CurrentPage.Int32)
|
||||
progressData.Page = &progress
|
||||
}
|
||||
if progress.TotalPages.Valid {
|
||||
progressData.TotalPages = new(int(progress.TotalPages.Int32))
|
||||
progress := int(progress.TotalPages.Int32)
|
||||
progressData.TotalPages = &progress
|
||||
}
|
||||
|
||||
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -629,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
|
||||
for _, ann := range annotations {
|
||||
if ann.AnnotationType == "highlight" {
|
||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
|
||||
pos0 := ann.StartPosition.String
|
||||
pos1 := ann.EndPosition.String
|
||||
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
|
||||
pos0 = converted
|
||||
}
|
||||
}
|
||||
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
|
||||
pos1 = converted
|
||||
}
|
||||
}
|
||||
highlight := KOReaderHighlight{
|
||||
Text: ann.SelectionText,
|
||||
Pos0: ann.StartPosition.String,
|
||||
Pos1: ann.EndPosition.String,
|
||||
Pos0: pos0,
|
||||
Pos1: pos1,
|
||||
Color: ann.Color.String,
|
||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if ann.NoteText.Valid && ann.NoteText.String != "" {
|
||||
highlight.Notes = ann.NoteText.String
|
||||
}
|
||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
|
||||
} else if ann.AnnotationType == "note" {
|
||||
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
||||
Text: ann.SelectionText,
|
||||
@@ -645,6 +830,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
for _, bm := range bookmarks {
|
||||
pos0 := bm.Position.String
|
||||
if pos0 == "" && bm.CfiPosition.Valid {
|
||||
pos0 = bm.CfiPosition.String
|
||||
}
|
||||
koreaderBookmark := KOReaderBookmark{
|
||||
Text: bm.Title,
|
||||
Pos0: pos0,
|
||||
Pos1: pos0,
|
||||
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
|
||||
}
|
||||
if bm.Notes.Valid && bm.Notes.String != "" {
|
||||
koreaderBookmark.Notes = bm.Notes.String
|
||||
}
|
||||
if bm.ChapterNumber.Valid {
|
||||
koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32)
|
||||
}
|
||||
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
||||
}
|
||||
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
DeletedAt: cutoff,
|
||||
})
|
||||
for _, ts := range tombstones {
|
||||
var dd map[string]interface{}
|
||||
if len(ts.DeviceSyncData) > 0 {
|
||||
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||
}
|
||||
if dd == nil {
|
||||
dd = map[string]interface{}{}
|
||||
}
|
||||
dd["dedup_key"] = ts.DedupKey.String
|
||||
if ts.AnnotationType == "highlight" {
|
||||
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
|
||||
} else if ts.AnnotationType == "bookmark" {
|
||||
annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd)
|
||||
}
|
||||
}
|
||||
|
||||
lastSync := "never"
|
||||
if progress.LastSyncTimestamp.Valid {
|
||||
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
||||
@@ -662,6 +893,55 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, metadata)
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
|
||||
if h.libraryService == nil {
|
||||
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
|
||||
return
|
||||
}
|
||||
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
|
||||
return
|
||||
}
|
||||
if epubPath == "" {
|
||||
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
|
||||
return
|
||||
}
|
||||
|
||||
converter := wsync.NewCFIConverter(epubPath)
|
||||
contextText := ""
|
||||
if progress.ContextText.Valid {
|
||||
contextText = progress.ContextText.String
|
||||
}
|
||||
pct := progress.Percentage.Float64
|
||||
|
||||
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
|
||||
return
|
||||
}
|
||||
if result != nil && result.XPointer != "" {
|
||||
progressData.KoreaderXPointer = &result.XPointer
|
||||
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string {
|
||||
if h.libraryService == nil || epubcfi == "" {
|
||||
return ""
|
||||
}
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil || epubPath == "" {
|
||||
return ""
|
||||
}
|
||||
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
if loc.Position != "" && loc.Position != epubcfi {
|
||||
return loc.Position
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
@@ -693,14 +973,15 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||
if err == nil {
|
||||
percentRead = progress.Percentage.Float64
|
||||
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
||||
pagesRemaining = new(int(progress.TotalPages.Int32 - progress.CurrentPage.Int32))
|
||||
pages := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
|
||||
pagesRemaining = &pages
|
||||
}
|
||||
if progress.LastReadAt.Valid {
|
||||
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgItemUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -800,15 +1081,36 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.Text,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": bookmark.Datetime,
|
||||
"pos0": bookmark.Pos0,
|
||||
"page": bookmark.Page,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
bookmarksSynced++
|
||||
result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmark.Text,
|
||||
Position: position,
|
||||
ChapterNumber: int32(bookmark.Chapter),
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.Text,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,15 +1132,35 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", note.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: note.Notes,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
"pos0": note.Pos0,
|
||||
"page": note.Page,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
notesSynced++
|
||||
result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: note.Notes,
|
||||
Position: position,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
notesSynced++
|
||||
}
|
||||
} else {
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: note.Notes,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
notesSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,17 +1187,51 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
color = highlight.Color
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.Text,
|
||||
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
|
||||
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
|
||||
Color: pgtype.Text{String: color, Valid: true},
|
||||
})
|
||||
if h.annotationSvc != nil {
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
|
||||
|
||||
if err == nil {
|
||||
highlightsSynced++
|
||||
pctStart := 0.0
|
||||
if highlight.Percentage != nil {
|
||||
pctStart = *highlight.Percentage
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": highlight.Datetime,
|
||||
"pos0": highlight.Pos0,
|
||||
"pos1": highlight.Pos1,
|
||||
"page": highlight.Page,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: color,
|
||||
NoteText: highlight.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSynced++
|
||||
}
|
||||
} else {
|
||||
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.Text,
|
||||
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
|
||||
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
|
||||
Color: pgtype.Text{String: color, Valid: true},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
highlightsSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ func parseUUID(uuidStr string) (pgtype.UUID, error) {
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
return pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}, nil
|
||||
return pgtype.UUID{Bytes: parsedUUID, Valid: true}, nil
|
||||
}
|
||||
|
||||
// GetUserVisibleLibrariesData returns libraries for SSR (not JSON response)
|
||||
|
||||
+340
-60
@@ -3,19 +3,23 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -60,33 +64,41 @@ type CreateMediaItemRequest struct {
|
||||
|
||||
// UpdateMediaItemRequest represents the request for updating a media item
|
||||
type UpdateMediaItemRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
Tags []string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors []string `json:"contributors"`
|
||||
// NEW: Reading direction and comic metadata fields
|
||||
MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'
|
||||
ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical'
|
||||
SeriesCount int32 `json:"series_count"`
|
||||
Volume int32 `json:"volume"`
|
||||
Imprint string `json:"imprint"`
|
||||
AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult'
|
||||
WebURL string `json:"web_url"`
|
||||
MetadataNotes string `json:"metadata_notes"`
|
||||
CommunityRating float64 `json:"community_rating"`
|
||||
StoryArc string `json:"story_arc"`
|
||||
IsBlackAndWhite bool `json:"is_black_and_white"`
|
||||
AlternateInfo string `json:"alternate_info"` // JSON string
|
||||
ScanInformation string `json:"scan_information"`
|
||||
Summary string `json:"summary"`
|
||||
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `form:"author" json:"author"`
|
||||
ISBN string `form:"isbn" json:"isbn"`
|
||||
Description string `form:"description" json:"description"`
|
||||
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
|
||||
CoverAction string `form:"cover_action" json:"cover_action"`
|
||||
Series string `form:"series" json:"series"`
|
||||
SeriesNumber int32 `form:"series_number" json:"series_number"`
|
||||
Tags []string `form:"tags" json:"tags"`
|
||||
ASIN string `form:"asin" json:"asin"`
|
||||
DatePublished string `form:"date_published" json:"date_published"`
|
||||
Publisher string `form:"publisher" json:"publisher"`
|
||||
Contributors []string `form:"contributors" json:"contributors"`
|
||||
Language string `form:"language" json:"language"`
|
||||
Edition string `form:"edition" json:"edition"`
|
||||
PageCount int32 `form:"page_count" json:"page_count"`
|
||||
Genre string `form:"genre" json:"genre"`
|
||||
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
|
||||
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
|
||||
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
|
||||
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
|
||||
MangaType string `form:"manga_type" json:"manga_type"`
|
||||
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
|
||||
SeriesCount int32 `form:"series_count" json:"series_count"`
|
||||
Volume int32 `form:"volume" json:"volume"`
|
||||
Imprint string `form:"imprint" json:"imprint"`
|
||||
AgeRating string `form:"age_rating" json:"age_rating"`
|
||||
WebURL string `form:"web_url" json:"web_url"`
|
||||
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
|
||||
CommunityRating float64 `form:"community_rating" json:"community_rating"`
|
||||
StoryArc string `form:"story_arc" json:"story_arc"`
|
||||
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
|
||||
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
|
||||
ScanInformation string `form:"scan_information" json:"scan_information"`
|
||||
Summary string `form:"summary" json:"summary"`
|
||||
}
|
||||
|
||||
// CreateMediaNoteRequest represents the request for creating a media note
|
||||
@@ -124,6 +136,8 @@ type MediaHandler struct {
|
||||
worker *services.Worker
|
||||
libraryService *services.LibraryService
|
||||
searchService *services.SearchService
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
}
|
||||
|
||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
||||
@@ -138,6 +152,14 @@ func NewMediaHandler(db *database.Queries, libraryService *services.LibraryServi
|
||||
return mh
|
||||
}
|
||||
|
||||
func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
mh.progressSvc = svc
|
||||
}
|
||||
|
||||
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
mh.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||
if err != nil {
|
||||
@@ -548,7 +570,22 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
||||
PageCount: existingMedia.PageCount,
|
||||
GoodreadsID: existingMedia.GoodreadsID,
|
||||
OpenlibraryID: existingMedia.OpenlibraryID,
|
||||
GoogleBooksID: existingMedia.GoogleBooksID,
|
||||
CoverImagePath: existingMedia.CoverImagePath,
|
||||
MangaType: existingMedia.MangaType,
|
||||
ReadingDirection: existingMedia.ReadingDirection,
|
||||
SeriesCount: existingMedia.SeriesCount,
|
||||
Volume: existingMedia.Volume,
|
||||
Imprint: existingMedia.Imprint,
|
||||
AgeRating: existingMedia.AgeRating,
|
||||
WebUrl: existingMedia.WebUrl,
|
||||
MetadataNotes: existingMedia.MetadataNotes,
|
||||
CommunityRating: existingMedia.CommunityRating,
|
||||
StoryArc: existingMedia.StoryArc,
|
||||
IsBlackAndWhite: existingMedia.IsBlackAndWhite,
|
||||
AlternateInfo: existingMedia.AlternateInfo,
|
||||
ScanInformation: existingMedia.ScanInformation,
|
||||
Summary: existingMedia.Summary,
|
||||
}
|
||||
|
||||
if update.Updates.Title != nil {
|
||||
@@ -728,7 +765,7 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
||||
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -836,7 +873,7 @@ func (mh *MediaHandler) GetMediaRating(c *echo.Context) error {
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -889,12 +926,12 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
progress, err := mh.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
||||
progress, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"current_page": 0,
|
||||
"total_pages": nil,
|
||||
@@ -903,7 +940,27 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, progress)
|
||||
resp := map[string]interface{}{
|
||||
"id": progress.ID,
|
||||
"media_item_id": progress.MediaItemID,
|
||||
"user_id": progress.UserID,
|
||||
"current_page": progress.CurrentPage,
|
||||
"total_pages": progress.TotalPages,
|
||||
"last_read_at": progress.LastReadAt,
|
||||
"percentage": progress.Percentage,
|
||||
"character_offset": progress.CharacterOffset,
|
||||
"epubcfi": progress.Epubcfi,
|
||||
"chapter": progress.Chapter,
|
||||
"chapter_progress": progress.ChapterProgress,
|
||||
"format_group": progress.FormatGroup,
|
||||
"total_characters": progress.TotalCharacters,
|
||||
"chapter_count": progress.ChapterCount,
|
||||
"last_sync_device": progress.LastSyncDevice,
|
||||
"last_sync_source": progress.LastSyncSource,
|
||||
"last_sync_timestamp": progress.LastSyncTimestamp,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
|
||||
@@ -921,24 +978,94 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CurrentPage int32 `json:"current_page"`
|
||||
TotalPages int32 `json:"total_pages"`
|
||||
Epubcfi string `json:"epubcfi"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
CurrentPage *int32 `json:"current_page"`
|
||||
TotalPages *int32 `json:"total_pages"`
|
||||
Epubcfi *string `json:"epubcfi"`
|
||||
ContextText *string `json:"context_text"`
|
||||
Percentage *float64 `json:"percentage"`
|
||||
Chapter *int `json:"chapter"`
|
||||
ChapterProgress *float64 `json:"chapter_progress"`
|
||||
CharacterOffset *int64 `json:"character_offset"`
|
||||
ReadingMode *string `json:"reading_mode"`
|
||||
ZoomLevel *float64 `json:"zoom_level"`
|
||||
ScrollX *float64 `json:"scroll_position_x"`
|
||||
ScrollY *float64 `json:"scroll_position_y"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
|
||||
if mh.progressSvc != nil {
|
||||
saveReq := wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Source: "web",
|
||||
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Percentage: req.Percentage,
|
||||
Epubcfi: req.Epubcfi,
|
||||
ContextText: req.ContextText,
|
||||
CharacterOffset: req.CharacterOffset,
|
||||
Chapter: req.Chapter,
|
||||
ChapterProgress: req.ChapterProgress,
|
||||
CurrentPage: nil,
|
||||
TotalPages: nil,
|
||||
ZoomLevel: req.ZoomLevel,
|
||||
ScrollX: req.ScrollX,
|
||||
ScrollY: req.ScrollY,
|
||||
ReadingMode: req.ReadingMode,
|
||||
DeviceType: "web",
|
||||
DeviceName: "Web",
|
||||
Broadcast: true,
|
||||
}
|
||||
if req.CurrentPage != nil {
|
||||
cp := int(*req.CurrentPage)
|
||||
saveReq.CurrentPage = &cp
|
||||
}
|
||||
if req.TotalPages != nil {
|
||||
tp := int(*req.TotalPages)
|
||||
saveReq.TotalPages = &tp
|
||||
}
|
||||
|
||||
if req.Percentage != nil && *req.Percentage < 0.005 {
|
||||
existing, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err == nil && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 {
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "ignored"})
|
||||
}
|
||||
}
|
||||
|
||||
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
percentage := 0.0
|
||||
if req.Percentage != nil {
|
||||
percentage = *req.Percentage
|
||||
}
|
||||
epubcfi := ""
|
||||
if req.Epubcfi != nil {
|
||||
epubcfi = *req.Epubcfi
|
||||
}
|
||||
currentPage := int32(0)
|
||||
if req.CurrentPage != nil {
|
||||
currentPage = *req.CurrentPage
|
||||
}
|
||||
totalPages := int32(0)
|
||||
if req.TotalPages != nil {
|
||||
totalPages = *req.TotalPages
|
||||
}
|
||||
|
||||
progress, err := mh.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Percentage: pgtype.Float8{Float64: req.Percentage, Valid: true},
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
CharacterOffset: pgtype.Int8{Valid: false},
|
||||
Epubcfi: pgtype.Text{String: req.Epubcfi, Valid: req.Epubcfi != ""},
|
||||
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
|
||||
Chapter: pgtype.Int4{Valid: false},
|
||||
ChapterProgress: pgtype.Float8{Valid: false},
|
||||
ViewportX: pgtype.Float8{Valid: false},
|
||||
@@ -950,8 +1077,8 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
ReadingMode: pgtype.Text{Valid: false},
|
||||
LastSyncDevice: pgtype.Text{String: "web", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "web", Valid: true},
|
||||
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
||||
CurrentPage: pgtype.Int4{Int32: currentPage, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: totalPages, Valid: totalPages > 0},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1026,7 +1153,7 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
|
||||
_, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1109,25 +1236,52 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
||||
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
||||
|
||||
// Validate and normalize ISBN
|
||||
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
||||
if err != nil && req.ISBN != "" {
|
||||
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
|
||||
}
|
||||
|
||||
// Use normalized ISBN if valid, otherwise empty string
|
||||
isbnValue := normalizedISBN
|
||||
if err != nil {
|
||||
isbnValue = ""
|
||||
}
|
||||
|
||||
if req.CoverAction == "" {
|
||||
req.CoverAction = "keep"
|
||||
}
|
||||
|
||||
existing, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||
}
|
||||
|
||||
coverPath := existing.CoverImagePath.String
|
||||
|
||||
if req.CoverAction == "remove" {
|
||||
coverPath = ""
|
||||
} else if req.CoverAction == "upload" {
|
||||
file, err := c.FormFile("cover_file")
|
||||
if err == nil {
|
||||
savedPath, err := mh.saveCoverImage(*c, mediaUUID, file)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
||||
}
|
||||
coverPath = savedPath
|
||||
}
|
||||
}
|
||||
|
||||
var alternateInfoBytes []byte
|
||||
if req.AlternateInfo != "" {
|
||||
alternateInfoBytes = []byte(req.AlternateInfo)
|
||||
}
|
||||
|
||||
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
|
||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: req.Tags,
|
||||
@@ -1137,11 +1291,37 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Contributors: req.Contributors,
|
||||
ContributorsSearch: contributorsSearch,
|
||||
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
|
||||
Edition: pgtype.Text{String: req.Edition, Valid: req.Edition != ""},
|
||||
PageCount: pgtype.Int4{Int32: req.PageCount, Valid: req.PageCount > 0},
|
||||
Genre: pgtype.Text{String: req.Genre, Valid: req.Genre != ""},
|
||||
CopyrightYear: pgtype.Int4{Int32: req.CopyrightYear, Valid: req.CopyrightYear > 0},
|
||||
GoodreadsID: pgtype.Text{String: req.GoodreadsID, Valid: req.GoodreadsID != ""},
|
||||
OpenlibraryID: pgtype.Text{String: req.OpenlibraryID, Valid: req.OpenlibraryID != ""},
|
||||
GoogleBooksID: pgtype.Text{String: req.GoogleBooksID, Valid: req.GoogleBooksID != ""},
|
||||
MangaType: pgtype.Text{String: req.MangaType, Valid: req.MangaType != ""},
|
||||
ReadingDirection: pgtype.Text{String: req.ReadingDirection, Valid: req.ReadingDirection != ""},
|
||||
SeriesCount: pgtype.Int4{Int32: req.SeriesCount, Valid: req.SeriesCount > 0},
|
||||
Volume: pgtype.Int4{Int32: req.Volume, Valid: req.Volume > 0},
|
||||
Imprint: pgtype.Text{String: req.Imprint, Valid: req.Imprint != ""},
|
||||
AgeRating: pgtype.Text{String: req.AgeRating, Valid: req.AgeRating != ""},
|
||||
WebUrl: pgtype.Text{String: req.WebURL, Valid: req.WebURL != ""},
|
||||
MetadataNotes: pgtype.Text{String: req.MetadataNotes, Valid: req.MetadataNotes != ""},
|
||||
CommunityRating: pgtype.Float8{Float64: req.CommunityRating, Valid: req.CommunityRating > 0},
|
||||
StoryArc: pgtype.Text{String: req.StoryArc, Valid: req.StoryArc != ""},
|
||||
IsBlackAndWhite: pgtype.Bool{Bool: req.IsBlackAndWhite, Valid: req.IsBlackAndWhite},
|
||||
AlternateInfo: alternateInfoBytes,
|
||||
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
||||
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
c.Response().Header().Set("HX-Redirect", "/media/"+mediaID)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
@@ -1214,14 +1394,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
var note database.MediaNotes
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: req.Position,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
note = result.Note
|
||||
} else {
|
||||
var err error
|
||||
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, note)
|
||||
@@ -1237,7 +1434,7 @@ func (mh *MediaHandler) GetMediaNote(c *echo.Context) error {
|
||||
|
||||
note, err := mh.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1282,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
if mh.annotationSvc != nil {
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
} else {
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1351,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
|
||||
color = req.Color
|
||||
}
|
||||
|
||||
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: req.StartPosition,
|
||||
EndPosition: req.EndPosition,
|
||||
Color: color,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusCreated, result.Highlight)
|
||||
}
|
||||
|
||||
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||
@@ -1377,7 +1598,7 @@ func (mh *MediaHandler) GetMediaHighlight(c *echo.Context) error {
|
||||
|
||||
highlight, err := mh.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1439,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
||||
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1684,3 +1914,53 @@ func jsonBytesToMap(b []byte) map[string]interface{} {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file *multipart.FileHeader) (string, error) {
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open uploaded file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
imageData, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read uploaded file: %w", err)
|
||||
}
|
||||
|
||||
if len(imageData) < 512 {
|
||||
return "", fmt.Errorf("file too small to be a valid image")
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(imageData)
|
||||
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
|
||||
return "", fmt.Errorf("invalid image type: %s", contentType)
|
||||
}
|
||||
|
||||
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("media item not found: %w", err)
|
||||
}
|
||||
|
||||
relativeFilePath := mediaItem.FilePath
|
||||
if relativeFilePath == "" {
|
||||
return "", fmt.Errorf("media item has no file path")
|
||||
}
|
||||
|
||||
coverRelPath := relativeFilePath + ".cover.jpg"
|
||||
|
||||
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve cover path: %w", err)
|
||||
}
|
||||
|
||||
coverDir := filepath.Dir(coverFullPath)
|
||||
if err := os.MkdirAll(coverDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create cover directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(coverFullPath, imageData, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write cover file: %w", err)
|
||||
}
|
||||
|
||||
return coverRelPath, nil
|
||||
}
|
||||
|
||||
+103
-25
@@ -49,6 +49,73 @@ func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
||||
return baseURL.Value, opdsBaseURL, nil
|
||||
}
|
||||
|
||||
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
||||
token := c.QueryParam("token")
|
||||
if token == "" {
|
||||
token = strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func appendToken(url, token string) string {
|
||||
if token == "" {
|
||||
return url
|
||||
}
|
||||
if strings.Contains(url, "?") {
|
||||
return url + "&token=" + token
|
||||
}
|
||||
return url + "?token=" + token
|
||||
}
|
||||
|
||||
// resolveMimeType returns the mime type for a media item, preferring the stored
|
||||
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
||||
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
||||
if mime.Valid && mime.String != "" {
|
||||
return mime.String
|
||||
}
|
||||
if formatMime.Valid && formatMime.String != "" {
|
||||
return formatMime.String
|
||||
}
|
||||
return "application/epub+zip"
|
||||
}
|
||||
|
||||
// isComicArchive reports whether a format group represents a comic/manga
|
||||
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
|
||||
// and should not be offered as EPUB/KEPUB/PDF conversions.
|
||||
func isComicArchive(formatGroup string) bool {
|
||||
return strings.EqualFold(formatGroup, "comic_archive")
|
||||
}
|
||||
|
||||
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
|
||||
// file path's extension, defaulting to "epub" when it cannot be determined.
|
||||
func formatLabelFromPath(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return "epub"
|
||||
case ".pdf":
|
||||
return "pdf"
|
||||
case ".cbz":
|
||||
return "cbz"
|
||||
case ".cbr":
|
||||
return "cbr"
|
||||
case ".cb7":
|
||||
return "cb7"
|
||||
case ".cbt":
|
||||
return "cbt"
|
||||
case ".mobi":
|
||||
return "mobi"
|
||||
case ".azw", ".azw3":
|
||||
return "azw3"
|
||||
case ".txt":
|
||||
return "txt"
|
||||
case "":
|
||||
return "epub"
|
||||
default:
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
||||
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
@@ -140,11 +207,12 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
token := h.getAuthToken(c)
|
||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
|
||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID)
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
||||
|
||||
// Add entries
|
||||
@@ -170,16 +238,21 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
entry.SetSummary(item.Description.String)
|
||||
}
|
||||
|
||||
// Add acquisition links
|
||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||
// Add acquisition link using the item's real mime type
|
||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||
|
||||
// Add format variants
|
||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
|
||||
// archives are served as-is in their native format.
|
||||
if !isComicArchive(item.FormatGroup) {
|
||||
if device.DeviceType == "kobo" {
|
||||
kepubURL := downloadURL + "&format=kepub"
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
}
|
||||
|
||||
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL)
|
||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||
pdfURL := downloadURL + "&format=pdf"
|
||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||
}
|
||||
|
||||
// Add canonical identifier
|
||||
entry.SetIdentifier(bookUUID)
|
||||
@@ -194,7 +267,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
if err == nil {
|
||||
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||
for _, col := range collections {
|
||||
if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID {
|
||||
if col.UserID.Valid && col.UserID.Bytes == userUUID {
|
||||
entry.AddCategory(collectionScheme, col.Name)
|
||||
}
|
||||
}
|
||||
@@ -267,10 +340,11 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
token := h.getAuthToken(c)
|
||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
|
||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
|
||||
// Add entries (same as catalog)
|
||||
@@ -296,11 +370,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
entry.SetSummary(item.Description.String)
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||
|
||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
// Only offer kepub conversion for ebooks; comic archives are served
|
||||
// as-is in their native format.
|
||||
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
|
||||
kepubURL := downloadURL + "&format=kepub"
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
}
|
||||
|
||||
entry.SetIdentifier(bookUUID)
|
||||
|
||||
@@ -312,7 +390,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
if err == nil {
|
||||
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||
for _, col := range collections {
|
||||
if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID {
|
||||
if col.UserID.Valid && col.UserID.Bytes == userUUID {
|
||||
entry.AddCategory(collectionScheme, col.Name)
|
||||
}
|
||||
}
|
||||
@@ -367,7 +445,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
||||
// Check if book is in visible library
|
||||
visible := false
|
||||
for _, lib := range libraries {
|
||||
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
||||
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
||||
visible = true
|
||||
break
|
||||
}
|
||||
@@ -514,7 +592,7 @@ func (h *OPDSHandler) GetCoverImage(c *echo.Context) error {
|
||||
// Check if book is in visible library
|
||||
visible := false
|
||||
for _, lib := range libraries {
|
||||
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
||||
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
||||
visible = true
|
||||
break
|
||||
}
|
||||
@@ -599,7 +677,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
|
||||
@@ -655,7 +733,7 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
||||
// Check if book is in visible library
|
||||
visible := false
|
||||
for _, lib := range libraries {
|
||||
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
||||
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
||||
visible = true
|
||||
break
|
||||
}
|
||||
@@ -682,14 +760,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
||||
|
||||
formatList := []FormatInfo{}
|
||||
|
||||
// Add EPUB format (always available if media item exists)
|
||||
// Add the primary/native format (always available if media item exists)
|
||||
fileSize := int64(0)
|
||||
if mediaItem.FileSize.Valid {
|
||||
fileSize = mediaItem.FileSize.Int64
|
||||
}
|
||||
|
||||
formatList = append(formatList, FormatInfo{
|
||||
FormatType: "epub",
|
||||
FormatType: formatLabelFromPath(mediaItem.FilePath),
|
||||
FilePath: mediaItem.FilePath,
|
||||
FileSha256: func() string {
|
||||
if mediaItem.FileSha256.Valid {
|
||||
@@ -791,7 +869,7 @@ func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
||||
}
|
||||
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"opds_token": map[string]interface{}{
|
||||
|
||||
@@ -39,7 +39,7 @@ type ProcessingIssueStats struct {
|
||||
|
||||
// ListProcessingIssues returns all processing issues for a library
|
||||
func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
libraryID, err := uuid.Parse(c.Param("libraryId"))
|
||||
libraryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
|
||||
// GetProcessingIssueStats returns statistics about processing issues
|
||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error {
|
||||
libraryID, err := uuid.Parse(c.Param("libraryId"))
|
||||
libraryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -43,7 +44,7 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
|
||||
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"media_item_id": mediaItemID,
|
||||
"progress": nil,
|
||||
@@ -75,7 +76,7 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
|
||||
response["location_references"].(map[string]interface{})["chapter_progress"] = progress.ChapterProgress.Float64
|
||||
}
|
||||
if progress.CharacterOffset.Valid {
|
||||
response["location_references"].(map[string]interface{})["character"] = int64(progress.CharacterOffset.Int64)
|
||||
response["location_references"].(map[string]interface{})["character"] = progress.CharacterOffset.Int64
|
||||
}
|
||||
|
||||
deviceSync := map[string]interface{}{}
|
||||
@@ -138,7 +139,7 @@ func (h *Handler) UpdateUniversalProgress(c *echo.Context) error {
|
||||
}
|
||||
|
||||
currentPage := 0
|
||||
totalPages := 200
|
||||
totalPages := 0
|
||||
if req.Location.Page != nil {
|
||||
currentPage = *req.Location.Page
|
||||
}
|
||||
@@ -260,6 +261,8 @@ type ProgressWithMedia struct {
|
||||
ProgressPercentage float64 `json:"-"`
|
||||
EpubCFI string `json:"-"`
|
||||
LastUpdated string `json:"-"`
|
||||
FormatGroup string `json:"format_group"`
|
||||
EstimatedPages int `json:"estimated_pages"`
|
||||
}
|
||||
|
||||
// GetAllProgress retrieves all progress for a user with sync source info
|
||||
@@ -303,7 +306,7 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
|
||||
|
||||
lastUpdated := ""
|
||||
if progress.LastReadAt.Valid {
|
||||
lastUpdated = progress.LastReadAt.Time.Format("2006-01-02 15:04")
|
||||
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||
}
|
||||
|
||||
progressList = append(progressList, ProgressWithMedia{
|
||||
@@ -317,10 +320,12 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
|
||||
LastReadAt: progress.LastReadAt.Time,
|
||||
Epubcfi: epubcfi,
|
||||
LastSyncDevice: deviceName,
|
||||
ProgressPercentage: progress.Percentage.Float64,
|
||||
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||
EpubCFI: epubcfi,
|
||||
LastUpdated: lastUpdated,
|
||||
DeviceIcon: getDeviceIcon(deviceName),
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -370,16 +375,19 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
}
|
||||
|
||||
progressList = append(progressList, ProgressWithMedia{
|
||||
MediaItemID: progress.MediaItemID.Bytes,
|
||||
Title: mediaItem.Title,
|
||||
Author: author,
|
||||
CoverImagePath: coverPath,
|
||||
Percentage: progress.Percentage.Float64,
|
||||
CurrentPage: progress.CurrentPage.Int32,
|
||||
TotalPages: progress.TotalPages.Int32,
|
||||
LastReadAt: progress.LastReadAt.Time,
|
||||
Epubcfi: epubcfi,
|
||||
LastSyncDevice: deviceName,
|
||||
MediaItemID: progress.MediaItemID.Bytes,
|
||||
Title: mediaItem.Title,
|
||||
Author: author,
|
||||
CoverImagePath: coverPath,
|
||||
Percentage: progress.Percentage.Float64,
|
||||
CurrentPage: progress.CurrentPage.Int32,
|
||||
TotalPages: progress.TotalPages.Int32,
|
||||
LastReadAt: progress.LastReadAt.Time,
|
||||
Epubcfi: epubcfi,
|
||||
LastSyncDevice: deviceName,
|
||||
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -28,3 +29,124 @@ func TestGetDeviceIcon_Unknown(t *testing.T) {
|
||||
result = getDeviceIcon("")
|
||||
assert.Equal(t, "📚", result)
|
||||
}
|
||||
|
||||
func TestTextPtrToPgText(t *testing.T) {
|
||||
t.Run("nil returns invalid", func(t *testing.T) {
|
||||
result := textPtrToPgText(nil)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
t.Run("non-nil returns valid", func(t *testing.T) {
|
||||
s := "epubcfi(/6/4/2:10)"
|
||||
result := textPtrToPgText(&s)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, s, result.String)
|
||||
})
|
||||
t.Run("empty string returns valid", func(t *testing.T) {
|
||||
s := ""
|
||||
result := textPtrToPgText(&s)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, "", result.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntPtrToPgInt4(t *testing.T) {
|
||||
t.Run("nil returns invalid", func(t *testing.T) {
|
||||
result := intPtrToPgInt4(nil)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
t.Run("non-nil returns valid", func(t *testing.T) {
|
||||
v := 5
|
||||
result := intPtrToPgInt4(&v)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, int32(5), result.Int32)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInt64PtrToPgInt8(t *testing.T) {
|
||||
t.Run("nil returns invalid", func(t *testing.T) {
|
||||
result := int64PtrToPgInt8(nil)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
t.Run("non-nil returns valid", func(t *testing.T) {
|
||||
v := int64(10000)
|
||||
result := int64PtrToPgInt8(&v)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, int64(10000), result.Int64)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFloat64PtrHelpers(t *testing.T) {
|
||||
t.Run("pgtype float64 valid", func(t *testing.T) {
|
||||
v := pgtype.Float8{Float64: 0.5, Valid: true}
|
||||
result := float64PtrVal(v)
|
||||
assert.NotNil(t, result)
|
||||
assert.InDelta(t, 0.5, *result, 0.001)
|
||||
})
|
||||
t.Run("pgtype float64 invalid", func(t *testing.T) {
|
||||
v := pgtype.Float8{Valid: false}
|
||||
result := float64PtrVal(v)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
t.Run("pgtype text valid", func(t *testing.T) {
|
||||
v := pgtype.Text{String: "hello", Valid: true}
|
||||
result := textPtrVal(v)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "hello", *result)
|
||||
})
|
||||
t.Run("pgtype text invalid", func(t *testing.T) {
|
||||
v := pgtype.Text{Valid: false}
|
||||
result := textPtrVal(v)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
t.Run("pgtype int4 valid", func(t *testing.T) {
|
||||
v := pgtype.Int4{Int32: 42, Valid: true}
|
||||
result := int32PtrVal(v)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 42, *result)
|
||||
})
|
||||
t.Run("pgtype int4 invalid", func(t *testing.T) {
|
||||
v := pgtype.Int4{Valid: false}
|
||||
result := int32PtrVal(v)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
t.Run("pgtype int8 valid", func(t *testing.T) {
|
||||
v := pgtype.Int8{Int64: 10000, Valid: true}
|
||||
result := int64PtrVal(v)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, int64(10000), *result)
|
||||
})
|
||||
t.Run("pgtype int8 invalid", func(t *testing.T) {
|
||||
v := pgtype.Int8{Valid: false}
|
||||
result := int64PtrVal(v)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func float64PtrVal(v pgtype.Float8) *float64 {
|
||||
if v.Valid {
|
||||
return &v.Float64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func textPtrVal(v pgtype.Text) *string {
|
||||
if v.Valid {
|
||||
return &v.String
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func int32PtrVal(v pgtype.Int4) *int {
|
||||
if v.Valid {
|
||||
val := int(v.Int32)
|
||||
return &val
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func int64PtrVal(v pgtype.Int8) *int64 {
|
||||
if v.Valid {
|
||||
return &v.Int64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -310,7 +310,8 @@ func uuidPtrToString(u pgtype.UUID) *string {
|
||||
if !u.Valid {
|
||||
return nil
|
||||
}
|
||||
return new(uuid.UUID(u.Bytes).String())
|
||||
s := uuid.UUID(u.Bytes).String()
|
||||
return &s
|
||||
}
|
||||
|
||||
func textPtrToString(t pgtype.Text) *string {
|
||||
@@ -324,5 +325,6 @@ func timestamptzPtrToString(t pgtype.Timestamptz) *string {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
return new(t.Time.Format("2006-01-02T15:04:05Z07:00"))
|
||||
timeFormat := t.Time.Format("2006-01-02T15:04:05Z07:00")
|
||||
return &timeFormat
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -63,7 +64,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
||||
// Get media item
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch media item"})
|
||||
@@ -108,7 +109,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||
UserID: userData.ID,
|
||||
})
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
progress = database.ReadingProgress{}
|
||||
}
|
||||
|
||||
@@ -316,7 +317,7 @@ func (h *ReaderHandler) GetReadingSpeed(c *echo.Context) error {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Return zero values if no reading has occurred
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"words_per_minute": 0,
|
||||
@@ -361,7 +362,7 @@ func (h *ReaderHandler) UpdateReadingSpeed(c *echo.Context) error {
|
||||
// Update reading speed using service
|
||||
err = h.readerService.CalculateReadingSpeed(
|
||||
c.Request().Context(),
|
||||
uuid.UUID(user.ID.Bytes),
|
||||
user.ID.Bytes,
|
||||
parsedUUID,
|
||||
req.PagesRead,
|
||||
req.TimeSpentMinutes,
|
||||
@@ -476,7 +477,7 @@ func (h *ReaderHandler) GetSettings(c *echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
|
||||
// Use reader service to get settings
|
||||
settings, err := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes))
|
||||
settings, err := h.readerService.GetSettings(c.Request().Context(), user.ID.Bytes)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"})
|
||||
}
|
||||
@@ -509,13 +510,13 @@ func (h *ReaderHandler) UpdateSettings(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// Use reader service to update settings
|
||||
err := h.readerService.UpdateSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes), settings)
|
||||
err := h.readerService.UpdateSettings(c.Request().Context(), user.ID.Bytes, settings)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
|
||||
}
|
||||
|
||||
// Return updated settings
|
||||
updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes))
|
||||
updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), user.ID.Bytes)
|
||||
return c.JSON(http.StatusOK, updatedSettings)
|
||||
}
|
||||
|
||||
@@ -597,7 +598,7 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
|
||||
// Fetch media item
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(404, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -26,7 +27,7 @@ func parseTokenUUID(tokenStr string) (pgtype.UUID, error) {
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
return pgtype.UUID{Bytes: [16]byte(tokenUUID), Valid: true}, nil
|
||||
return pgtype.UUID{Bytes: tokenUUID, Valid: true}, nil
|
||||
}
|
||||
|
||||
type RefreshTokenResponse struct {
|
||||
@@ -52,7 +53,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
|
||||
|
||||
tokenInfo, err := h.db.GetRefreshToken(c.Request().Context(), tokenUUID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to validate refresh token"})
|
||||
@@ -88,7 +89,7 @@ func (h *AuthHandler) Logout(c *echo.Context) error {
|
||||
}
|
||||
|
||||
err = h.db.RevokeRefreshToken(c.Request().Context(), tokenUUID)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke refresh token"})
|
||||
}
|
||||
|
||||
@@ -102,8 +103,8 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
|
||||
|
||||
expiresAt := time.Now().Add(refreshTokenExpiration)
|
||||
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
Token: pgtype.UUID{Bytes: [16]byte(tokenUUID), Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -62,7 +62,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// Fetch library folders from database
|
||||
libraryFolders, err := h.db.GetLibraryFolders(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryUUID), Valid: true})
|
||||
libraryFolders, err := h.db.GetLibraryFolders(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func (h *Handler) StartWatchMode(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||
}
|
||||
|
||||
if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}); err != nil {
|
||||
if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: libraryID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ func (h *Handler) StopWatchMode(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||
}
|
||||
|
||||
if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}); err != nil {
|
||||
if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: libraryID, Valid: true}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type SeriesHandler struct {
|
||||
seriesService *services.SeriesService
|
||||
}
|
||||
|
||||
func NewSeriesHandler(db *database.Queries) *SeriesHandler {
|
||||
return &SeriesHandler{
|
||||
seriesService: services.NewSeriesService(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
libraryID := c.QueryParam("library_id")
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
parsed, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
limit := 20
|
||||
if l := c.QueryParam("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
||||
limit = v
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
offset := 0
|
||||
if o := c.QueryParam("offset"); o != "" {
|
||||
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
||||
offset = v
|
||||
}
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, limit, offset)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
||||
}
|
||||
|
||||
type SeriesResponse struct {
|
||||
Name string `json:"name"`
|
||||
BookCount int64 `json:"book_count"`
|
||||
TotalInSeries int `json:"total_in_series"`
|
||||
CoverPaths []string `json:"cover_paths"`
|
||||
LastEntryAt string `json:"last_entry_at"`
|
||||
}
|
||||
|
||||
response := make([]SeriesResponse, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
response = append(response, SeriesResponse{
|
||||
Name: s.Name,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: s.CoverPaths,
|
||||
LastEntryAt: s.LastEntryAt,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"series": response,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
seriesName := c.QueryParam("name")
|
||||
if seriesName == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||
}
|
||||
|
||||
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
|
||||
}
|
||||
|
||||
bookCards := make([]BookInfo, 0, len(books))
|
||||
for _, item := range books {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookCards = append(bookCards, BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"name": seriesName,
|
||||
"books": bookCards,
|
||||
"total": len(bookCards),
|
||||
})
|
||||
}
|
||||
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||
svc := services.NewSeriesService(db)
|
||||
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewSeriesHandler_NilDB(t *testing.T) {
|
||||
handler := NewSeriesHandler(nil)
|
||||
assert.NotNil(t, handler, "Handler should not be nil even with nil DB")
|
||||
assert.NotNil(t, handler.seriesService, "Internal service should be initialized")
|
||||
}
|
||||
|
||||
func TestSeriesHandler_TextToStringConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input pgtype.Text
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "valid author text",
|
||||
input: pgtype.Text{String: "Brandon Sanderson", Valid: true},
|
||||
expected: "Brandon Sanderson",
|
||||
},
|
||||
{
|
||||
name: "empty valid text",
|
||||
input: pgtype.Text{String: "", Valid: true},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "null text returns empty",
|
||||
input: pgtype.Text{String: "ignored", Valid: false},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := textToString(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// Build sidecar config
|
||||
config := SidecarConfig{
|
||||
sidecarConfig := SidecarConfig{
|
||||
Version: "1.0",
|
||||
Bookhoard: SidecarBookhoardConfig{
|
||||
OPDSCatalog: opdsCatalogURL,
|
||||
@@ -188,7 +188,7 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
LastUpdated: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, config)
|
||||
return c.JSON(http.StatusOK, sidecarConfig)
|
||||
}
|
||||
|
||||
// DownloadSidecarConfig generates a .bookhoard.json file for device setup
|
||||
@@ -352,8 +352,8 @@ func (h *SidecarHandler) GetSystemConfiguration(c *echo.Context) error {
|
||||
|
||||
// Build config map
|
||||
result := make(map[string]string)
|
||||
for _, config := range configs {
|
||||
result[config.Key] = config.Value
|
||||
for _, systemConfig := range configs {
|
||||
result[systemConfig.Key] = systemConfig.Value
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
@@ -388,6 +388,23 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
|
||||
// Update each config value
|
||||
for key, value := range req {
|
||||
if key == "default_timezone" {
|
||||
if _, err := time.LoadLocation(value); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid timezone",
|
||||
})
|
||||
}
|
||||
err := h.db.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
|
||||
SettingKey: "default_timezone",
|
||||
SettingValue: value,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to update default timezone",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||
Key: key,
|
||||
Value: value,
|
||||
@@ -400,6 +417,25 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if newBaseURL, ok := req["base_url"]; ok && newBaseURL != "" {
|
||||
derivedConfigs := map[string]string{
|
||||
"opds_base_url": newBaseURL + "/opds",
|
||||
"api_base_url": newBaseURL + "/api",
|
||||
}
|
||||
for derivedKey, derivedValue := range derivedConfigs {
|
||||
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||
Key: derivedKey,
|
||||
Value: derivedValue,
|
||||
UpdatedBy: pgUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": fmt.Sprintf("failed to update derived config key: %s", derivedKey),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for HTMX request
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
// Fetch updated base_url for template
|
||||
@@ -408,6 +444,12 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to fetch updated configuration</div>`)
|
||||
}
|
||||
|
||||
defaultTimezone := "UTC"
|
||||
tz, err := h.db.GetSystemTimezone(ctx)
|
||||
if err == nil && tz != "" {
|
||||
defaultTimezone = tz
|
||||
}
|
||||
|
||||
// Render success message with updated form
|
||||
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
||||
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
|
||||
@@ -437,6 +479,44 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">System Defaults</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
|
||||
<select name="default_timezone" id="default_timezone" class="w-full px-4 py-2 rounded-lg border" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||
<option value="UTC"%s>UTC (UTC+0)</option>
|
||||
<option value="Pacific/Honolulu"%s>Hawaii (UTC-10)</option>
|
||||
<option value="America/Anchorage"%s>Alaska (UTC-9/-8)</option>
|
||||
<option value="America/Los_Angeles"%s>Pacific (UTC-8/-7)</option>
|
||||
<option value="America/Denver"%s>Mountain (UTC-7/-6)</option>
|
||||
<option value="America/Phoenix"%s>Mountain - no DST (UTC-7)</option>
|
||||
<option value="America/Chicago"%s>Central (UTC-6/-5)</option>
|
||||
<option value="America/New_York"%s>Eastern (UTC-5/-4)</option>
|
||||
<option value="America/Sao_Paulo"%s>Brasilia (UTC-3/-2)</option>
|
||||
<option value="Europe/London"%s>British (UTC+0/+1)</option>
|
||||
<option value="Europe/Paris"%s>Central European (UTC+1/+2)</option>
|
||||
<option value="Europe/Helsinki"%s>Eastern European (UTC+2/+3)</option>
|
||||
<option value="Europe/Moscow"%s>Moscow (UTC+3)</option>
|
||||
<option value="Asia/Tehran"%s>Iran (UTC+3:30)</option>
|
||||
<option value="Asia/Dubai"%s>Gulf (UTC+4)</option>
|
||||
<option value="Asia/Karachi"%s>Pakistan (UTC+5)</option>
|
||||
<option value="Asia/Kolkata"%s>India (UTC+5:30)</option>
|
||||
<option value="Asia/Dhaka"%s>Bangladesh (UTC+6)</option>
|
||||
<option value="Asia/Bangkok"%s>Indochina (UTC+7)</option>
|
||||
<option value="Asia/Shanghai"%s>China (UTC+8)</option>
|
||||
<option value="Asia/Tokyo"%s>Japan/Korea (UTC+9)</option>
|
||||
<option value="Australia/Darwin"%s>Australian Central (UTC+9:30)</option>
|
||||
<option value="Australia/Sydney"%s>Australian Eastern (UTC+10/+11)</option>
|
||||
<option value="Pacific/Auckland"%s>New Zealand (UTC+12/+13)</option>
|
||||
</select>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
@@ -447,7 +527,32 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
<p><strong>Device Sync:</strong> %s/api/sync</p>
|
||||
</div>
|
||||
</div>
|
||||
`, baseURL.Value, baseURL.Value, baseURL.Value, baseURL.Value))
|
||||
`, baseURL.Value,
|
||||
selectedAttr(defaultTimezone, "UTC"),
|
||||
selectedAttr(defaultTimezone, "Pacific/Honolulu"),
|
||||
selectedAttr(defaultTimezone, "America/Anchorage"),
|
||||
selectedAttr(defaultTimezone, "America/Los_Angeles"),
|
||||
selectedAttr(defaultTimezone, "America/Denver"),
|
||||
selectedAttr(defaultTimezone, "America/Phoenix"),
|
||||
selectedAttr(defaultTimezone, "America/Chicago"),
|
||||
selectedAttr(defaultTimezone, "America/New_York"),
|
||||
selectedAttr(defaultTimezone, "America/Sao_Paulo"),
|
||||
selectedAttr(defaultTimezone, "Europe/London"),
|
||||
selectedAttr(defaultTimezone, "Europe/Paris"),
|
||||
selectedAttr(defaultTimezone, "Europe/Helsinki"),
|
||||
selectedAttr(defaultTimezone, "Europe/Moscow"),
|
||||
selectedAttr(defaultTimezone, "Asia/Tehran"),
|
||||
selectedAttr(defaultTimezone, "Asia/Dubai"),
|
||||
selectedAttr(defaultTimezone, "Asia/Karachi"),
|
||||
selectedAttr(defaultTimezone, "Asia/Kolkata"),
|
||||
selectedAttr(defaultTimezone, "Asia/Dhaka"),
|
||||
selectedAttr(defaultTimezone, "Asia/Bangkok"),
|
||||
selectedAttr(defaultTimezone, "Asia/Shanghai"),
|
||||
selectedAttr(defaultTimezone, "Asia/Tokyo"),
|
||||
selectedAttr(defaultTimezone, "Australia/Darwin"),
|
||||
selectedAttr(defaultTimezone, "Australia/Sydney"),
|
||||
selectedAttr(defaultTimezone, "Pacific/Auckland"),
|
||||
baseURL.Value, baseURL.Value, baseURL.Value))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
@@ -477,3 +582,10 @@ func sanitizeAll(s string, old string, new string) string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func selectedAttr(current, value string) string {
|
||||
if current == value {
|
||||
return " selected"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -30,6 +32,28 @@ type ScanSettingsResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateTimezoneSettingsRequest struct {
|
||||
DefaultTimezone string `json:"default_timezone" validate:"required"`
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
||||
var req UpdateTimezoneSettingsRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||
}
|
||||
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
|
||||
}
|
||||
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
|
||||
SettingKey: "default_timezone",
|
||||
SettingValue: req.DefaultTimezone,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
var req UpdateScanSettingsRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
@@ -47,7 +71,7 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
SettingValue: scanFrequencyValue,
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -58,7 +82,7 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
SettingValue: autoScanValue,
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -74,9 +98,9 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
||||
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||
ScanPollIntervalSeconds: 60,
|
||||
ScanPollIntervalSeconds: 1800,
|
||||
AutoScanEnabled: true,
|
||||
})
|
||||
}
|
||||
@@ -85,9 +109,9 @@ func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
||||
|
||||
autoScanSetting, err := h.db.GetSystemSetting(c.Request().Context(), "auto_scan_enabled")
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||
ScanPollIntervalSeconds: 60,
|
||||
ScanPollIntervalSeconds: 1800,
|
||||
AutoScanEnabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
@@ -83,7 +84,7 @@ func WrapHandler(fn func(*echo.Context) error) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
err := fn(c)
|
||||
if err != nil {
|
||||
if httpErr, ok := err.(*HTTPError); ok {
|
||||
if httpErr, ok := errors.AsType[*HTTPError](err); ok {
|
||||
return RespondWithHTTPError(c, httpErr)
|
||||
}
|
||||
return RespondWithError(c, http.StatusInternalServerError, "Internal server error", err)
|
||||
|
||||
+13
-4
@@ -22,8 +22,8 @@ type Feed struct {
|
||||
|
||||
type Entry struct {
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"dc:title"`
|
||||
Creator string `xml:"dc:creator,omitempty"`
|
||||
Title string `xml:"title"`
|
||||
Author *Author `xml:"author,omitempty"`
|
||||
Updated string `xml:"updated"`
|
||||
Summary string `xml:"summary,omitempty"`
|
||||
Links []Link `xml:"link"`
|
||||
@@ -32,6 +32,12 @@ type Entry struct {
|
||||
Categories []Category `xml:"category,omitempty"`
|
||||
}
|
||||
|
||||
type Author struct {
|
||||
XMLName xml.Name `xml:"author"`
|
||||
Name string `xml:"name"`
|
||||
URI string `xml:"uri,omitempty"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
@@ -85,14 +91,17 @@ func (f *Feed) AddEntry(entry Entry) {
|
||||
|
||||
// NewEntry creates a new OPDS entry
|
||||
func NewEntry(id, title, creator, updated string) Entry {
|
||||
return Entry{
|
||||
e := Entry{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Creator: creator,
|
||||
Updated: updated,
|
||||
Links: []Link{},
|
||||
Metadata: []Meta{},
|
||||
}
|
||||
if creator != "" {
|
||||
e.Author = &Author{Name: creator}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// AddAcquisitionLink adds an acquisition link to the entry
|
||||
|
||||
+243
-177
@@ -111,13 +111,169 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Protected frontend routes (no /api prefix)
|
||||
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
|
||||
|
||||
// Helper to extract text from pgtype.Text
|
||||
getText := func(t pgtype.Text) string {
|
||||
if t.Valid {
|
||||
return t.String
|
||||
// Series browse page
|
||||
frontendProtected.GET("/series", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var errorMsg string
|
||||
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
libData := libRes.Libraries
|
||||
if libRes.IsAll {
|
||||
errorMsg = ""
|
||||
}
|
||||
|
||||
perSeriesPage := 24
|
||||
page := 1
|
||||
if p := c.QueryParam("page"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
}
|
||||
offset := (page - 1) * perSeriesPage
|
||||
|
||||
var seriesCards []templates.SeriesCardData
|
||||
totalPages := 1
|
||||
|
||||
if errorMsg == "" {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
} else {
|
||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
covers := s.CoverPaths
|
||||
if covers == nil {
|
||||
covers = []string{}
|
||||
}
|
||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||
Name: s.Name,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if seriesCards == nil {
|
||||
seriesCards = []templates.SeriesCardData{}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Series detail page (books in a specific series)
|
||||
frontendProtected.GET("/series/detail", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
var errorMsg string
|
||||
|
||||
seriesName := c.QueryParam("name")
|
||||
if seriesName == "" {
|
||||
return renderErrorPage(c, "Series name required", "bad_request")
|
||||
}
|
||||
|
||||
var bookInfoList []handlers.BookInfo
|
||||
|
||||
svc := services.NewSeriesService(cfg.Queries)
|
||||
books, err := svc.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesBooks failed: %v", err)
|
||||
errorMsg = "Error loading series books"
|
||||
} else {
|
||||
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
||||
for _, item := range books {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if bookInfoList == nil {
|
||||
bookInfoList = []handlers.BookInfo{}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
frontendProtected.GET("/tags/detail", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
var errorMsg string
|
||||
|
||||
tagName := c.QueryParam("name")
|
||||
if tagName == "" {
|
||||
return renderErrorPage(c, "Tag name required", "bad_request")
|
||||
}
|
||||
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
var bookInfoList []handlers.BookInfo
|
||||
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
|
||||
LibraryID: libRes.LibUUID,
|
||||
Column2: tagName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("GetBooksByTag failed: %v", err)
|
||||
errorMsg = "Error loading tag books"
|
||||
} else {
|
||||
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
||||
for _, item := range books {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bookInfoList == nil {
|
||||
bookInfoList = []handlers.BookInfo{}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
frontendProtected.GET("/bookshelf", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
@@ -127,52 +283,20 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
var errorMsg string
|
||||
|
||||
// Get library_id from query param or user's first library
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
||||
if err == nil && len(libraries) > 0 {
|
||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
||||
libraryID = libUUID.String()
|
||||
} else {
|
||||
errorMsg = "No libraries available"
|
||||
}
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
libData := libRes.Libraries
|
||||
|
||||
// Get libraries for dropdown
|
||||
// Fetch saved filters for SSR
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
||||
if err != nil {
|
||||
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Error loading libraries"
|
||||
}
|
||||
}
|
||||
|
||||
libData := make([]templates.LibraryData, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
libData[i] = templates.LibraryData{
|
||||
ID: libUUID.String(),
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch saved filters for SSR (using existing query)
|
||||
var savedFilters []database.SavedFilters
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
ResourceType: "media-items",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("GetSavedFilters failed: %v", err)
|
||||
savedFilters = []database.SavedFilters{}
|
||||
}
|
||||
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
ResourceType: "media-items",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("GetSavedFilters failed: %v", err)
|
||||
savedFilters = []database.SavedFilters{}
|
||||
}
|
||||
|
||||
// Fetch first page of books for SSR
|
||||
@@ -181,64 +305,51 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err == nil {
|
||||
// Check URL params for pagination
|
||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
if errorMsg == "" {
|
||||
// Check URL params for pagination
|
||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
}
|
||||
|
||||
params := services.SearchParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
LibraryID: libRes.LibUUID,
|
||||
SearchQuery: "",
|
||||
AuthorFilter: "",
|
||||
SeriesFilter: "",
|
||||
GenreFilter: "",
|
||||
TagsFilter: "",
|
||||
LanguageFilter: "",
|
||||
YearMin: 0,
|
||||
YearMax: 0,
|
||||
HasCover: pgtype.Bool{Valid: false},
|
||||
Sort: "created_at DESC",
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
var results []database.SearchMediaItemsUnifiedRow
|
||||
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
||||
if err != nil {
|
||||
log.Printf("ExecuteSearch failed: %v", err)
|
||||
} else {
|
||||
bookInfoList = make([]handlers.BookInfo, len(results))
|
||||
for i, book := range results {
|
||||
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
|
||||
bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16])
|
||||
bookInfoList[i] = handlers.BookInfo{
|
||||
MediaItemID: bookUUID.String(),
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
||||
}
|
||||
}
|
||||
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
}
|
||||
// Convert user.ID (string) to pgtype.UUID for service layer
|
||||
userUUID, err := uuid.Parse(user.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse user ID: %v", err)
|
||||
return renderErrorPage(c, "Error loading user", "user_id_error")
|
||||
}
|
||||
// Build search params (same as search.go:76-91)
|
||||
params := services.SearchParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
SearchQuery: "", // Empty for initial SSR load
|
||||
AuthorFilter: "",
|
||||
SeriesFilter: "",
|
||||
GenreFilter: "",
|
||||
TagsFilter: "",
|
||||
LanguageFilter: "",
|
||||
YearMin: 0,
|
||||
YearMax: 0,
|
||||
HasCover: pgtype.Bool{Valid: false},
|
||||
Sort: "created_at DESC",
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
// Execute search using the same handler as API (search.go:93)
|
||||
var results []database.SearchMediaItemsUnifiedRow
|
||||
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
||||
if err != nil {
|
||||
log.Printf("ExecuteSearch failed: %v", err)
|
||||
// Continue without books - will show empty state
|
||||
} else {
|
||||
// Convert to BookInfo (same as search.go:99-109)
|
||||
bookInfoList = make([]handlers.BookInfo, len(results))
|
||||
for i, book := range results {
|
||||
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
|
||||
bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16])
|
||||
bookInfoList[i] = handlers.BookInfo{
|
||||
MediaItemID: bookUUID.String(),
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
||||
}
|
||||
}
|
||||
log.Printf("SSR: fetched %d books for library %s", len(bookInfoList), libraryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,20 +370,13 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
var errorMsg string
|
||||
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
||||
if err == nil && len(libraries) > 0 {
|
||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
||||
libraryID = libUUID.String()
|
||||
}
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
libUUID, _ := uuid.Parse(libraryID)
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
pgLibUUID := libRes.LibUUID
|
||||
|
||||
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, pgLibUUID)
|
||||
if err != nil {
|
||||
log.Printf("GetDashboardPreferences failed: %v", err)
|
||||
prefs = database.UserDashboardPreferences{
|
||||
@@ -290,7 +394,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
allSections, err := cfg.DashboardService.GetDashboardSections(
|
||||
c.Request().Context(),
|
||||
userUUID,
|
||||
libUUID,
|
||||
pgLibUUID,
|
||||
limit,
|
||||
prefs.CollectionOrder,
|
||||
[]string{}, // No filtering - get all sections
|
||||
@@ -304,32 +408,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Get only visible sections for the dashboard display
|
||||
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
||||
|
||||
userUUID2, _ := uuid.Parse(user.ID)
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
|
||||
if err != nil {
|
||||
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Error loading libraries"
|
||||
}
|
||||
}
|
||||
|
||||
libData := make([]templates.LibraryData, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
libData[i] = templates.LibraryData{
|
||||
ID: libUUID.String(),
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
}
|
||||
}
|
||||
|
||||
sectionData := handlers.BuildSections(visibleSections, libraryID)
|
||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -453,30 +536,18 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
var books []handlers.BookInfo
|
||||
|
||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||
// System collection - use query type
|
||||
// System collection - need library_id for system collections
|
||||
// Get library_id from query param or default to user's first library
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
||||
if err == nil && len(libraries) > 0 {
|
||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
||||
libraryID = libUUID.String()
|
||||
}
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
libUUID, _ := uuid.Parse(libraryID)
|
||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
||||
sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||
if err != nil {
|
||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
|
||||
if secErr != nil {
|
||||
return renderErrorPage(c, "Error loading books", "books_load_error")
|
||||
}
|
||||
|
||||
// Find the matching section and convert items
|
||||
for _, section := range sections {
|
||||
if section.CollectionID.String() == collectionID {
|
||||
// Convert []database.MediaItems to []handlers.BookInfo
|
||||
bookCards := make([]handlers.BookInfo, len(section.Items))
|
||||
for i, item := range section.Items {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
@@ -492,27 +563,21 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User collection - check if library_id filter is present
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
if libraryID != "" {
|
||||
// Filter by library - reuse dashboard query
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
if libraryID != "" && !libRes.IsAll {
|
||||
libUUID, parseErr := uuid.Parse(libraryID)
|
||||
if parseErr != nil {
|
||||
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
|
||||
}
|
||||
|
||||
// Use GetCollectionItemsForDashboard for library-filtered results
|
||||
collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Limit: 1000,
|
||||
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if collErr != nil {
|
||||
books = []handlers.BookInfo{}
|
||||
} else {
|
||||
// Convert to BookInfo format (non-excluded only)
|
||||
var validItems []database.GetCollectionItemsForDashboardRow
|
||||
for _, item := range collItems {
|
||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||
@@ -533,13 +598,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
books = bookCards
|
||||
}
|
||||
} else {
|
||||
// No library filter - show all books in collection
|
||||
collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||
if err != nil {
|
||||
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||
if collErr != nil {
|
||||
books = []handlers.BookInfo{}
|
||||
}
|
||||
|
||||
// Convert to BookInfo format
|
||||
bookCards := make([]handlers.BookInfo, len(collItems))
|
||||
for i, item := range collItems {
|
||||
itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
|
||||
@@ -562,11 +625,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
Icon: collection.Icon.String,
|
||||
}
|
||||
|
||||
// Get library_id from query params for template
|
||||
libraryID := c.QueryParam("library_id")
|
||||
// Render the CollectionDetail template
|
||||
var buf bytes.Buffer
|
||||
err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Request().Context(), &buf)
|
||||
err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -932,7 +992,13 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
systemConfig := map[string]string{
|
||||
"base_url": baseURL,
|
||||
"base_url": baseURL,
|
||||
"default_timezone": "UTC",
|
||||
}
|
||||
|
||||
defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
|
||||
if err == nil && defaultTimezone != "" {
|
||||
systemConfig["default_timezone"] = defaultTimezone
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -3,7 +3,10 @@ package router
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -35,6 +38,11 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
userTheme = userDB.Theme.String
|
||||
}
|
||||
|
||||
userTimezone := "UTC"
|
||||
if userDB.Timezone.Valid {
|
||||
userTimezone = userDB.Timezone.String
|
||||
}
|
||||
|
||||
// Extract JWT token for WebSocket authentication
|
||||
token := ""
|
||||
if cookie, err := c.Cookie("token"); err == nil {
|
||||
@@ -48,6 +56,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
Role: userRole,
|
||||
Theme: userTheme,
|
||||
Token: token,
|
||||
Timezone: userTimezone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -58,7 +67,7 @@ func convertPending(pending []map[string]interface{}) []templates.PendingRegistr
|
||||
RegistrationID: p["registration_id"].(string),
|
||||
DeviceName: p["device_name"].(string),
|
||||
DeviceType: p["device_type"].(string),
|
||||
ExpiresAt: p["expires_at"].(string),
|
||||
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -78,5 +87,79 @@ func parseUUID(s string) (uuid.UUID, error) {
|
||||
}
|
||||
|
||||
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
|
||||
return pgtype.UUID{Bytes: u, Valid: true}
|
||||
}
|
||||
|
||||
const selectedLibraryCookie = "selectedLibrary"
|
||||
const allLibrariesSentinel = "__all__"
|
||||
|
||||
type LibraryResolution struct {
|
||||
LibraryID string
|
||||
IsAll bool
|
||||
LibUUID pgtype.UUID
|
||||
Libraries []templates.LibraryData
|
||||
FirstID string
|
||||
}
|
||||
|
||||
func getText(t pgtype.Text) string {
|
||||
if t.Valid {
|
||||
return t.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution {
|
||||
res := LibraryResolution{}
|
||||
|
||||
userU, _ := uuid.Parse(userUUID)
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
|
||||
if err != nil {
|
||||
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||
}
|
||||
|
||||
res.Libraries = make([]templates.LibraryData, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
res.Libraries[i] = templates.LibraryData{
|
||||
ID: libUUID.String(),
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
}
|
||||
}
|
||||
|
||||
if len(libraries) > 0 {
|
||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
||||
res.FirstID = libUUID.String()
|
||||
}
|
||||
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
if cookie, err := c.Cookie(selectedLibraryCookie); err == nil {
|
||||
val, _ := url.QueryUnescape(cookie.Value)
|
||||
if val == allLibrariesSentinel {
|
||||
res.IsAll = true
|
||||
res.LibraryID = ""
|
||||
return res
|
||||
}
|
||||
if _, parseErr := uuid.Parse(val); parseErr == nil {
|
||||
libraryID = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if libraryID == "" {
|
||||
res.LibraryID = res.FirstID
|
||||
if res.LibraryID != "" {
|
||||
parsed, _ := uuid.Parse(res.LibraryID)
|
||||
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
res.LibraryID = libraryID
|
||||
parsed, _ := uuid.Parse(libraryID)
|
||||
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func registerMediaRoutes(cfg *Config) {
|
||||
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
|
||||
protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating)
|
||||
|
||||
// Legacy progress routes (all authenticated users)
|
||||
// Progress routes (all authenticated users)
|
||||
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
|
||||
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)
|
||||
protected.DELETE("/media-items/:id/progress", cfg.MediaHandler.DeleteMediaReadingProgress)
|
||||
|
||||
@@ -7,12 +7,8 @@ import (
|
||||
func registerProgressRoutes(cfg *Config, scannerHandler *handlers.Handler) {
|
||||
e := cfg.Echo
|
||||
|
||||
// JWT middleware for protected routes
|
||||
jwtMiddleware := createJWTMiddleware(cfg)
|
||||
protected := e.Group("/api", jwtMiddleware)
|
||||
|
||||
// Universal Progress routes
|
||||
protected.GET("/progress/:id", scannerHandler.GetUniversalProgress)
|
||||
protected.POST("/progress/:id", scannerHandler.UpdateUniversalProgress)
|
||||
protected.GET("/progress/:id/history", scannerHandler.GetProgressHistory)
|
||||
}
|
||||
|
||||
+22
-13
@@ -4,9 +4,11 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/templates"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -73,7 +75,7 @@ func registerReaderRoutes(cfg *Config) {
|
||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||
UserID: uuidToPGType(userUUID),
|
||||
})
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
progress = database.ReadingProgress{}
|
||||
}
|
||||
// Get bookmarks
|
||||
@@ -98,21 +100,26 @@ func registerReaderRoutes(cfg *Config) {
|
||||
MangaType: textToString(mediaItem.MangaType),
|
||||
ReadingDirection: textToString(mediaItem.ReadingDirection),
|
||||
LibraryID: libUUID.String(),
|
||||
FileURL: fmt.Sprintf("/uploads/library-%s/%s", libUUID.String(), mediaItem.FilePath),
|
||||
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
|
||||
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
||||
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
}
|
||||
// Progress conversion (inline)
|
||||
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
|
||||
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
|
||||
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
|
||||
templateProgress := templates.ReadingProgress{
|
||||
ID: progressUUID.String(),
|
||||
MediaItemID: progressMediaUUID.String(),
|
||||
UserID: progressUserUUID.String(),
|
||||
CurrentPage: int(progress.CurrentPage.Int32),
|
||||
TotalPages: int(progress.TotalPages.Int32),
|
||||
Percentage: progress.Percentage.Float64,
|
||||
EpubCfi: textToString(progress.Epubcfi),
|
||||
LastReadAt: progress.LastReadAt.Time,
|
||||
ID: progressUUID.String(),
|
||||
MediaItemID: progressMediaUUID.String(),
|
||||
UserID: progressUserUUID.String(),
|
||||
CurrentPage: int(progress.CurrentPage.Int32),
|
||||
TotalPages: int(progress.TotalPages.Int32),
|
||||
Percentage: progress.Percentage.Float64 * 100,
|
||||
EpubCfi: textToString(progress.Epubcfi),
|
||||
LastReadAt: progress.LastReadAt.Time,
|
||||
Chapter: int(progress.Chapter.Int32),
|
||||
ChapterProgress: progress.ChapterProgress.Float64 * 100,
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
}
|
||||
// Bookmarks conversion (inline, with loop)
|
||||
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
|
||||
@@ -123,12 +130,14 @@ func registerReaderRoutes(cfg *Config) {
|
||||
|
||||
var pageNumber *int
|
||||
if b.PageNumber.Valid {
|
||||
pageNumber = new(int(b.PageNumber.Int32))
|
||||
val := int(b.PageNumber.Int32)
|
||||
pageNumber = &val
|
||||
}
|
||||
|
||||
var chapterNumber *int
|
||||
if b.ChapterNumber.Valid {
|
||||
chapterNumber = new(int(b.ChapterNumber.Int32))
|
||||
val := int(b.ChapterNumber.Int32)
|
||||
chapterNumber = &val
|
||||
}
|
||||
|
||||
templateBookmarks[i] = templates.Bookmark{
|
||||
|
||||
@@ -56,16 +56,20 @@ type Config struct {
|
||||
FiltersHandler *handlers.FiltersHandler
|
||||
DashboardHandler *handlers.DashboardHandler
|
||||
DashboardService *services.DashboardService
|
||||
SeriesHandler *handlers.SeriesHandler
|
||||
OPDSHandler *handlers.OPDSHandler
|
||||
SystemSettingsHandler *handlers.SystemSettingsHandler
|
||||
ConnManager *sync.ConnectionManager
|
||||
QueueProcessor *sync.SyncQueueProcessor
|
||||
ProgressService *sync.ProgressService
|
||||
AnnotationService *sync.AnnotationService
|
||||
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
||||
LoginTracker *ratelimit.LoginAttemptTracker
|
||||
ScannerHandler *handlers.Handler
|
||||
JobsHandler *handlers.JobsHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
ReaderHandler *handlers.ReaderHandler
|
||||
LibraryService *services.LibraryService
|
||||
}
|
||||
|
||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||
@@ -90,7 +94,7 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
c.Set("user", database.Users{
|
||||
ID: pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true},
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Email: claims["user_email"].(string),
|
||||
Username: claims["user_username"].(string),
|
||||
Role: claims["user_role"].(string),
|
||||
@@ -186,6 +190,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
}
|
||||
e.Validator = &CustomValidator{validator: v}
|
||||
|
||||
// Setup redirect middleware - must run before all routes
|
||||
e.Pre(setupRedirectMiddleware(cfg))
|
||||
|
||||
// Rate limiter
|
||||
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
||||
Enabled: cfg.Cfg.RateLimitEnabled,
|
||||
@@ -204,12 +211,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
cfg.ScannerHandler = scannerHandler
|
||||
|
||||
// Register route groups
|
||||
registerSetupRoutes(cfg)
|
||||
registerAuthRoutes(cfg, rateLimitMiddleware)
|
||||
registerLibraryRoutes(cfg)
|
||||
registerDeviceRoutes(cfg)
|
||||
registerSystemRoutes(cfg)
|
||||
registerSyncRoutes(cfg)
|
||||
registerCollectionsRoutes(cfg)
|
||||
registerSeriesRoutes(cfg)
|
||||
registerDashboardRoutes(cfg)
|
||||
registerMediaRoutes(cfg)
|
||||
registerSearchRoutes(cfg)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package router
|
||||
|
||||
func registerSeriesRoutes(cfg *Config) {
|
||||
jwtMiddleware := createJWTMiddleware(cfg)
|
||||
protected := cfg.Echo.Group("/api", jwtMiddleware)
|
||||
|
||||
series := protected.Group("/series")
|
||||
series.GET("", cfg.SeriesHandler.GetSeries)
|
||||
series.GET("/books", cfg.SeriesHandler.GetSeriesBooks)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"bookhoard/internal/setupstatus"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
func isSetupComplete(cfg *Config) bool {
|
||||
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
|
||||
}
|
||||
|
||||
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
|
||||
if path == "/setup" || path == "/setup/" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if !isSetupComplete(cfg) {
|
||||
return c.Redirect(http.StatusFound, "/setup")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerSetupRoutes(cfg *Config) {
|
||||
e := cfg.Echo
|
||||
|
||||
e.GET("/setup", func(c *echo.Context) error {
|
||||
if isSetupComplete(cfg) {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
|
||||
log.Printf("Failed to render setup template: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
}
|
||||
@@ -32,6 +32,9 @@ func registerSyncRoutes(cfg *Config) {
|
||||
// Kobo devices use URL path: /api/sync/kobo/{token}/markup
|
||||
// API clients can use Authorization header: Authorization: Bearer {token}
|
||||
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
||||
koboHandler.SetProgressService(cfg.ProgressService)
|
||||
koboHandler.SetAnnotationService(cfg.AnnotationService)
|
||||
koboHandler.SetLibraryService(cfg.LibraryService)
|
||||
koboSync := e.Group("/api/sync/kobo/:token")
|
||||
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
||||
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
@@ -81,7 +82,10 @@ func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID
|
||||
}
|
||||
|
||||
var fileSize pgtype.Int8
|
||||
fileSize.Scan(int64(fileinfo.Size()))
|
||||
err = fileSize.Scan(fileinfo.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
||||
MediaItemID: mediaItemID,
|
||||
|
||||
@@ -135,7 +135,8 @@ type DashboardSection struct {
|
||||
|
||||
func (s *DashboardService) GetDashboardSections(
|
||||
ctx context.Context,
|
||||
userID, libraryID uuid.UUID,
|
||||
userID uuid.UUID,
|
||||
libraryID pgtype.UUID,
|
||||
limit int,
|
||||
collectionOrder []string,
|
||||
hiddenCollections []string,
|
||||
@@ -154,7 +155,7 @@ func (s *DashboardService) GetDashboardSections(
|
||||
}
|
||||
|
||||
results = append(results, DashboardSection{
|
||||
CollectionID: uuid.UUID(coll.ID.Bytes),
|
||||
CollectionID: coll.ID.Bytes,
|
||||
CollectionName: coll.Name,
|
||||
Items: items,
|
||||
QueryType: coll.QueryType.String,
|
||||
@@ -182,7 +183,7 @@ func (s *DashboardService) GetDashboardSections(
|
||||
}
|
||||
|
||||
results = append(results, DashboardSection{
|
||||
CollectionID: uuid.UUID(coll.ID.Bytes),
|
||||
CollectionID: coll.ID.Bytes,
|
||||
CollectionName: coll.Name,
|
||||
Items: items,
|
||||
QueryType: coll.QueryType.String,
|
||||
@@ -267,43 +268,57 @@ func (s *DashboardService) sortByPriority(sections []DashboardSection) []Dashboa
|
||||
return sorted
|
||||
}
|
||||
|
||||
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
|
||||
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
||||
switch coll.QueryType.String {
|
||||
case "continue-reading":
|
||||
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
case "recently-added":
|
||||
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
case "recently-read":
|
||||
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
case "not-started":
|
||||
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
case "continue-series":
|
||||
rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{
|
||||
LibraryID: libraryID,
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]database.MediaItems, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, continueSeriesRowToMediaItems(row))
|
||||
}
|
||||
return items, nil
|
||||
default:
|
||||
return []database.MediaItems{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
|
||||
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
||||
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||
|
||||
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -320,7 +335,7 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
if len(coll.AutoAssignRules) > 0 {
|
||||
var rules []Rule
|
||||
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
|
||||
allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
|
||||
allLibraryItems, err := s.db.GetLibraryItems(ctx, libraryID)
|
||||
if err == nil {
|
||||
for _, item := range allLibraryItems {
|
||||
alreadyInCollection := false
|
||||
@@ -360,10 +375,10 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
return finalItems, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
|
||||
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
||||
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -407,6 +422,7 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
|
||||
"Recently Added": {"Newly added items to this library", "🆕", "#9ece6a", 2, "recently-added"},
|
||||
"Recently Read": {"Books you've finished (progress >= 1)", "✅", "#e0af68", 3, "recently-read"},
|
||||
"Not Started": {"Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", 4, "not-started"},
|
||||
"Continue Series": {"Next book in series you're reading", "📚", "#bb9af7", 5, "continue-series"},
|
||||
}
|
||||
|
||||
meta, exists := defaultMetadata[collectionName]
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -30,8 +31,8 @@ const (
|
||||
|
||||
var AllowedExtensions = map[string][]string{
|
||||
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"},
|
||||
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".pdf"},
|
||||
LibraryTypeManga: {".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
||||
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
||||
}
|
||||
|
||||
var MimeTypes = map[string]string{
|
||||
@@ -286,6 +287,19 @@ func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]
|
||||
return dirs, cleanPath, parentPath, nil
|
||||
}
|
||||
|
||||
// SyncAllowedExtensions syncs the Go AllowedExtensions map into the database.
|
||||
// This ensures library_types.allowed_extensions stays in sync with the Go source of truth.
|
||||
func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) {
|
||||
for typeName, exts := range AllowedExtensions {
|
||||
if err := s.db.SyncLibraryTypeExtensions(ctx, database.SyncLibraryTypeExtensionsParams{
|
||||
Name: typeName,
|
||||
AllowedExtensions: exts,
|
||||
}); err != nil {
|
||||
log.Printf("Warning: failed to sync allowed extensions for library type %s: %v", typeName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
||||
// Get library folders for this library
|
||||
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
|
||||
|
||||
+411
-217
@@ -15,6 +15,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
@@ -74,6 +75,9 @@ type MediaMetadata struct {
|
||||
WebURL string // URL to info page (Goodreads, ComicVine, etc.)
|
||||
MetadataNotes string // Notes from metadata files (not user notes)
|
||||
CommunityRating float64 // Pre-existing community rating (0-10)
|
||||
PageCount int32 // Actual page count (images for comics, pages for PDF)
|
||||
TotalCharacters int64 // Total text characters (for reflowable EPUBs)
|
||||
ChapterCount int32 // Number of chapters detected
|
||||
|
||||
// Comic-specific fields
|
||||
StoryArc string // Story arc name
|
||||
@@ -114,7 +118,6 @@ type MediaScanner struct {
|
||||
fileStabilityMu sync.RWMutex
|
||||
scanMutex sync.Mutex
|
||||
scanInProgress atomic.Bool
|
||||
pollInterval time.Duration
|
||||
watching atomic.Bool
|
||||
settingsCache *SettingsCache
|
||||
|
||||
@@ -151,24 +154,22 @@ func NewMediaScanner(db *database.Queries) *MediaScanner {
|
||||
}
|
||||
|
||||
return &MediaScanner{
|
||||
db: db,
|
||||
watcher: watcher,
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
dirtyDirs: make(map[string]time.Time),
|
||||
fileStability: make(map[string]*atomic.Bool),
|
||||
pollInterval: 60 * time.Second,
|
||||
watching: atomic.Bool{},
|
||||
scanInProgress: atomic.Bool{},
|
||||
folders: []string{},
|
||||
adminID: pgtype.UUID{},
|
||||
db: db,
|
||||
watcher: watcher,
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
dirtyDirs: make(map[string]time.Time),
|
||||
fileStability: make(map[string]*atomic.Bool),
|
||||
watching: atomic.Bool{},
|
||||
scanInProgress: atomic.Bool{},
|
||||
folders: []string{},
|
||||
adminID: pgtype.UUID{},
|
||||
defaultLibraryID: pgtype.UUID{Valid: false},
|
||||
libraryTypes: make(map[string][]string),
|
||||
logger: NewScannerLogger(),
|
||||
libraryTypes: make(map[string][]string),
|
||||
logger: NewScannerLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MediaScanner) GetPollInterval() time.Duration {
|
||||
// Check cache first
|
||||
if cached, ok := s.settingsCache.Get("scan_poll_interval_seconds"); ok {
|
||||
if seconds, err := strconv.Atoi(cached); err == nil {
|
||||
return time.Duration(seconds) * time.Second
|
||||
@@ -176,25 +177,22 @@ func (s *MediaScanner) GetPollInterval() time.Duration {
|
||||
}
|
||||
|
||||
if s.db == nil {
|
||||
return 60 * time.Second
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
// Cache miss - query database
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
setting, err := s.db.GetSystemSetting(ctx, "scan_poll_interval_seconds")
|
||||
if err != nil || setting == "" {
|
||||
return 60 * time.Second
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
s.settingsCache.Set("scan_poll_interval_seconds", setting)
|
||||
|
||||
// Convert to duration
|
||||
seconds, err := strconv.Atoi(setting)
|
||||
if err != nil {
|
||||
return 60 * time.Second
|
||||
return 5 * time.Minute
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
@@ -260,40 +258,122 @@ func (s *MediaScanner) SetFolders(folders []string) error {
|
||||
s.watcher = watcher
|
||||
|
||||
// Build cache of allowed extensions per folder
|
||||
// Uses Go AllowedExtensions map as source of truth (not DB)
|
||||
s.libraryTypes = make(map[string][]string)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, folder := range folders {
|
||||
// Get library for this folder
|
||||
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to get library for folder %s: %v\n", folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get library type with allowed extensions
|
||||
libType, err := s.db.GetLibraryType(ctx, lib.LibraryTypeID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to get library type for %s: %v\n", folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Cache allowed extensions for this folder
|
||||
s.libraryTypes[folder] = libType.AllowedExtensions
|
||||
fmt.Printf("Scanner: Folder %s (type: %s) allows extensions: %v\n",
|
||||
folder, libType.Name, libType.AllowedExtensions)
|
||||
}
|
||||
|
||||
// Add all folders to watch
|
||||
for _, folder := range folders {
|
||||
if err := s.watcher.Add(folder); err != nil {
|
||||
fmt.Printf("Warning: failed to watch folder %s: %v\n", folder, err)
|
||||
if exts, ok := AllowedExtensions[libType.Name]; ok {
|
||||
s.libraryTypes[folder] = exts
|
||||
fmt.Printf("Scanner: Folder %s (type: %s) allows extensions: %v\n",
|
||||
folder, libType.Name, exts)
|
||||
} else {
|
||||
s.libraryTypes[folder] = libType.AllowedExtensions
|
||||
fmt.Printf("Scanner: Folder %s (type: %s) using DB extensions (no Go map entry): %v\n",
|
||||
folder, libType.Name, libType.AllowedExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
// Add all folders and their subdirectories to the watcher (like Audiobookshelf)
|
||||
watchCount := 0
|
||||
for _, folder := range folders {
|
||||
if err := s.watcher.Add(folder); err != nil {
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
||||
} else {
|
||||
watchCount++
|
||||
}
|
||||
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() || path == folder {
|
||||
return nil
|
||||
}
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
} else {
|
||||
watchCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MediaScanner) enqueueLibraryScan(rootFolder string) {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
libRow, err := s.db.GetLibraryByFolderPathPrefix(context.Background(), rootFolder)
|
||||
if err != nil {
|
||||
fmt.Printf("[MTIME-POLL] Warning: could not find library for %s: %v\n", rootFolder, err)
|
||||
return
|
||||
}
|
||||
|
||||
folders, err := s.db.GetLibraryFolders(context.Background(), libRow.LibraryID)
|
||||
if err != nil {
|
||||
fmt.Printf("[MTIME-POLL] Warning: could not get folders for library: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
folderPaths := make([]string, len(folders))
|
||||
for i, f := range folders {
|
||||
folderPaths[i] = f.FolderPath
|
||||
}
|
||||
|
||||
adminIDStr := ""
|
||||
if libRow.CreatedByAdminID.Valid {
|
||||
adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String()
|
||||
}
|
||||
if adminIDStr == "" {
|
||||
fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n")
|
||||
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
|
||||
if err != nil {
|
||||
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
|
||||
return
|
||||
}
|
||||
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
|
||||
}
|
||||
|
||||
libraryIDStr := uuid.UUID(libRow.LibraryID.Bytes).String()
|
||||
|
||||
job := &Job{
|
||||
ID: uuid.New().String(),
|
||||
Type: JobTypeScan,
|
||||
Status: JobStatusPending,
|
||||
UserID: adminIDStr,
|
||||
Context: context.Background(),
|
||||
Params: map[string]any{
|
||||
"library_id": libraryIDStr,
|
||||
"folders": folderPaths,
|
||||
"admin_id": adminIDStr,
|
||||
"db": s.db,
|
||||
"force": false,
|
||||
},
|
||||
}
|
||||
|
||||
if WorkerInstance != nil {
|
||||
WorkerInstance.Enqueue(job)
|
||||
fmt.Printf("[MTIME-POLL] Enqueued library scan for %s (library: %s)\n", rootFolder, libraryIDStr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
if len(s.folders) == 0 {
|
||||
return fmt.Errorf("no folders set")
|
||||
@@ -531,6 +611,26 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
|
||||
return metadata
|
||||
}
|
||||
|
||||
var bookExtensions = map[string]bool{
|
||||
".epub": true, ".pdf": true, ".mobi": true, ".azw": true, ".azw3": true,
|
||||
".fb2": true, ".txt": true, ".rtf": true, ".doc": true, ".docx": true,
|
||||
".lit": true, ".pdb": true, ".djvu": true,
|
||||
".cbz": true, ".cbr": true, ".cb7": true, ".cbt": true,
|
||||
}
|
||||
|
||||
func hasSiblingBookFile(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && bookExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool, error) {
|
||||
fmt.Printf("Processing media file: %s\n", path)
|
||||
|
||||
@@ -541,9 +641,10 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
return false, fmt.Errorf("failed to get file info: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("File info for %s: size=%d\n", path, info.Size())
|
||||
if isImageFile(path) && hasSiblingBookFile(filepath.Dir(path)) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Get file modification time for created_at
|
||||
fileModTime := info.ModTime()
|
||||
|
||||
// Find library for this file's folder
|
||||
@@ -590,7 +691,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
fmt.Printf("Media item already exists with same size, skipping: %s\n", path)
|
||||
return false, nil
|
||||
}
|
||||
} else if err != pgx.ErrNoRows {
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
fmt.Printf("Database error checking media item existence: %v\n", err)
|
||||
return false, fmt.Errorf("failed to check if media item exists: %v", err)
|
||||
}
|
||||
@@ -687,6 +788,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
TagsSearch: tagsSearch,
|
||||
AddedByAdminID: s.adminID,
|
||||
CreatedAt: pgtype.Timestamptz{Time: fileModTime, Valid: true},
|
||||
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
MangaType: pgtype.Text{String: metadata.MangaType, Valid: metadata.MangaType != ""},
|
||||
ReadingDirection: pgtype.Text{String: metadata.ReadingDirection, Valid: metadata.ReadingDirection != ""},
|
||||
SeriesCount: pgtype.Int4{Int32: metadata.SeriesCount, Valid: metadata.SeriesCount > 0},
|
||||
@@ -706,6 +808,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
||||
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
||||
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
|
||||
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to create media item: %v", err)
|
||||
@@ -725,6 +828,46 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
}
|
||||
}
|
||||
|
||||
// Set format group, total characters, and chapter count
|
||||
mimeType := s.getMimeType(path)
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
var formatGroup string
|
||||
var isReflowable, hasFixedLayout bool
|
||||
switch ext {
|
||||
case ".epub":
|
||||
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
||||
if fixedErr == nil && isFixed {
|
||||
formatGroup = "fixed_layout"
|
||||
hasFixedLayout = true
|
||||
} else {
|
||||
formatGroup = "reflowable"
|
||||
isReflowable = true
|
||||
}
|
||||
case ".mobi", ".azw", ".azw3", ".fb2", ".txt":
|
||||
formatGroup = "reflowable"
|
||||
isReflowable = true
|
||||
case ".pdf", ".djvu":
|
||||
formatGroup = "fixed_layout"
|
||||
hasFixedLayout = true
|
||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||
formatGroup = "comic_archive"
|
||||
hasFixedLayout = true
|
||||
default:
|
||||
formatGroup = "unknown"
|
||||
}
|
||||
err = s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
||||
ID: createdItem.ID,
|
||||
FormatGroup: formatGroup,
|
||||
FormatMimetype: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
||||
IsReflowable: pgtype.Bool{Bool: isReflowable, Valid: true},
|
||||
HasFixedLayout: pgtype.Bool{Bool: hasFixedLayout, Valid: true},
|
||||
TotalCharacters: pgtype.Int8{Int64: metadata.TotalCharacters, Valid: metadata.TotalCharacters > 0},
|
||||
ChapterCount: pgtype.Int4{Int32: metadata.ChapterCount, Valid: metadata.ChapterCount > 0},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to update format info for %s: %v\n", path, err)
|
||||
}
|
||||
|
||||
// Store format information in the database
|
||||
for _, format := range metadata.FileFormats {
|
||||
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
||||
@@ -782,6 +925,16 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
|
||||
if err == nil {
|
||||
genreTags := extractGenreTagsFromEPUB(book)
|
||||
processGenresAndTags(metadata, genreTags)
|
||||
if allText := book.AllChaptersText(); len(allText) > 0 {
|
||||
metadata.TotalCharacters = int64(len(allText))
|
||||
}
|
||||
metadata.ChapterCount = int32(book.ChapterCount())
|
||||
}
|
||||
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
||||
if fixedErr == nil && isFixed {
|
||||
if pageCount, imgErr := countArchiveImages(path); imgErr == nil && pageCount > 0 {
|
||||
metadata.PageCount = int32(pageCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,6 +1051,10 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
|
||||
fmt.Printf("Merged comic metadata from %s: title=%s, series=%s, issue=%d, manga=%s, direction=%s\n",
|
||||
path, comicInfo.Title, comicInfo.Series, comicInfo.Number, comicInfo.Manga, metadata.ReadingDirection)
|
||||
}
|
||||
|
||||
if pageCount, err := countArchiveImages(path); err == nil && pageCount > 0 {
|
||||
metadata.PageCount = int32(pageCount)
|
||||
}
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
@@ -953,7 +1110,7 @@ func determineReadingDirection(comicInfo *ComicInfo) string {
|
||||
if strings.Contains(tags, "webtoon") || strings.Contains(tags, "manhwa") {
|
||||
return "vertical" // Korean/Chinese webcomics
|
||||
}
|
||||
if strings.Contains(tags, "manga") && (lang == "ja" || lang == "jpn") {
|
||||
if strings.Contains(tags, "manga") {
|
||||
return "rtl" // Japanese manga
|
||||
}
|
||||
|
||||
@@ -1076,38 +1233,62 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
switch ext {
|
||||
case ".epub":
|
||||
metadata, err := s.extractEPUBMetadata(path)
|
||||
case ".epub", ".kepub":
|
||||
metadata := &MediaMetadata{}
|
||||
result, err := s.extractEPUBMetadata(path)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if result != nil {
|
||||
metadata = result
|
||||
}
|
||||
// Enhanced format detection for EPUBs
|
||||
isFixedLayout, detectErr := s.DetectFixedLayoutEPUB(path)
|
||||
if detectErr == nil && isFixedLayout {
|
||||
metadata.FileFormats = []*FormatInfo{{
|
||||
FormatType: "fixed_layout",
|
||||
FilePath: path,
|
||||
MimeType: s.getMimeType(path),
|
||||
}}
|
||||
if pageCount, imgErr := countArchiveImages(path); imgErr == nil && pageCount > 0 {
|
||||
metadata.PageCount = int32(pageCount)
|
||||
}
|
||||
}
|
||||
// Try to extract embedded cover
|
||||
coverPath, err := s.extractEPUBCover(path)
|
||||
if err != nil {
|
||||
// Enhanced format detection for EPUBs
|
||||
isFixedLayout, detectErr := s.DetectFixedLayoutEPUB(path)
|
||||
if detectErr == nil && isFixedLayout {
|
||||
// Override format group for manga EPUBs
|
||||
metadata.FileFormats = []*FormatInfo{{
|
||||
FormatType: "fixed_layout",
|
||||
FilePath: path,
|
||||
MimeType: s.getMimeType(path),
|
||||
}}
|
||||
fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err)
|
||||
} else if coverPath != "" {
|
||||
metadata.CoverPath = s.getRelativePath(coverPath)
|
||||
}
|
||||
// If no embedded cover, try sidecar
|
||||
if metadata.CoverPath == "" {
|
||||
sidecarCover := findSidecarCover(path)
|
||||
if sidecarCover != "" {
|
||||
metadata.CoverPath = s.getRelativePath(sidecarCover)
|
||||
}
|
||||
// Try to extract embedded cover
|
||||
coverPath, err := s.extractEPUBCover(path)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err)
|
||||
} else if coverPath != "" {
|
||||
metadata.CoverPath = s.getRelativePath(coverPath)
|
||||
}
|
||||
// If no embedded cover, try sidecar
|
||||
if metadata.CoverPath == "" {
|
||||
sidecarCover := findSidecarCover(path)
|
||||
if sidecarCover != "" {
|
||||
metadata.CoverPath = s.getRelativePath(sidecarCover)
|
||||
}
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
return metadata, nil
|
||||
case ".pdf":
|
||||
return s.extractPDFMetadata(path)
|
||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||
metadata, err := s.mergeMetadata(path, nil)
|
||||
if err != nil {
|
||||
return &MediaMetadata{
|
||||
Title: strings.TrimSuffix(filepath.Base(path), ext),
|
||||
}, nil
|
||||
}
|
||||
if metadata.Title == "" {
|
||||
metadata.Title = strings.TrimSuffix(filepath.Base(path), ext)
|
||||
}
|
||||
// If no cover from archive, try sidecar
|
||||
if metadata.CoverPath == "" {
|
||||
sidecarCover := findSidecarCover(path)
|
||||
if sidecarCover != "" {
|
||||
metadata.CoverPath = s.getRelativePath(sidecarCover)
|
||||
}
|
||||
}
|
||||
return metadata, nil
|
||||
default:
|
||||
// For other formats, return basic metadata
|
||||
return &MediaMetadata{
|
||||
@@ -1297,12 +1478,13 @@ func (s *MediaScanner) ValidateMediaItemForLibrary(
|
||||
// Must be fixed-layout or comic archive
|
||||
if mediaItem.FormatGroup != "fixed_layout" &&
|
||||
mediaItem.FormatGroup != "comic_archive" {
|
||||
return new(fmt.Sprintf(
|
||||
str := fmt.Sprintf(
|
||||
"EPUB file '%s' is reflowable (text-based), not fixed-layout (image-based). "+
|
||||
"Manga library only accepts fixed-layout EPUBs, CBZ, CBR, or image files. "+
|
||||
"Consider moving this file to an ebooks library.",
|
||||
mediaItem.Title,
|
||||
))
|
||||
)
|
||||
return &str
|
||||
}
|
||||
|
||||
// Set manga-specific flags for fixed-layout EPUBs
|
||||
@@ -1327,11 +1509,12 @@ func (s *MediaScanner) ValidateMediaItemForLibrary(
|
||||
// Accept comic archives and fixed-layout
|
||||
if mediaItem.FormatGroup != "comic_archive" &&
|
||||
mediaItem.FormatGroup != "fixed_layout" {
|
||||
return new(fmt.Sprintf(
|
||||
str := fmt.Sprintf(
|
||||
"File '%s' is not a comic archive format. "+
|
||||
"Comics library only accepts CBZ, CBR, CB7, CBT, PDF, or fixed-layout EPUBs.",
|
||||
mediaItem.Title,
|
||||
))
|
||||
)
|
||||
return &str
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,11 +1523,12 @@ func (s *MediaScanner) ValidateMediaItemForLibrary(
|
||||
// Flag manga for potential reorganization (info level)
|
||||
if mediaItem.FormatGroup == "fixed_layout" &&
|
||||
(!mediaItem.MangaType.Valid || mediaItem.MangaType.String == "yes" || mediaItem.MangaType.String == "yes_and_right_to_left") {
|
||||
return new(fmt.Sprintf(
|
||||
str := fmt.Sprintf(
|
||||
"File '%s' appears to be manga (fixed-layout with images). "+
|
||||
"Consider moving to a manga or comics library for better organization.",
|
||||
mediaItem.Title,
|
||||
))
|
||||
)
|
||||
return &str
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1531,7 +1715,7 @@ func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) {
|
||||
opfStartAttr += len("full-path=")
|
||||
quote := content[opfStart+opfStartAttr]
|
||||
opfStartQuote := opfStart + opfStartAttr + 1
|
||||
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{byte(quote)})
|
||||
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
|
||||
if opfEndQuote == -1 {
|
||||
continue
|
||||
}
|
||||
@@ -1806,6 +1990,8 @@ func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
|
||||
metadata.Publisher = pdfInfo.Producer
|
||||
}
|
||||
|
||||
metadata.PageCount = int32(pdfInfo.PageCount)
|
||||
|
||||
// Try to extract cover image
|
||||
coverPath, err := s.extractPDFCover(path)
|
||||
if err != nil {
|
||||
@@ -2250,7 +2436,81 @@ func (t *tarFileAdapter) Open() (io.ReadCloser, error) {
|
||||
// isImageFile checks if a file is an image based on extension
|
||||
func isImageFile(filename string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif"
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".tiff", ".tif":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countArchiveImages counts image files in a comic archive
|
||||
func countArchiveImages(filePath string) (int, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
count := 0
|
||||
|
||||
switch ext {
|
||||
case ".cbz", ".epub":
|
||||
r, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
if !f.FileInfo().IsDir() && isImageFile(f.Name) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
case ".cbr":
|
||||
r, err := rardecode.OpenReader(filePath, "")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer r.Close()
|
||||
for {
|
||||
header, err := r.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !header.IsDir && isImageFile(header.Name) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
case ".cb7":
|
||||
sz, err := sevenzip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer sz.Close()
|
||||
for _, f := range sz.File {
|
||||
if !f.FileInfo().IsDir() && isImageFile(f.Name) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
case ".cbt":
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
tr := tar.NewReader(f)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !header.FileInfo().IsDir() && isImageFile(header.Name) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, _ os.FileInfo) error {
|
||||
@@ -2268,6 +2528,10 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
|
||||
tagsSearch := utils.NormalizeTagsSearch(metadata.Tags)
|
||||
|
||||
// Call the database update - only update fields available in MediaMetadata
|
||||
var alternateInfoBytes []byte
|
||||
if metadata.AlternateInfo != "" {
|
||||
alternateInfoBytes = []byte(metadata.AlternateInfo)
|
||||
}
|
||||
_, err = s.db.UpdateMediaItem(ctx, database.UpdateMediaItemParams{
|
||||
ID: mediaItemID,
|
||||
Title: metadata.Title,
|
||||
@@ -2284,6 +2548,23 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
|
||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||
Contributors: metadata.Contributors,
|
||||
ContributorsSearch: contributorsSearch,
|
||||
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},
|
||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
|
||||
MangaType: pgtype.Text{String: metadata.MangaType, Valid: metadata.MangaType != ""},
|
||||
ReadingDirection: pgtype.Text{String: metadata.ReadingDirection, Valid: metadata.ReadingDirection != ""},
|
||||
SeriesCount: pgtype.Int4{Int32: metadata.SeriesCount, Valid: metadata.SeriesCount > 0},
|
||||
Volume: pgtype.Int4{Int32: metadata.Volume, Valid: metadata.Volume > 0},
|
||||
Imprint: pgtype.Text{String: metadata.Imprint, Valid: metadata.Imprint != ""},
|
||||
AgeRating: pgtype.Text{String: metadata.AgeRating, Valid: metadata.AgeRating != ""},
|
||||
WebUrl: pgtype.Text{String: metadata.WebURL, Valid: metadata.WebURL != ""},
|
||||
MetadataNotes: pgtype.Text{String: metadata.MetadataNotes, Valid: metadata.MetadataNotes != ""},
|
||||
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
|
||||
StoryArc: pgtype.Text{String: metadata.StoryArc, Valid: metadata.StoryArc != ""},
|
||||
IsBlackAndWhite: pgtype.Bool{Bool: metadata.IsBlackAndWhite, Valid: metadata.IsBlackAndWhite},
|
||||
AlternateInfo: alternateInfoBytes,
|
||||
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
||||
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -2304,56 +2585,55 @@ func (s *MediaScanner) getMimeType(path string) string {
|
||||
}
|
||||
|
||||
func (s *MediaScanner) WatchChanges(ctx context.Context) error {
|
||||
// Prevent duplicate calls
|
||||
if !s.watching.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("already watching")
|
||||
}
|
||||
|
||||
// Reset flag when context is cancelled
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.watching.Store(false)
|
||||
}()
|
||||
|
||||
// Perform initial scan of all root folders
|
||||
go s.performInitialScan(ctx)
|
||||
|
||||
// Start directory processor
|
||||
go s.processDirtyDirectories(ctx)
|
||||
|
||||
// Start polling fallback
|
||||
go s.StartPolling(ctx)
|
||||
go s.startBackupScan(ctx)
|
||||
|
||||
// Handle fsnotify events - queue them for debouncing
|
||||
go func() {
|
||||
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-s.watcher.Events:
|
||||
if !ok {
|
||||
fmt.Printf("[WATCHER] Event channel closed\n")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle new directories - add them to the watcher
|
||||
if event.Has(fsnotify.Create) {
|
||||
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
|
||||
if err := s.watcher.Add(event.Name); err != nil {
|
||||
fmt.Printf("Warning: failed to watch new directory %s: %v\n", event.Name, err)
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch new directory %s: %v\n", event.Name, err)
|
||||
} else {
|
||||
fmt.Printf("[WATCHER] Now watching new directory: %s\n", event.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark directory dirty for ANY file change
|
||||
if event.Has(fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Chmod | fsnotify.Rename) {
|
||||
if event.Has(fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename) {
|
||||
fmt.Printf("[WATCHER] Event: %s on %s\n", event.Op, event.Name)
|
||||
s.markDirectoryDirty(filepath.Dir(event.Name))
|
||||
}
|
||||
|
||||
case err, ok := <-s.watcher.Errors:
|
||||
if !ok {
|
||||
fmt.Printf("[WATCHER] Error channel closed\n")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Watcher error: %v\n", err)
|
||||
fmt.Printf("[WATCHER] Error: %v\n", err)
|
||||
|
||||
case <-ctx.Done():
|
||||
fmt.Printf("[WATCHER] Event loop stopped\n")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2431,8 +2711,6 @@ func (s *MediaScanner) processDirtyDirectories(ctx context.Context) {
|
||||
now := time.Now()
|
||||
readyDirs := make([]string, 0)
|
||||
|
||||
// Find directories that haven't been modified in 10 seconds
|
||||
// This batches changes together (Audiobookshelf approach)
|
||||
for dirPath, lastChange := range s.dirtyDirs {
|
||||
if now.Sub(lastChange) >= 10*time.Second {
|
||||
readyDirs = append(readyDirs, dirPath)
|
||||
@@ -2442,30 +2720,23 @@ func (s *MediaScanner) processDirtyDirectories(ctx context.Context) {
|
||||
|
||||
s.dirtyDirsMu.Unlock()
|
||||
|
||||
// Process all ready directories in a batch via job queue
|
||||
// Job queue serializes scans - prevents concurrent directory access
|
||||
if len(readyDirs) > 0 {
|
||||
for _, dirPath := range readyDirs {
|
||||
// Create directory scan job with correct params for processDirectoryScanJob()
|
||||
job := &Job{
|
||||
ID: uuid.New().String(),
|
||||
Type: JobTypeDirectoryScan,
|
||||
Params: map[string]any{
|
||||
"directory": dirPath,
|
||||
"db": s.db,
|
||||
},
|
||||
Status: JobStatusPending,
|
||||
}
|
||||
if len(readyDirs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Enqueue via global worker singleton
|
||||
if WorkerInstance != nil {
|
||||
WorkerInstance.Enqueue(job)
|
||||
fmt.Printf("Enqueued directory scan job: %s\n", dirPath)
|
||||
} else {
|
||||
fmt.Printf("Warning: Worker not initialized, skipping directory scan: %s\n", dirPath)
|
||||
affectedRoots := make(map[string]bool)
|
||||
for _, dirPath := range readyDirs {
|
||||
for _, folder := range s.folders {
|
||||
if strings.HasPrefix(dirPath, folder) {
|
||||
affectedRoots[folder] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for rootFolder := range affectedRoots {
|
||||
s.enqueueLibraryScan(rootFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2564,7 +2835,7 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
|
||||
for _, folder := range s.folders {
|
||||
if strings.HasPrefix(dirPath, folder) {
|
||||
rootFolder = folder
|
||||
if lib, err := s.db.GetLibraryByFolder(ctx, folder); err == nil {
|
||||
if lib, err := s.db.GetLibraryByFolderPathPrefix(ctx, dirPath); err == nil {
|
||||
libraryID = lib.LibraryID
|
||||
break
|
||||
}
|
||||
@@ -2576,15 +2847,12 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Walk directory and process new files
|
||||
// Walk directory and process new files (recurses into subdirectories)
|
||||
if err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != dirPath {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !s.isScannableFile(path) {
|
||||
@@ -2601,7 +2869,7 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
|
||||
LibraryID: libraryID,
|
||||
})
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if _, err := s.processMediaFile(ctx, path); err != nil {
|
||||
s.errors++
|
||||
} else {
|
||||
@@ -2621,36 +2889,34 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
|
||||
func (s *MediaScanner) performInitialScan(ctx context.Context) {
|
||||
fmt.Printf("Performing initial scan of root folders...\n")
|
||||
|
||||
for _, folder := range s.folders {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Printf("Initial scan cancelled\n")
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Skip if folder doesn't exist
|
||||
if _, err := os.Stat(folder); os.IsNotExist(err) {
|
||||
fmt.Printf("Skipping nonexistent folder: %s\n", folder)
|
||||
continue
|
||||
}
|
||||
if s.defaultLibraryID.Valid && s.adminID.Valid {
|
||||
folderPaths := s.folders
|
||||
libraryIDStr := uuid.UUID(s.defaultLibraryID.Bytes).String()
|
||||
adminIDStr := uuid.UUID(s.adminID.Bytes).String()
|
||||
|
||||
// Submit scan job to worker (non-blocking)
|
||||
job := &Job{
|
||||
ID: uuid.New().String(),
|
||||
Type: JobTypeDirectoryScan,
|
||||
ID: uuid.New().String(),
|
||||
Type: JobTypeScan,
|
||||
Status: JobStatusPending,
|
||||
UserID: adminIDStr,
|
||||
Context: context.Background(),
|
||||
Params: map[string]any{
|
||||
"directory": folder,
|
||||
"db": s.db,
|
||||
"library_id": libraryIDStr,
|
||||
"folders": folderPaths,
|
||||
"admin_id": adminIDStr,
|
||||
"db": s.db,
|
||||
"force": false,
|
||||
},
|
||||
Status: JobStatusPending,
|
||||
}
|
||||
|
||||
if WorkerInstance != nil {
|
||||
WorkerInstance.Enqueue(job)
|
||||
fmt.Printf("Enqueued initial scan job: %s\n", folder)
|
||||
fmt.Printf("Enqueued initial library scan job\n")
|
||||
} else {
|
||||
fmt.Printf("Warning: Worker not initialized, skipping initial scan: %s\n", folder)
|
||||
fmt.Printf("Warning: Worker not initialized, skipping initial scan\n")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Warning: no library/admin ID set, skipping initial scan\n")
|
||||
}
|
||||
|
||||
fmt.Printf("Initial scan jobs enqueued\n")
|
||||
@@ -2696,13 +2962,13 @@ func (s *MediaScanner) Close() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
func (s *MediaScanner) StartPolling(ctx context.Context) {
|
||||
func (s *MediaScanner) startBackupScan(ctx context.Context) {
|
||||
interval := s.GetPollInterval()
|
||||
if interval <= 0 {
|
||||
fmt.Println("Polling fallback disabled (interval = 0")
|
||||
fmt.Println("[BACKUP-SCAN] Periodic scan disabled (interval = 0)")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Polling fallback started with interval: %v\n", interval)
|
||||
fmt.Printf("[BACKUP-SCAN] Periodic scan started with interval: %v\n", interval)
|
||||
|
||||
for {
|
||||
ticker := time.NewTicker(interval)
|
||||
@@ -2710,93 +2976,21 @@ func (s *MediaScanner) StartPolling(ctx context.Context) {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Println("Polling fallback stopped")
|
||||
fmt.Println("[BACKUP-SCAN] Periodic scan stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
//Re-read interval each tick for dynamic updates
|
||||
interval = s.GetPollInterval()
|
||||
fmt.Printf("Running polling fallback sync (interval: %v)...\n", interval)
|
||||
if err := s.SyncFilesystemWithDatabase(ctx); err != nil {
|
||||
fmt.Printf("Polling sync error: %v\n", err)
|
||||
if !s.GetAutoScanEnabled() {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[BACKUP-SCAN] Running periodic full scan (interval: %v)...\n", interval)
|
||||
for _, folder := range s.folders {
|
||||
s.enqueueLibraryScan(folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MediaScanner) SyncFilesystemWithDatabase(ctx context.Context) error {
|
||||
fmt.Println("[POLL-SYNC] Starting filesystem sync with database")
|
||||
for _, folder := range s.folders {
|
||||
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
||||
if err != nil {
|
||||
fmt.Printf("[POLL-SYNC] Warning: failed to get library for folder %s: %v\n", folder, err)
|
||||
continue
|
||||
}
|
||||
libraryID := lib.LibraryID
|
||||
// Get all media items from database for this library
|
||||
dbItems, err := s.db.ListMediaItemsByLibrary(ctx, libraryID)
|
||||
if err != nil {
|
||||
fmt.Printf("[POLL-SYNC] Warning: failed to get library items: %v\n", err)
|
||||
continue
|
||||
}
|
||||
// Build set of existing file paths from filesystem
|
||||
existingPaths := make(map[string]bool)
|
||||
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if !d.IsDir() && s.isScannableFile(path) {
|
||||
existingPaths[s.getRelativePath(path)] = true
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
fmt.Printf("[POLL-SYNC] Warning: failed to walk directory %s: %v\n", folder, err)
|
||||
continue
|
||||
}
|
||||
// Check for orphaned items (in DB but not on filesystem)
|
||||
for _, item := range dbItems {
|
||||
if item.FilePath != "" && !existingPaths[item.FilePath] {
|
||||
msg := fmt.Sprintf("[POLL-SYNC] Orphaned media item found: ID=%s, Title=%s, Path=%s",
|
||||
item.ID, item.Title, item.FilePath)
|
||||
s.logger.LogDelete(msg)
|
||||
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
|
||||
errMsg := fmt.Sprintf("[POLL-SYNC] ERROR: failed to delete orphaned item %s: %v", item.Title, err)
|
||||
s.logger.LogDelete(errMsg)
|
||||
s.logger.LogError(errMsg)
|
||||
} else {
|
||||
s.logger.LogDelete(fmt.Sprintf("[POLL-SYNC] SUCCESS: deleted orphaned item '%s'", item.Title))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for new files (on filesystem but not in DB)
|
||||
// This is expensive, so we just check a few representative files
|
||||
// The fsnotify handler should catch most new files
|
||||
for relPath := range existingPaths {
|
||||
// Check if this file exists in DB
|
||||
_, err := s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
||||
FilePath: relPath,
|
||||
LibraryID: libraryID,
|
||||
})
|
||||
if err == pgx.ErrNoRows {
|
||||
// New file found - scan it
|
||||
absPath := folder + "/" + relPath
|
||||
if _, err := os.Stat(absPath); err == nil {
|
||||
fmt.Printf("[POLL-SYNC] New file detected, scanning: %s\n", absPath)
|
||||
if _, err := s.processMediaFile(ctx, absPath); err != nil {
|
||||
fmt.Printf("[POLL-SYNC] Error scanning new file %s: %v\n", absPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("[POLL-SYNC] Filesystem sync completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SCANNER ENHANCEMENTS
|
||||
// ============================================
|
||||
|
||||
// calculateFileSHA256 calculates SHA-256 hash using streaming to avoid loading entire file into memory
|
||||
func (s *MediaScanner) calculateFileSHA256(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -2844,19 +3038,19 @@ func (s *MediaScanner) extractOPFIdentifiers(epubPath string) (opfIdentifier, op
|
||||
return "", "", "low", nil
|
||||
}
|
||||
|
||||
var identifier, uuid string
|
||||
var identifier, uuidString string
|
||||
for _, id := range identifiers {
|
||||
id = strings.TrimSpace(id)
|
||||
|
||||
// Check for UUID format (urn:uuid:)
|
||||
if trimmed, found := strings.CutPrefix(strings.ToLower(id), "urn:uuid:"); found {
|
||||
uuid = trimmed
|
||||
uuidString = trimmed
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a plain UUID (8-4-4-4-12 format)
|
||||
if isValidUUID(id) {
|
||||
uuid = id
|
||||
uuidString = id
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2875,9 +3069,9 @@ func (s *MediaScanner) extractOPFIdentifiers(epubPath string) (opfIdentifier, op
|
||||
}
|
||||
}
|
||||
|
||||
confidence = s.determineHashConfidence(uuid, identifier)
|
||||
confidence = s.determineHashConfidence(uuidString, identifier)
|
||||
|
||||
return identifier, uuid, confidence, nil
|
||||
return identifier, uuidString, confidence, nil
|
||||
}
|
||||
|
||||
// isValidUUID checks if string is a valid UUID (8-4-4-4-12 format)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -73,7 +74,7 @@ func TestCalculateFileSHA256LargeFile(t *testing.T) {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && err != bufio.ErrBufferFull {
|
||||
if err != nil && !errors.Is(err, bufio.ErrBufferFull) {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ func TestMediaScanner_GetPollInterval(t *testing.T) {
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
}
|
||||
interval := scanner.GetPollInterval()
|
||||
if interval != 60*time.Second {
|
||||
t.Errorf("expected 60s, got %v", interval)
|
||||
if interval != 5*time.Minute {
|
||||
t.Errorf("expected 5m, got %v", interval)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper function to setup test database
|
||||
func setupTestDB(t *testing.T) *database.Queries {
|
||||
// Use existing test database setup
|
||||
// This would connect to the test database
|
||||
return &database.Queries{} // Placeholder - use your actual test DB setup
|
||||
return &database.Queries{}
|
||||
}
|
||||
|
||||
func TestMarkDirectoryDirty(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
scanner := NewMediaScanner(db)
|
||||
@@ -28,6 +26,7 @@ func TestMarkDirectoryDirty(t *testing.T) {
|
||||
scanner.dirtyDirsMu.RUnlock()
|
||||
assert.True(t, exists, "Directory should be marked dirty")
|
||||
}
|
||||
|
||||
func TestMarkDirectoryDirty_IgnoresNonWatchedPaths(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
scanner := NewMediaScanner(db)
|
||||
@@ -38,17 +37,16 @@ func TestMarkDirectoryDirty_IgnoresNonWatchedPaths(t *testing.T) {
|
||||
scanner.dirtyDirsMu.RUnlock()
|
||||
assert.False(t, exists, "Non-watched directory should be ignored")
|
||||
}
|
||||
|
||||
func TestMarkDirectoryDirty_SmartEventMerging(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
scanner := NewMediaScanner(db)
|
||||
scanner.folders = []string{"/test/folder"}
|
||||
// Mark subdirectory first
|
||||
scanner.markDirectoryDirty("/test/folder/subdir1")
|
||||
scanner.dirtyDirsMu.RLock()
|
||||
_, exists1 := scanner.dirtyDirs["/test/folder/subdir1"]
|
||||
scanner.dirtyDirsMu.RUnlock()
|
||||
assert.True(t, exists1)
|
||||
// Mark parent directory - should replace subdirectory
|
||||
scanner.markDirectoryDirty("/test/folder")
|
||||
scanner.dirtyDirsMu.RLock()
|
||||
_, parentExists := scanner.dirtyDirs["/test/folder"]
|
||||
@@ -57,38 +55,34 @@ func TestMarkDirectoryDirty_SmartEventMerging(t *testing.T) {
|
||||
assert.True(t, parentExists, "Parent should exist")
|
||||
assert.False(t, childExists, "Child should be removed (consolidated)")
|
||||
}
|
||||
|
||||
func TestWaitForFileStability_StableFile(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
scanner := NewMediaScanner(db)
|
||||
// Create a stable file
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "stable.epub")
|
||||
err := os.WriteFile(filePath, []byte("test content"), 0644)
|
||||
require.NoError(t, err)
|
||||
// Should return true immediately (file already stable)
|
||||
assert.True(t, scanner.waitForFileStability(filePath))
|
||||
}
|
||||
|
||||
func TestWaitForFileStability_UnstableFile(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
scanner := NewMediaScanner(db)
|
||||
// Create a file
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "unstable.epub")
|
||||
file, err := os.Create(filePath)
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
// Start stability check in background
|
||||
stableChan := make(chan bool)
|
||||
go func() {
|
||||
stableChan <- scanner.waitForFileStability(filePath)
|
||||
}()
|
||||
// Modify file repeatedly
|
||||
for i := 0; i < 3; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
file.WriteString("more data\n")
|
||||
}
|
||||
file.Close()
|
||||
// Should eventually return true
|
||||
select {
|
||||
case stable := <-stableChan:
|
||||
assert.True(t, stable)
|
||||
@@ -96,27 +90,32 @@ func TestWaitForFileStability_UnstableFile(t *testing.T) {
|
||||
t.Fatal("waitForFileStability timeout")
|
||||
}
|
||||
}
|
||||
func TestProcessDirtyDirectories_BatchesScans(t *testing.T) {
|
||||
|
||||
func TestProcessDirtyDirectories_CollectsReadyDirs(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
scanner := NewMediaScanner(db)
|
||||
scanner.folders = []string{"/test/folder"}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
// Mark directory dirty multiple times rapidly
|
||||
for i := 0; i < 5; i++ {
|
||||
scanner.markDirectoryDirty("/test/folder/subdir")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
scanner.dirtyDirsMu.Lock()
|
||||
scanner.dirtyDirs["/test/folder/subdir"] = time.Now().Add(-15 * time.Second)
|
||||
scanner.dirtyDirsMu.Unlock()
|
||||
|
||||
scanner.dirtyDirsMu.Lock()
|
||||
now := time.Now()
|
||||
readyDirs := make([]string, 0)
|
||||
for dirPath, lastChange := range scanner.dirtyDirs {
|
||||
if now.Sub(lastChange) >= 10*time.Second {
|
||||
readyDirs = append(readyDirs, dirPath)
|
||||
delete(scanner.dirtyDirs, dirPath)
|
||||
}
|
||||
}
|
||||
go scanner.processDirtyDirectories(ctx)
|
||||
// Should wait 10 seconds before processing
|
||||
scanner.dirtyDirsMu.Unlock()
|
||||
|
||||
assert.Equal(t, 1, len(readyDirs), "Should find one ready directory")
|
||||
assert.Equal(t, "/test/folder/subdir", readyDirs[0])
|
||||
|
||||
scanner.dirtyDirsMu.RLock()
|
||||
count := len(scanner.dirtyDirs)
|
||||
scanner.dirtyDirsMu.RUnlock()
|
||||
assert.Equal(t, 1, count, "Directory should still be in dirty list")
|
||||
// Wait for batch to complete
|
||||
time.Sleep(15 * time.Second)
|
||||
scanner.dirtyDirsMu.RLock()
|
||||
count = len(scanner.dirtyDirs)
|
||||
scanner.dirtyDirsMu.RUnlock()
|
||||
assert.Equal(t, 0, count, "All dirty directories should be processed after 10s")
|
||||
assert.Equal(t, 0, count, "Ready directory should be removed from dirty list")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user