Skip to content

Commit 969c5fa

Browse files
committed
Add webhooks, import/export, scheduler, reading history, responsive UI, series auto-complete
- Discord/generic webhook notifications on downloads and request changes - JSON/CSV import and export for library, wishlist, and requests - Scheduled wishlist searches with auto-download (configurable interval and min score) - Reading history tracking with stats (books/month, ratings, currently reading) - Mobile-responsive UI with hamburger menu and stacked layout - Series auto-complete: detects series gaps, searches for missing entries via Open Library
1 parent 6f00c29 commit 969c5fa

13 files changed

Lines changed: 2085 additions & 1 deletion

File tree

cmd/librarr/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,12 @@ func main() {
107107
scanner := organize.NewAudiobookScanner(cfg, database, targets)
108108
go scanner.Start(ctx)
109109

110-
// Create HTTP server.
110+
// Create HTTP server (also initializes webhook sender, scheduler, series detector).
111111
server := api.NewServer(cfg, database, searchMgr, downloadMgr, qb, sab, organizer, targets)
112112

113+
// Start scheduled search goroutine.
114+
go server.StartScheduler(ctx)
115+
113116
httpServer := &http.Server{
114117
Addr: fmt.Sprintf(":%d", cfg.Port),
115118
Handler: server.Handler(),

internal/api/history.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"strconv"
7+
"time"
8+
9+
"github.com/JeremiahM37/librarr/internal/db"
10+
)
11+
12+
// handleAddHistory adds a new reading history entry.
13+
func (s *Server) handleAddHistory(w http.ResponseWriter, r *http.Request) {
14+
userID := getUserIDFromContext(r)
15+
if userID == 0 {
16+
writeJSON(w, http.StatusUnauthorized, map[string]interface{}{
17+
"success": false, "error": "Authentication required",
18+
})
19+
return
20+
}
21+
22+
var req struct {
23+
BookTitle string `json:"book_title"`
24+
Author string `json:"author"`
25+
Format string `json:"format"`
26+
StartedAt string `json:"started_at"`
27+
FinishedAt string `json:"finished_at"`
28+
Rating *int `json:"rating"`
29+
Notes string `json:"notes"`
30+
LibraryItemID *int64 `json:"library_item_id"`
31+
}
32+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
33+
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
34+
"success": false, "error": "Invalid JSON",
35+
})
36+
return
37+
}
38+
39+
if req.BookTitle == "" {
40+
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
41+
"success": false, "error": "book_title is required",
42+
})
43+
return
44+
}
45+
46+
var startedAt, finishedAt *time.Time
47+
if req.StartedAt != "" {
48+
t, err := time.Parse(time.RFC3339, req.StartedAt)
49+
if err != nil {
50+
t, err = time.Parse("2006-01-02", req.StartedAt)
51+
}
52+
if err == nil {
53+
startedAt = &t
54+
}
55+
}
56+
if startedAt == nil {
57+
now := time.Now()
58+
startedAt = &now
59+
}
60+
61+
if req.FinishedAt != "" {
62+
t, err := time.Parse(time.RFC3339, req.FinishedAt)
63+
if err != nil {
64+
t, err = time.Parse("2006-01-02", req.FinishedAt)
65+
}
66+
if err == nil {
67+
finishedAt = &t
68+
}
69+
}
70+
71+
id, err := s.db.AddReadingHistory(userID, req.BookTitle, req.Author, req.Format, startedAt, finishedAt, req.Rating, req.Notes, req.LibraryItemID)
72+
if err != nil {
73+
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
74+
"success": false, "error": "Failed to add history entry",
75+
})
76+
return
77+
}
78+
79+
writeJSON(w, http.StatusCreated, map[string]interface{}{
80+
"success": true,
81+
"id": id,
82+
})
83+
}
84+
85+
// handleGetHistory lists reading history with pagination and filter.
86+
func (s *Server) handleGetHistory(w http.ResponseWriter, r *http.Request) {
87+
userID := getUserIDFromContext(r)
88+
if userID == 0 {
89+
writeJSON(w, http.StatusUnauthorized, map[string]interface{}{
90+
"success": false, "error": "Authentication required",
91+
})
92+
return
93+
}
94+
95+
limit := 50
96+
offset := 0
97+
status := r.URL.Query().Get("status") // "reading", "finished", or "" for all
98+
99+
if l := r.URL.Query().Get("limit"); l != "" {
100+
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 200 {
101+
limit = v
102+
}
103+
}
104+
if o := r.URL.Query().Get("offset"); o != "" {
105+
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
106+
offset = v
107+
}
108+
}
109+
110+
entries, err := s.db.GetReadingHistory(userID, status, limit, offset)
111+
if err != nil {
112+
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
113+
"success": false, "error": "Failed to get history",
114+
})
115+
return
116+
}
117+
118+
if entries == nil {
119+
entries = []db.ReadingHistoryEntry{}
120+
}
121+
122+
writeJSON(w, http.StatusOK, map[string]interface{}{
123+
"success": true,
124+
"history": entries,
125+
})
126+
}
127+
128+
// handleUpdateHistory updates a reading history entry.
129+
func (s *Server) handleUpdateHistory(w http.ResponseWriter, r *http.Request) {
130+
userID := getUserIDFromContext(r)
131+
if userID == 0 {
132+
writeJSON(w, http.StatusUnauthorized, map[string]interface{}{
133+
"success": false, "error": "Authentication required",
134+
})
135+
return
136+
}
137+
138+
idStr := r.PathValue("id")
139+
id, err := strconv.ParseInt(idStr, 10, 64)
140+
if err != nil {
141+
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
142+
"success": false, "error": "Invalid ID",
143+
})
144+
return
145+
}
146+
147+
var req struct {
148+
FinishedAt *string `json:"finished_at"`
149+
Rating *int `json:"rating"`
150+
Notes *string `json:"notes"`
151+
}
152+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
153+
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
154+
"success": false, "error": "Invalid JSON",
155+
})
156+
return
157+
}
158+
159+
var finishedAt *time.Time
160+
if req.FinishedAt != nil && *req.FinishedAt != "" {
161+
t, err := time.Parse(time.RFC3339, *req.FinishedAt)
162+
if err != nil {
163+
t, err = time.Parse("2006-01-02", *req.FinishedAt)
164+
}
165+
if err == nil {
166+
finishedAt = &t
167+
}
168+
}
169+
170+
if err := s.db.UpdateReadingHistory(id, userID, finishedAt, req.Rating, req.Notes); err != nil {
171+
writeJSON(w, http.StatusNotFound, map[string]interface{}{
172+
"success": false, "error": "Entry not found",
173+
})
174+
return
175+
}
176+
177+
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true})
178+
}
179+
180+
// handleDeleteHistory removes a reading history entry.
181+
func (s *Server) handleDeleteHistory(w http.ResponseWriter, r *http.Request) {
182+
userID := getUserIDFromContext(r)
183+
if userID == 0 {
184+
writeJSON(w, http.StatusUnauthorized, map[string]interface{}{
185+
"success": false, "error": "Authentication required",
186+
})
187+
return
188+
}
189+
190+
idStr := r.PathValue("id")
191+
id, err := strconv.ParseInt(idStr, 10, 64)
192+
if err != nil {
193+
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
194+
"success": false, "error": "Invalid ID",
195+
})
196+
return
197+
}
198+
199+
if err := s.db.DeleteReadingHistory(id, userID); err != nil {
200+
writeJSON(w, http.StatusNotFound, map[string]interface{}{
201+
"success": false, "error": "Entry not found",
202+
})
203+
return
204+
}
205+
206+
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true})
207+
}
208+
209+
// handleHistoryStats returns reading statistics.
210+
func (s *Server) handleHistoryStats(w http.ResponseWriter, r *http.Request) {
211+
userID := getUserIDFromContext(r)
212+
if userID == 0 {
213+
writeJSON(w, http.StatusUnauthorized, map[string]interface{}{
214+
"success": false, "error": "Authentication required",
215+
})
216+
return
217+
}
218+
219+
stats, err := s.db.GetReadingStats(userID)
220+
if err != nil {
221+
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
222+
"success": false, "error": "Failed to get stats",
223+
})
224+
return
225+
}
226+
227+
writeJSON(w, http.StatusOK, map[string]interface{}{
228+
"success": true,
229+
"stats": stats,
230+
})
231+
}

0 commit comments

Comments
 (0)