package storage import ( "context" "fmt" "log" "os" "sort" "strconv" "strings" "sync" "time" "github.com/redis/go-redis/v9" ) // Client wraps Redis operations for metadata storage type Client struct { client *redis.Client prefix string connected bool mu sync.RWMutex } // NewClient creates a new storage client func NewClient(prefix string) *Client { return &Client{ prefix: prefix, } } // Connect connects to Redis at the given URL func (c *Client) Connect(url string) error { c.mu.Lock() defer c.mu.Unlock() // Parse Redis URL (redis://host:port) addr := strings.TrimPrefix(url, "redis://") c.client = redis.NewClient(&redis.Options{ Addr: addr, DialTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, PoolSize: 10, }) // Test connection ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := c.client.Ping(ctx).Err(); err != nil { return fmt.Errorf("failed to connect to Redis: %w", err) } c.connected = true log.Printf("[Storage] Connected to Redis at %s", addr) return nil } // Close closes the Redis connection func (c *Client) Close() error { c.mu.Lock() defer c.mu.Unlock() if c.client != nil { c.connected = false return c.client.Close() } return nil } // IsConnected returns true if connected to Redis func (c *Client) IsConnected() bool { c.mu.RLock() defer c.mu.RUnlock() return c.connected } // Health checks if Redis is responding func (c *Client) Health() bool { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil || !c.connected { return false } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() return c.client.Ping(ctx).Err() == nil } // buildKey constructs the full key with prefix func (c *Client) buildKey(key string) string { if c.prefix != "" { return c.prefix + key } return key } // buildKeyPrefix constructs the prefix for a file's flat keys func (c *Client) buildKeyPrefix(hashID string) string { return c.buildKey(fmt.Sprintf("file:%s/", hashID)) } // buildIndexKey constructs the key for the file index set func (c *Client) buildIndexKey() string { return c.buildKey("file:__index__") } // GetMetadataFlat retrieves all metadata for a file as a flat map. // // Reads the record's field-name index and MGETs those keys — two round-trips, // independent of keyspace size. Records written before the index existed fall // back to the old SCAN and are indexed on the way out. See field_index.go for // why the index exists and why it is safe to derive. func (c *Client) GetMetadataFlat(hashID string) (map[string]string, error) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return nil, fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return c.readMetadataLocked(ctx, hashID) } // SetMetadataFlat stores metadata for a file using flat keys // Uses pipeline SET for each key: file:{hashId}/{property} func (c *Client) SetMetadataFlat(hashID string, metadata map[string]string) error { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return fmt.Errorf("not connected") } if len(metadata) == 0 { return nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() prefix := c.buildKeyPrefix(hashID) pipe := c.client.Pipeline() fields := make([]string, 0, len(metadata)) for field, value := range metadata { // A payload carrying the reserved name (a hand-made snapshot, a buggy // client) would SET a string over the index SET and break every // subsequent read of this record. Drop it — it is derived state, so // there is never a reason to accept it from outside. if isReservedFieldName(field) { continue } pipe.Set(ctx, prefix+field, value, 0) fields = append(fields, field) } // Same pipeline as the SETs, so indexing the field names costs no extra // round-trip. See field_index.go. c.addFieldsToPipe(ctx, pipe, hashID, fields) if _, err := pipe.Exec(ctx); err != nil { return fmt.Errorf("pipeline exec failed: %w", err) } // Add to index set if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil { return fmt.Errorf("sadd index failed: %w", err) } c.markDirtyLocked(ctx, hashID) // Auto-register reverse-index aliases for any cids/ key-set members in // the payload. That is the ONLY shape recognised — the legacy flat cid_* / // midhash256 named fields are not (see cid_resolution.go: cidFromKeysetField). c.maybeAddAliasesFromMetadataLocked(ctx, hashID, metadata) return nil } // GetAllHashIDs returns all unique file hash IDs stored // Uses Redis Set: SMEMBERS file:__index__ func (c *Client) GetAllHashIDs() ([]string, error) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return nil, fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() indexKey := c.buildIndexKey() result, err := c.client.SMembers(ctx, indexKey).Result() if err != nil { return nil, fmt.Errorf("smembers failed: %w", err) } return result, nil } // GetProperty retrieves a single property value // Uses flat key: GET file:{hashId}/{property} func (c *Client) GetProperty(hashID, property string) (string, error) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return "", fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() key := c.buildKeyPrefix(hashID) + property result, err := c.client.Get(ctx, key).Result() if err == redis.Nil { return "", nil } return result, err } // SetProperty sets a single property value // Uses flat key: SET file:{hashId}/{property} {value} func (c *Client) SetProperty(hashID, property, value string) error { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // See SetMetadataFlat: the reserved name addresses the derived index SET, // not a metadata field. if isReservedFieldName(property) { return fmt.Errorf("field name %q is reserved", property) } key := c.buildKeyPrefix(hashID) + property // Use SET to set the key if err := c.client.Set(ctx, key, value, 0).Err(); err != nil { return fmt.Errorf("set failed: %w", err) } c.addFieldLocked(ctx, hashID, property) // Add to index set if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil { return fmt.Errorf("sadd index failed: %w", err) } c.markDirtyLocked(ctx, hashID) // Auto-register reverse-index alias if this is a CID field. _ = c.maybeAddAliasFromFieldLocked(ctx, hashID, property, value) return nil } // DeleteMetadata deletes all metadata for a file // Uses SCAN + DEL for flat keys: file:{hashId}/* // Also unmaps every reverse-index entry registered for this root, so a // subsequent GET /api/meta/ correctly returns 404 instead of // pointing at a UUID with no keys. func (c *Client) DeleteMetadata(hashID string) (int64, error) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return 0, fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() prefix := c.buildKeyPrefix(hashID) // Read the cids key-set first — once file:/* is gone we've lost the // list of reverse-index keys that need cleanup. cids, err := c.cidsForRootLocked(ctx, hashID) if err != nil { return 0, err } if len(cids) > 0 { idxKeys := make([]string, 0, len(cids)) for _, cidStr := range cids { idxKeys = append(idxKeys, c.buildCIDIndexKey(cidStr)) } if err := c.client.Del(ctx, idxKeys...).Err(); err != nil { return 0, fmt.Errorf("del cid index: %w", err) } } // Scan and delete all keys with this prefix var deletedCount int64 var cursor uint64 for { keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result() if err != nil { return deletedCount, fmt.Errorf("scan failed: %w", err) } if len(keys) > 0 { deleted, err := c.client.Del(ctx, keys...).Result() if err != nil { return deletedCount, fmt.Errorf("del failed: %w", err) } deletedCount += deleted } cursor = nextCursor if cursor == 0 { break } } // Remove from index set if err := c.client.SRem(ctx, c.buildIndexKey(), hashID).Err(); err != nil { return deletedCount, fmt.Errorf("srem index failed: %w", err) } // Marked, not unmarked: the incremental refresh reads the record, finds it // gone, and evicts it from the search index. Skipping this would leave a // deleted record answering searches until the next full rebuild. c.markDirtyLocked(ctx, hashID) return deletedCount, nil } // CountFiles returns the number of unique files stored func (c *Client) CountFiles() (int, error) { hashIDs, err := c.GetAllHashIDs() if err != nil { return 0, err } return len(hashIDs), nil } // LookupPathByCID resolves a bare CID to its file path on disk via the // reverse index. O(1) lookup followed by a single GET of file:/filePath. // // Any registered CID resolves — the midhash, a sibling sha2-256, an IPFS // CID, a btih, etc. (they all alias to the same root). The bare CID is what // the editor passes to /api/file/{cid}/info and what plugins write (e.g. // `backdrop=`). // // Plugin output (TMDB posters/backdrops, extracted subtitles, etc.) is // resolved through this same path: the plugin output directory is a // watcher root (see config.DefaultWatcherPaths), so every plugin artefact // gets a midhash alias and resolves through the reverse index like any // other file. There is no path-companion sidecar field. func (c *Client) LookupPathByCID(cidStr string) (string, error) { uuid, err := c.GetByCID(cidStr) if err != nil { return "", err } if uuid == "" { return "", nil } fp, err := c.GetProperty(uuid, "filePath") if err != nil { return "", err } return normalizeFilesRelativePath(fp), nil } // normalizeFilesRelativePath strips a leading "/files/" prefix so handlers can // safely filepath.Join(FilesPath, …) without double-prefixing. Different // plugins store paths inconsistently — poster/backdrop use FilesPath-relative // ("/plugin/…") while filePath is FilesPath-absolute ("/files/watch/…"). func normalizeFilesRelativePath(p string) string { return strings.TrimPrefix(p, "/files") } // getMetadataFlatInternal mirrors GetMetadataFlat without re-acquiring the lock. // Callers must already hold c.mu. func (c *Client) getMetadataFlatInternal(ctx context.Context, hashID string) (map[string]string, error) { result, err := c.readMetadataLocked(ctx, hashID) if err != nil { return nil, err } // Unlike GetMetadataFlat, this variant hands back an empty map rather than // nil for a record with no fields — GetMetadataDocument writes into it. if result == nil { result = make(map[string]string) } return result, nil } // getAllHashIDsInternal is an internal version that doesn't acquire locks // (caller must hold the lock) func (c *Client) getAllHashIDsInternal(ctx context.Context) ([]string, error) { indexKey := c.buildIndexKey() result, err := c.client.SMembers(ctx, indexKey).Result() if err != nil { return nil, fmt.Errorf("smembers failed: %w", err) } return result, nil } // MergeMetadataFlat merges new metadata into existing (PATCH semantics) // New keys are added, existing keys are updated, missing keys are NOT deleted // Uses pipeline SET for flat keys: file:{hashId}/{property} func (c *Client) MergeMetadataFlat(hashID string, metadata map[string]string) (int, error) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return 0, fmt.Errorf("not connected") } if len(metadata) == 0 { return 0, nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() prefix := c.buildKeyPrefix(hashID) pipe := c.client.Pipeline() fields := make([]string, 0, len(metadata)) for field, value := range metadata { // See SetMetadataFlat: never let an inbound payload overwrite the // derived index SET with a string. if isReservedFieldName(field) { continue } pipe.Set(ctx, prefix+field, value, 0) fields = append(fields, field) } c.addFieldsToPipe(ctx, pipe, hashID, fields) if _, err := pipe.Exec(ctx); err != nil { return 0, fmt.Errorf("pipeline exec failed: %w", err) } // Add to index set if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil { return len(metadata), fmt.Errorf("sadd index failed: %w", err) } c.markDirtyLocked(ctx, hashID) // Auto-register reverse-index aliases for any cids/ key-set members in // the payload. That is the ONLY shape recognised — the legacy flat cid_* / // midhash256 named fields are not (see cid_resolution.go: cidFromKeysetField). c.maybeAddAliasesFromMetadataLocked(ctx, hashID, metadata) return len(metadata), nil } // DeleteProperty deletes a single property // Uses flat key: DEL file:{hashId}/{property} // // For a cids/ member this is the full inverse of the write: the flat key, // the __fields__ entry AND the cid: reverse alias all go. Anything less // leaves the CID still resolving to this record — see removeAliasLocked. func (c *Client) DeleteProperty(hashID, property string) error { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() key := c.buildKeyPrefix(hashID) + property if err := c.client.Del(ctx, key).Err(); err != nil { return err } // Before the field index forgets the name, use it: a cids/ member owns a // reverse-index entry that nothing else will ever clean up. if cidStr := cidFromKeysetField(property); cidStr != "" { if err := c.removeAliasLocked(ctx, hashID, cidStr); err != nil { return fmt.Errorf("remove alias for %s: %w", cidStr, err) } } c.removeFieldLocked(ctx, hashID, property) c.markDirtyLocked(ctx, hashID) return nil } // AddToSet adds a value to a set-type field (stored as pipe-delimited string in flat key) // Returns true if value was added, false if it already existed func (c *Client) AddToSet(hashID, property, value string) (bool, error) { c.mu.Lock() defer c.mu.Unlock() if c.client == nil { return false, fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() key := c.buildKeyPrefix(hashID) + property // Get current value current, err := c.client.Get(ctx, key).Result() if err != nil && err != redis.Nil { return false, err } // Parse existing values (pipe-delimited) var values []string if current != "" { values = strings.Split(current, "|") } // Check if value already exists for _, v := range values { if v == value { return false, nil // Already exists } } // Add new value values = append(values, value) newValue := strings.Join(values, "|") // Save back using SET if err := c.client.Set(ctx, key, newValue, 0).Err(); err != nil { return false, err } // The SET above may have created the key, so the field name is new. c.addFieldLocked(ctx, hashID, property) // Add to index set if err := c.client.SAdd(ctx, c.buildIndexKey(), hashID).Err(); err != nil { return true, fmt.Errorf("sadd index failed: %w", err) } c.markDirtyLocked(ctx, hashID) return true, nil } // RemoveFromSet removes a value from a set-type field (stored in flat key) // Returns true if value was removed, false if it didn't exist func (c *Client) RemoveFromSet(hashID, property, value string) (bool, error) { c.mu.Lock() defer c.mu.Unlock() if c.client == nil { return false, fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() key := c.buildKeyPrefix(hashID) + property // Get current value current, err := c.client.Get(ctx, key).Result() if err == redis.Nil { return false, nil // Key doesn't exist } if err != nil { return false, err } // Parse existing values values := strings.Split(current, "|") // Find and remove value found := false newValues := make([]string, 0, len(values)) for _, v := range values { if v == value { found = true } else { newValues = append(newValues, v) } } if !found { return false, nil } // Save back (or delete key if empty) if len(newValues) == 0 { if err := c.client.Del(ctx, key).Err(); err != nil { return true, err } // The key is gone, so the field name must leave the index too. c.removeFieldLocked(ctx, hashID, property) c.markDirtyLocked(ctx, hashID) return true, nil } newValue := strings.Join(newValues, "|") err = c.client.Set(ctx, key, newValue, 0).Err() // The field survives with fewer members, so no field-index change — but the // VALUE changed, which is exactly what the search index caches. c.markDirtyLocked(ctx, hashID) return true, err } // FileTuple holds the (uuid, path, size, mtime, midhash) record for one // file. Used by the watcher hydrator at startup to avoid re-mid-hashing // files whose tuple matches what's already in Redis. // // HashID is the root identifier (UUIDv7 in the current model). Hydration // uses HashID + (FilePath, Size, MtimeNano) to seed the in-memory state so // the next scan can skip re-mid-hashing files whose size+mtime are unchanged. type FileTuple struct { HashID string FilePath string Size int64 MtimeNano int64 } // GetTuplesForAllFiles returns one FileTuple per indexed root, fetched in // batched MGETs (no SCAN per root). Skips files missing any of filePath, // sizeByte, or mtimeNano. Sub-second on local Redis for ~10K files. // // The midhash is intentionally NOT fetched here: it now lives only as a // cids/ key-set member (no named field), and the skip-rehash decision // is purely size+mtime — so reading it would cost a SCAN per file for no // benefit. func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return nil, fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() hashIDs, err := c.client.SMembers(ctx, c.buildIndexKey()).Result() if err != nil { return nil, fmt.Errorf("smembers index: %w", err) } if len(hashIDs) == 0 { return nil, nil } // Build the key list: 4 keys per root, in a fixed order so we can decode // the MGET response by position. fields[i] for hashIDs[j] sits at // keys[j*4+i]. const fieldsPerFile = 3 fields := [fieldsPerFile]string{"filePath", "sizeByte", "mtimeNano"} keys := make([]string, 0, len(hashIDs)*fieldsPerFile) for _, h := range hashIDs { prefix := c.buildKeyPrefix(h) for _, f := range fields { keys = append(keys, prefix+f) } } // Chunked MGET — Redis caps args per command; 1000 is a safe batch. const batch = 1000 values := make([]interface{}, 0, len(keys)) for start := 0; start < len(keys); start += batch { end := start + batch if end > len(keys) { end = len(keys) } chunk, err := c.client.MGet(ctx, keys[start:end]...).Result() if err != nil { return nil, fmt.Errorf("mget chunk [%d:%d]: %w", start, end, err) } values = append(values, chunk...) } out := make([]FileTuple, 0, len(hashIDs)) for i, h := range hashIDs { base := i * fieldsPerFile filePath, _ := values[base].(string) sizeStr, _ := values[base+1].(string) mtimeStr, _ := values[base+2].(string) if filePath == "" || sizeStr == "" || mtimeStr == "" { continue } size, err := strconv.ParseInt(sizeStr, 10, 64) if err != nil { continue } mtime, err := strconv.ParseInt(mtimeStr, 10, 64) if err != nil { continue } out = append(out, FileTuple{ HashID: h, FilePath: filePath, Size: size, MtimeNano: mtime, }) } return out, nil } // SearchTuple is one record's search state: its hashID, a lowercased haystack of // its searchable text fields (for token/substring match), and the full flat // metadata map (so a search hit is returned straight from memory — no per-result // Redis fetch). type SearchTuple struct { HashID string Haystack string Fields map[string]string } // searchIndexFields are the metadata fields folded into a record's search // haystack — must stay in sync with the fields the search handler documents. var searchIndexFields = []string{"title", "fileName", "originaltitle", "showtitle", "filePath"} // defaultIndexExcludePrefixes are field-name prefixes kept OUT of the in-memory // search index. Redis still stores them and every per-record read still returns // them — this narrows the *index*, never the store. // // `categories/newznab/` is the raw Newznab numeric category code, which // METADATA_KEYS documents as a mirror of the canonical `categories/