Files
meta-stremio/src/storage/redis_storage.py
T
2026-02-08 21:54:21 +01:00

413 lines
15 KiB
Python

"""
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