""" Redis storage provider for meta-stremio. Reads video metadata from Redis (written by meta-sort). Uses the same key schema as meta-sort for compatibility. """ from __future__ import annotations import os import json from typing import List, Optional try: import redis REDIS_AVAILABLE = True except ImportError: REDIS_AVAILABLE = False redis = None from .provider import StorageProvider, VideoMetadata # Redis configuration REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379') REDIS_PREFIX = os.environ.get('REDIS_PREFIX', '') class RedisStorage(StorageProvider): """ Redis storage provider. Reads video metadata from Redis using flat key schema: - file:{hashId}/{field} -> String value for each metadata property """ def __init__(self, url: str = None, prefix: str = None): if not REDIS_AVAILABLE: raise ImportError("redis package not installed. Run: pip install redis") self._url = url or REDIS_URL self._prefix = prefix or REDIS_PREFIX self._client: Optional[redis.Redis] = None self._connected = False def connect(self) -> None: """Connect to Redis.""" try: self._client = redis.from_url(self._url, decode_responses=True) # Test connection self._client.ping() self._connected = True print(f"[RedisStorage] Connected to {self._url}") except Exception as e: self._connected = False print(f"[RedisStorage] Failed to connect: {e}") raise def disconnect(self) -> None: """Disconnect from Redis.""" if self._client: self._client.close() self._client = None self._connected = False print("[RedisStorage] Disconnected") def is_connected(self) -> bool: """Check if connected to Redis.""" if not self._connected or not self._client: return False try: self._client.ping() return True except Exception: self._connected = False return False def get_file_path_by_cid(self, cid: str) -> Optional[str]: """ Get the file path for a file by its CID. Looks up file:{cid}/* in Redis and returns the path. Tries 'path' field first, then falls back to 'filePath'. This works for any file including poster images. Returns the relative path if found, None otherwise. """ if not self.is_connected(): return None try: prefix = self._get_file_key_prefix(cid) # Try 'path' field first (relative path) path = self._client.get(f"{prefix}path") if path: return path if isinstance(path, str) else path.decode() # Fall back to 'filePath' (full path from meta-sort) file_path = self._client.get(f"{prefix}filePath") if file_path: file_path_str = file_path if isinstance(file_path, str) else file_path.decode() # Convert absolute path to relative by removing /files/ prefix if file_path_str.startswith('/files/'): return file_path_str[7:] # Remove '/files/' return file_path_str return None except Exception as e: print(f"[RedisStorage] Error getting path for CID {cid}: {e}") return None def _get_file_key_prefix(self, hash_id: str) -> str: """Get the Redis key prefix for a file's flat keys.""" return f"{self._prefix}file:{hash_id}/" def _get_file_metadata(self, hash_id: str) -> dict: """Get all metadata for a file using flat key scan.""" prefix = self._get_file_key_prefix(hash_id) data = {} for key in self._client.scan_iter(f"{prefix}*", count=100): key_str = key if isinstance(key, str) else key.decode() field = key_str[len(prefix):] value = self._client.get(key) if value: data[field] = value if isinstance(value, str) else value.decode() return data def _parse_video(self, hash_id: str, data: dict) -> Optional[VideoMetadata]: """Parse Redis hash data into VideoMetadata. Handles both flat keys and nested keys (e.g., 'plot/eng' for description). meta-sort stores data using a flattened nested key format. """ if not data: return None # Parse JSON fields audio_tracks = [] subtitles = [] genres = [] studios = [] if 'audioTracks' in data: try: audio_tracks = json.loads(data['audioTracks']) except (json.JSONDecodeError, TypeError): pass if 'subtitles' in data: try: subtitles = json.loads(data['subtitles']) except (json.JSONDecodeError, TypeError): pass # Parse genres - can be JSON array or nested keys (genres/0, genres/1, etc.) if 'genres' in data: try: genres = json.loads(data['genres']) except (json.JSONDecodeError, TypeError): if isinstance(data['genres'], str): genres = [g.strip() for g in data['genres'].split(',') if g.strip()] else: # Try nested key format (genres/0, genres/1, etc.) genres = self._parse_nested_array(data, 'genres') # Parse studios - nested keys (studio/0, studio/1, etc.) studios = self._parse_nested_array(data, 'studio') # Parse numeric fields def parse_int(val): try: return int(val) if val else None except (ValueError, TypeError): return None def parse_float(val): try: return float(val) if val else None except (ValueError, TypeError): return None # Get description from nested plot/eng or flat description/plot description = data.get('description') if not description: description = data.get('plot') if not description: # Try nested format: plot/eng, plot/en, etc. for lang in ['eng', 'en', 'english', 'und']: description = data.get(f'plot/{lang}') if description: break # Parse rating (stored as string by TMDBProcessor) rating = parse_float(data.get('rating')) # Parse release date release_date = data.get('releasedate', data.get('releaseDate')) # Parse episode title (for series episodes) episode_title = data.get('episodeTitle', data.get('episodeName')) # Parse tagline tagline = data.get('tagline') # Get video/audio codec and resolution from various field formats # New format: stream/0, stream/1, etc. as JSON strings # Old format: fileinfo/streamdetails/video/0/... as flat keys video_codec = data.get('videoCodec') audio_codec = data.get('audioCodec') width = parse_int(data.get('width')) height = parse_int(data.get('height')) # Try new stream/* JSON format first if not video_codec or not width or not height or not audio_codec: for i in range(20): # Check up to 20 streams stream_key = f'stream/{i}' stream_json = data.get(stream_key) if not stream_json: break try: stream = json.loads(stream_json) if isinstance(stream_json, str) else stream_json stream_type = stream.get('type', '') if stream_type == 'video' and not video_codec: video_codec = stream.get('codec') if not width: width = parse_int(stream.get('width')) if not height: height = parse_int(stream.get('height')) elif stream_type == 'audio' and not audio_codec: audio_codec = stream.get('codec') except (json.JSONDecodeError, TypeError): pass # Fall back to old flat key format if not video_codec: video_codec = data.get('fileinfo/streamdetails/video/0/codec') if not audio_codec: audio_codec = data.get('fileinfo/streamdetails/audio/0/codec') if not width: width = parse_int(data.get('fileinfo/streamdetails/video/0/width')) if not height: height = parse_int(data.get('fileinfo/streamdetails/video/0/height')) duration = parse_float(data.get('duration')) if not duration: duration = parse_float(data.get('fileinfo/duration')) return VideoMetadata( hash_id=hash_id, file_path=data.get('filePath', ''), title=data.get('title', data.get('originalTitle', '')), video_type=data.get('type', data.get('videoType', 'movie')), year=parse_int(data.get('year', data.get('movieYear'))), season=parse_int(data.get('season')), episode=parse_int(data.get('episode')), duration=duration, width=width, height=height, video_codec=video_codec, audio_codec=audio_codec, container=data.get('container'), file_size=parse_int(data.get('fileSize', data.get('sizeByte'))), audio_tracks=audio_tracks, subtitles=subtitles, imdb_id=data.get('imdbId', data.get('imdbid')), tmdb_id=data.get('tmdbId', data.get('tmdbid')), poster=data.get('poster') or data.get('posterUrl'), # Fallback to posterUrl for backward compat backdrop=data.get('backdrop') or data.get('backdropUrl'), # Fallback to backdropUrl poster_path=data.get('posterPath'), backdrop_path=data.get('backdropPath'), description=description, genres=genres, episode_title=episode_title, rating=rating, release_date=release_date, tagline=tagline, studios=studios, ) def _parse_nested_array(self, data: dict, prefix: str) -> list: """Parse nested array format (prefix/0, prefix/1, etc.) into a list.""" items = [] index = 0 while True: key = f'{prefix}/{index}' if key in data: items.append(data[key]) index += 1 else: break return items def get_all_videos(self) -> List[VideoMetadata]: """Get all videos from Redis.""" if not self.is_connected(): return [] videos = [] try: # Get all hash IDs from index index_key = f"{self._prefix}file:__index__" hash_ids = self._client.smembers(index_key) for hash_id in hash_ids: hash_id_str = hash_id if isinstance(hash_id, str) else hash_id.decode() # Get all metadata for this file data = self._get_file_metadata(hash_id_str) # Check file type - only include video files if not data or data.get('fileType') != 'video': continue video = self._parse_video(hash_id_str, data) if video and video.file_path: videos.append(video) except Exception as e: print(f"[RedisStorage] Error getting all videos: {e}") # Sort by title videos.sort(key=lambda v: v.title.lower()) return videos def get_video_by_hash(self, hash_id: str) -> Optional[VideoMetadata]: """Get a video by its hash ID.""" if not self.is_connected(): return None try: data = self._get_file_metadata(hash_id) return self._parse_video(hash_id, data) if data else None except Exception as e: print(f"[RedisStorage] Error getting video {hash_id}: {e}") return None def get_videos_by_type(self, video_type: str) -> List[VideoMetadata]: """Get all videos of a specific type.""" videos = self.get_all_videos() return [v for v in videos if v.video_type == video_type] def search_videos(self, query: str) -> List[VideoMetadata]: """Search videos by title.""" if not query: return self.get_all_videos() query_lower = query.lower() videos = self.get_all_videos() return [v for v in videos if query_lower in v.title.lower()] def get_video_by_imdb_id(self, imdb_id: str) -> Optional[VideoMetadata]: """Get a video by its IMDB ID (e.g., 'tt1727587').""" if not self.is_connected() or not imdb_id: return None # Normalize IMDB ID format imdb_id = imdb_id.lower() if not imdb_id.startswith('tt'): imdb_id = f"tt{imdb_id}" try: # Get from index and check each file's imdbid index_key = f"{self._prefix}file:__index__" for hash_id in self._client.smembers(index_key): hash_id_str = hash_id if isinstance(hash_id, str) else hash_id.decode() prefix = self._get_file_key_prefix(hash_id_str) # Check imdbid field directly stored_imdb = self._client.get(f"{prefix}imdbid") or self._client.get(f"{prefix}imdbId") if stored_imdb: stored_imdb_str = stored_imdb if isinstance(stored_imdb, str) else stored_imdb.decode() if stored_imdb_str.lower() == imdb_id: data = self._get_file_metadata(hash_id_str) return self._parse_video(hash_id_str, data) except Exception as e: print(f"[RedisStorage] Error finding video by IMDB ID {imdb_id}: {e}") return None def get_video_count(self) -> int: """Get total number of videos.""" if not self.is_connected(): return 0 try: # Use index set for accurate count index_key = f"{self._prefix}file:__index__" return self._client.scard(index_key) except Exception as e: print(f"[RedisStorage] Error counting videos: {e}") return 0 def get_status(self) -> dict: """Get storage status for dashboard.""" status = { 'type': 'redis', 'connected': self.is_connected(), 'url': self._url, 'prefix': self._prefix, 'video_count': 0, } if status['connected']: try: status['video_count'] = self.get_video_count() info = self._client.info('memory') status['memory_used'] = info.get('used_memory_human', 'N/A') except Exception: pass return status