-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathjikan_cache.go
More file actions
179 lines (147 loc) · 3.99 KB
/
jikan_cache.go
File metadata and controls
179 lines (147 loc) · 3.99 KB
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
const (
jikanCacheFile = "mappings.json"
jikanCacheDir = "jikan-cache"
)
// JikanCacheEntry represents a single cached entry with timestamp.
type JikanCacheEntry struct {
Data json.RawMessage `json:"data"`
CachedAt time.Time `json:"cached_at"`
}
// JikanCache provides persistent JSON-based caching for Jikan API responses.
type JikanCache struct {
entries map[string]JikanCacheEntry
mu sync.RWMutex
filePath string
dirty bool
maxAge time.Duration
}
// NewJikanCache creates a new cache instance and loads existing data.
func NewJikanCache(cacheDir string, maxAge time.Duration) *JikanCache {
if cacheDir == "" {
cacheDir = getDefaultJikanCacheDir()
}
filePath := filepath.Join(cacheDir, jikanCacheFile)
cache := &JikanCache{
entries: make(map[string]JikanCacheEntry),
filePath: filePath,
maxAge: maxAge,
}
if fileExists(filePath) {
err := cache.load()
if err != nil {
LogWarn(context.Background(), "Failed to load Jikan cache: %v (starting fresh)", err)
}
}
return cache
}
// Get retrieves a cached manga entry by MAL ID.
// Returns (data, found). Expired entries are treated as cache miss.
func (c *JikanCache) Get(malID int) (json.RawMessage, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := fmt.Sprintf("manga_%d", malID)
entry, exists := c.entries[key]
if !exists {
return nil, false
}
if c.maxAge > 0 && time.Since(entry.CachedAt) > c.maxAge {
return nil, false
}
return entry.Data, true
}
// Set stores a manga entry in the cache.
func (c *JikanCache) Set(malID int, data json.RawMessage) {
c.mu.Lock()
defer c.mu.Unlock()
key := fmt.Sprintf("manga_%d", malID)
c.entries[key] = JikanCacheEntry{
Data: data,
CachedAt: time.Now(),
}
c.dirty = true
}
// GetSearch retrieves cached search results by normalized query.
// Returns (data, found). Expired entries are treated as cache miss.
func (c *JikanCache) GetSearch(query string) (json.RawMessage, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := "search_" + normalizeTitle(query)
entry, exists := c.entries[key]
if !exists {
return nil, false
}
if c.maxAge > 0 && time.Since(entry.CachedAt) > c.maxAge {
return nil, false
}
return entry.Data, true
}
// SetSearch stores search results in the cache.
func (c *JikanCache) SetSearch(query string, data json.RawMessage) {
c.mu.Lock()
defer c.mu.Unlock()
key := "search_" + normalizeTitle(query)
c.entries[key] = JikanCacheEntry{
Data: data,
CachedAt: time.Now(),
}
c.dirty = true
}
// Save persists the cache to disk if dirty.
func (c *JikanCache) Save(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.dirty {
return nil
}
cacheDir := filepath.Dir(c.filePath)
// #nosec G301 - Cache directory for non-sensitive data
if err := os.MkdirAll(cacheDir, 0o750); err != nil {
return fmt.Errorf("create cache directory: %w", err)
}
data, err := json.MarshalIndent(c.entries, "", " ")
if err != nil {
return fmt.Errorf("marshal cache: %w", err)
}
// #nosec G306 - Cache file is non-sensitive
if err := os.WriteFile(c.filePath, data, 0o600); err != nil {
return fmt.Errorf("write cache file: %w", err)
}
c.dirty = false
LogDebug(ctx, "[Jikan Cache] Saved %d entries to %s", len(c.entries), c.filePath)
return nil
}
// load reads the cache from disk.
func (c *JikanCache) load() error {
// #nosec G304 - File path comes from controlled cache directory
data, err := os.ReadFile(c.filePath)
if err != nil {
return fmt.Errorf("read cache file: %w", err)
}
if err := json.Unmarshal(data, &c.entries); err != nil {
return fmt.Errorf("unmarshal cache: %w", err)
}
return nil
}
// Size returns the number of cached entries.
func (c *JikanCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.entries)
}
func getDefaultJikanCacheDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(configDir, "anilist-mal-sync", jikanCacheDir)
}