[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
{
"banners": []
}
+431
View File
@@ -0,0 +1,431 @@
"""Read-only ReelBox metadata service. No upstream or media fetches."""
from collections import OrderedDict
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import sqlite3
import sys
import threading
import unicodedata
class ApiError(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
_FOLD = dict.fromkeys(i for i in range(0x110000)
if unicodedata.category(chr(i)) in {'Mn', 'Cf'})
_FOLD.update({ord('ي'): 'ی', ord('ى'): 'ی', ord('ك'): 'ک'})
def normal(value):
return str(value or '').lower().translate(_FOLD)
def allowed_text(value):
text = normal(unicodedata.normalize('NFKC', str(value or '')))
# Upstream notices are often disguised as movie rows, with new IDs after
# refreshes. Match their labels rather than IDs or bare English "notice",
# which would incorrectly hide films such as Red Notice / Notice to Quit.
label = re.sub(r'[\W_ـ]', '', text)
if label.startswith((
'اطلاعیه', 'اطلاعرسانی', 'بهروزرسانی', 'بروزرسانی',
'اعلانجدید', 'اعلانمهم', 'اعلانفوری', 'اعلامیهجدید', 'اعلامیهمهم',
'آپدیتجدید', 'اپدیتجدید',
'newannouncement', 'importantannouncement', 'urgentannouncement',
'newnotice', 'importantnotice', 'urgentnotice',
'appupdate', 'applicationupdate', 'softwareupdate')):
return False
compact = re.sub(r'[\s_\-ـ]', '', text)
return not (any(word in compact for word in
('ملودیباکس', 'ملودیبکس', 'melodybox', 'melodibox', 'نورتون', 'نورتن', 'norton',
'موویباکس', 'موسویباکس', 'نسخهجدیدهورتون', 'اپلیکیشنهورتون', 'نرمافزارهورتون'))
or 'تلگرام' in text or re.search(r'\btelegram\b|(^|[\s>(])@[a-z][a-z0-9_]{3,}', text)
or re.search(r'(دانلود|نصب|دریافت).{0,30}نسخه.{0,18}(برنامه|اپلیکیشن|سیمبا|مووی)', text)
or re.search(r'(install|download|update).{0,24}(latest|new).{0,12}(app|version)', text))
def banner_https(value):
from urllib.parse import urlsplit
if (not isinstance(value, str) or len(value) > 2048
or re.search(r'[\x00-\x20\x7f\\]', value)
or re.search(r'%(?![0-9a-f]{2})|%(?:0[0-9a-f]|1[0-9a-f]|7f)', value, re.I)):
return False
try:
uri = urlsplit(value)
return (uri.scheme == 'https' and bool(uri.hostname) and uri.username is None
and uri.password is None and uri.port in (None, 443)
and len(uri.hostname) <= 253
and all(re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?', part)
for part in uri.hostname.split('.')))
except ValueError:
return False
def load_banners(path):
"""Read a bounded operator-owned file, never a remote config or admin input."""
try:
with Path(path).open('rb') as source:
data = source.read(65537)
if len(data) > 65536:
return {'banners': []}
config = json.loads(data)
except (OSError, ValueError, RecursionError):
return {'banners': []}
if not isinstance(config, dict) or not isinstance(config.get('banners'), list):
return {'banners': []}
result = []
for row in config['banners'][:20]:
if len(result) == 5:
break
if not isinstance(row, dict) or not banner_https(row.get('target_url')):
continue
image = row.get('image_url')
if image not in (None, '') and not banner_https(image):
continue
clean = {}
for key, fallback, limit in (
('title_en', '', 80), ('title_fa', row.get('title_en', ''), 80),
('subtitle_en', '', 180), ('subtitle_fa', row.get('subtitle_en', ''), 180)):
text = row.get(key, fallback)
if (not isinstance(text, str) or len(text) > limit
or re.search(r'[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]', text)
or not allowed_text(text)):
break
clean[key] = text.strip()
else:
if not clean['title_en'] or not clean['title_fa']:
continue
clean['target_url'] = row['target_url']
if image:
clean['image_url'] = image
result.append(clean)
return {'banners': result}
class Catalog:
RESPONSE_CACHE_BYTES = 8 * 1024 * 1024
RESPONSE_CACHE_ENTRIES = 128
RESPONSE_CACHE_ITEM_BYTES = 1024 * 1024
def __init__(self, path, banner_config=None):
self.banner_config = Path(banner_config) if banner_config is not None else Path(__file__).with_name('banners.json')
self.db = sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro&immutable=1',
uri=True, check_same_thread=False)
self.db.execute('PRAGMA query_only=ON')
self.db.execute('PRAGMA cache_size=-8192')
self.lock = threading.RLock()
self._responses = OrderedDict()
self._response_bytes = 0
self.metadata = {k: json.loads(v) for k, v in self.db.execute('SELECT * FROM metadata')}
# The snapshot is immutable until service restart. Normalize searchable
# text once instead of redoing Unicode folding for every row/request.
# Bound retained strings on the 256 MiB service; overflow stays correct
# through the original normalization path rather than dropping results.
self.search_text = {}
self.search_index_bytes = 0
visible = set()
for title_id, kind, title, description in self.db.execute(
'SELECT id,type,title,description FROM titles'):
if kind not in {'movie', 'serie'} or not allowed_text(title) or not allowed_text(description):
continue
visible.add(title_id)
text = (normal(title), normal(description))
size = sys.getsizeof(text) + sum(sys.getsizeof(v) for v in text) + sys.getsizeof(title_id) + 64
if self.search_index_bytes + size <= 64 * 1024 * 1024:
self.search_text[title_id] = text
self.search_index_bytes += size
self.visible = frozenset(visible)
self.db.create_function('visible', 1, lambda value: value in self.visible, deterministic=True)
self.db.create_function('search_match', 4, self.search_match, deterministic=True)
tables = ('titles', 'genres', 'countries', 'movie_sources', 'seasons', 'episodes', 'episode_sources')
self.counts = {name: self.db.execute('SELECT COUNT(*) FROM ' + name).fetchone()[0] for name in tables}
self.counts['visible_titles'] = len(self.visible)
def search_match(self, title_id, title, description, query):
text = self.search_text.get(title_id)
if text is None:
text = (normal(title), normal(description))
return query in text[0] or query in text[1]
def close(self):
self.db.close()
def health(self):
built = self.metadata.get('built_at_utc')
age = None
if built:
age = max(0, int((datetime.now(timezone.utc) - datetime.fromisoformat(built)).total_seconds()))
return {'status': 'ok', 'counts': self.counts.copy(),
'snapshot_built_at_utc': built, 'snapshot_age_seconds': age,
'provenance': self.metadata.get('provenance', 'Exported API snapshot'),
'offline': True, 'media_proxy': False,
'snapshot_limitations': ['Not live-synchronised', 'Media URLs may expire',
'Cast, public lists and subtitle metadata were not exported']}
@staticmethod
def clean(value):
if isinstance(value, dict):
return {k: Catalog.clean(v) for k, v in value.items()
if k.lower() not in {'comment', 'comments', 'comments_enabled', 'replies'}}
if isinstance(value, list):
return [Catalog.clean(v) for v in value]
return value
def rows(self, sql, params=()):
return [self.clean(json.loads(row[0])) for row in self.db.execute(sql, params)]
def tags(self, name):
columns = 'id,title,image' if name == 'countries' else 'id,title'
cursor = self.db.execute('SELECT ' + columns + ' FROM ' + name + ' ORDER BY id')
keys = [column[0] for column in cursor.description]
return [dict(zip(keys, row)) for row in cursor
if allowed_text(row[1]) and not (name == 'genres' and row[0] == 48)]
def titles(self, kind='poster', genre=0, country=0, order='created', page=0, query=None):
orders = {'created': 't.id DESC', 'updated': 't.id DESC',
'imdb': 't.imdb DESC,t.id DESC', 'rating': 't.rating DESC,t.id DESC',
'year': 't.year DESC,t.id DESC', 'title': 't.title COLLATE NOCASE,t.id',
'random': '(t.id * 2654435761) % 2147483647,t.id'}
if order not in orders or kind not in {'movie', 'serie', 'poster'} or not 0 <= page <= 100000:
raise ApiError(400, 'Unsupported catalog filter')
where, params = ['visible(t.id)'], []
if kind != 'poster':
where.append('t.type=?')
params.append(kind)
for table, column, value in [('title_genres', 'genre_id', genre),
('title_countries', 'country_id', country)]:
if value:
where.append('EXISTS (SELECT 1 FROM ' + table + ' f WHERE f.title_id=t.id AND f.' + column + '=?)')
params.append(value)
if query is not None:
where.append('search_match(t.id,t.title,t.description,?)')
params.append(normal(query))
return self.rows('SELECT t.raw_json FROM titles t WHERE ' + ' AND '.join(where)
+ ' ORDER BY ' + orders[order] + ' LIMIT 30 OFFSET ?', params + [page * 30])
def home(self):
return {'genres': [
{'id': -2, 'title': 'Latest in catalogue', 'posters': self.titles()},
{'id': -3, 'title': 'Highest IMDb ratings', 'posters': self.titles(order='imdb')},
{'id': -4, 'title': 'Series in catalogue', 'posters': self.titles(kind='serie')}
], 'actors': [], 'countries': self.tags('countries'), 'slides': []}
def legacy(self, path, query):
from urllib.parse import parse_qs
try:
raw = parse_qs(query, keep_blank_values=True, max_num_fields=8)
except ValueError:
raise ApiError(400, 'Invalid query') from None
if any(len(values) != 1 for values in raw.values()):
raise ApiError(400, 'Repeated query parameter')
args = {key: values[0] for key, values in raw.items()}
allowed = {'action', 'q', 'pageno'}
if path.endswith('/filter.php'):
allowed = {'action', 'garid', 'pageno'}
elif path.endswith('/star.php'):
allowed = {'key', 'action', 'q', 'pageno'}
elif path.endswith(('/pishnahad.php', '/bartar.php')):
allowed = set()
if args.keys() - allowed or len(args.get('q', '')) > 200 or len(args.get('key', '')) > 256:
raise ApiError(400, 'Unsupported query parameter')
try:
page = int(args.get('pageno') or 1) - 1
genre = int(args.get('garid') or 0)
except ValueError:
raise ApiError(400, 'Invalid page or genre') from None
if not 0 <= page <= 100000 or not 0 <= genre <= 999999999:
raise ApiError(400, 'Invalid page or genre')
action = args.get('action', '')
if path.endswith(('/pishnahad.php', '/bartar.php')):
return [{'title': [tag['title']],
'link': ['/api-user/newapi/filter.php?action=pishnahad&garid=' + str(tag['id']) + '&pageno=']}
for tag in self.tags('genres')
if self.db.execute('SELECT 1 FROM title_genres WHERE genre_id=? AND visible(title_id) LIMIT 1', (tag['id'],)).fetchone()]
if path == '/api-user/newapi/filter.php' and action == 'pishnahad':
return self.titles(genre=genre, order='imdb', page=page)
if path == '/api-user/newapi/like.php':
if action in {'search-movie', 'search-movie-en'}:
return self.titles(query=args.get('q', '').strip(), page=page) if args.get('q', '').strip() else []
if action in {'best-rand-top', 'best-rand-year'}:
return self.titles(order='year' if action.endswith('year') else 'imdb', page=page)
if action in {'all-actor', 'colection', 'search-trand'}:
return []
if path == '/api-user/filmahang/star.php' and action in {'starsearch', 'mazmon', 'share', 'new'}:
return []
raise ApiError(404, 'Metadata route not available in snapshot')
def response(self, target):
# Only immutable REST snapshot metadata. Health/banner freshness and
# search privacy are preserved: no search query is retained in this LRU.
cacheable = (target.startswith('/api/') and '?' not in target
and target.rstrip('/') not in {'/api/health', '/api/banners'})
with self.lock:
cached = self._responses.get(target) if cacheable else None
if cached is not None:
self._responses.move_to_end(target)
return cached
body = json.dumps(self.dispatch(target), ensure_ascii=False,
separators=(',', ':')).encode('utf-8')
if len(body) > 32 * 1024 * 1024:
raise ApiError(503, 'Metadata response exceeds safety limit')
size = len(body) + len(target.encode('utf-8'))
if (cacheable and size <= self.RESPONSE_CACHE_ITEM_BYTES
and size <= self.RESPONSE_CACHE_BYTES and self.RESPONSE_CACHE_ENTRIES > 0):
while self._responses and (
self._response_bytes + size > self.RESPONSE_CACHE_BYTES
or len(self._responses) >= self.RESPONSE_CACHE_ENTRIES):
old_target, old_body = self._responses.popitem(last=False)
self._response_bytes -= len(old_body) + len(old_target.encode('utf-8'))
self._responses[target] = body
self._response_bytes += size
return body
def dispatch(self, target):
from urllib.parse import urlsplit
if len(target) > 4096 or re.search(r'[\x00-\x20\x7f]', target):
raise ApiError(400, 'Invalid metadata route')
try:
parsed = urlsplit(target)
except ValueError:
raise ApiError(400, 'Invalid metadata route') from None
path = parsed.path
if (len(target) > 4096 or parsed.scheme or parsed.netloc or parsed.fragment
or '%' in path or '\\' in path or re.search(r'[\x00-\x20\x7f]', path)
or re.search(r'(^|/)\.{1,2}(/|$)', path)):
raise ApiError(400, 'Invalid metadata route')
key = r'[A-Za-z0-9_-]{0,256}'
with self.lock:
if path in {'/api-user/newapi/like.php', '/api-user/newapi/filter.php',
'/api-user/newapi/pishnahad.php', '/api-user/newapi/bartar.php',
'/api-user/filmahang/star.php'}:
return self.legacy(path, parsed.query)
if parsed.query:
raise ApiError(400, 'Unexpected query parameters')
if path in {'/api/banners', '/api/banners/'}:
return load_banners(self.banner_config)
if path in {'/api/health', '/api/health/'}:
return self.health()
if re.fullmatch(r'/api/first/' + key + '/', path):
return self.home()
if re.fullmatch(r'/api/genre/all/' + key + '/', path):
return self.tags('genres')
if re.fullmatch(r'/api/country/all/' + key + '/', path):
return self.tags('countries')
match = re.fullmatch(r'/api/(movie|serie|poster)/by/filtres/(\d{1,9})/(?:(\d{1,9})/)?([a-z]+)/([0-9]{1,6})/' + key + '/', path)
if match:
kind, genre, country, order, page = match.groups()
return self.titles(kind, int(genre), int(country or 0), order, int(page))
match = re.fullmatch(r'/api/movie/by/([0-9]{1,10})/' + key + '/', path)
if match:
title_id = int(match[1])
if title_id not in self.visible:
raise ApiError(404, 'Title not in snapshot')
item = self.rows('SELECT raw_json FROM titles WHERE id=?', (title_id,))[0]
item['sources'] = self.rows('SELECT raw_json FROM movie_sources WHERE title_id=? ORDER BY source_index', (title_id,))
return item
match = re.fullmatch(r'/api/season/by/serie/([0-9]{1,10})/' + key + '/', path)
if match:
title_id = int(match[1])
if title_id not in self.visible:
raise ApiError(404, 'Title not in snapshot')
return self.rows('SELECT raw_json FROM seasons WHERE series_id=? ORDER BY season_index', (title_id,))
if re.fullmatch(r'/api/(?:role/by/poster|movie/by/actor|subtitles/by/(?:movie|episode))/[0-9]{1,10}/' + key + '/', path):
return []
raise ApiError(404, 'Unknown metadata route')
def make_server(catalog, host='127.0.0.1', port=18765):
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.0'
def setup(self):
self.request.settimeout(10)
super().setup()
def log_message(self, *_args):
pass # Never record source links, search terms or shared route keys.
def reply(self, status, value):
body = value if isinstance(value, bytes) else json.dumps(
value, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
if len(body) > 32 * 1024 * 1024:
status, body = 503, b'{"error":"Metadata response exceeds safety limit"}'
self.send_response_only(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Cache-Control', 'no-store')
self.send_header('Connection', 'close')
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
self.close_connection = True
def do_GET(self):
try:
if self.headers.get('Transfer-Encoding') or self.headers.get('Content-Length', '0') != '0':
raise ApiError(400, 'Request body not supported')
self.reply(200, catalog.response(self.path))
except ApiError as error:
self.reply(error.status, {'error': str(error)})
except (BrokenPipeError, ConnectionResetError, TimeoutError):
self.close_connection = True
except Exception:
self.reply(503, {'error': 'Catalogue temporarily unavailable'})
do_HEAD = do_GET
def reject_write(self):
self.reply(405, {'error': 'Read-only metadata API'})
do_POST = do_PUT = do_DELETE = do_PATCH = do_CONNECT = do_OPTIONS = do_TRACE = reject_write
class Server(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 16
def __init__(self, address):
self.slots = threading.BoundedSemaphore(16)
super().__init__(address, Handler)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
if host != '127.0.0.1':
raise ValueError('Bind behind the HTTPS reverse proxy on IPv4 loopback only')
return Server((host, port))
def main():
import os
catalog = Catalog(os.environ.get('REELBOX_CATALOG_DB', '/var/lib/reelbox-api/catalog.sqlite'),
banner_config=os.environ.get('REELBOX_BANNER_CONFIG', '/var/lib/reelbox-api/banners.json'))
server = make_server(catalog)
try:
server.serve_forever()
finally:
server.server_close()
catalog.close()
if __name__ == '__main__':
main()
+419
View File
@@ -0,0 +1,419 @@
"""Metadata-only catalogue synchronization; publication is the caller's job.
Only fixed-origin, allowlisted metadata GETs are possible. Image/video URLs are
opaque stored metadata and are never followed. Supply the key from the calling
process (e.g. REELBOX_UPSTREAM_KEY). Reports and errors contain no request URLs.
"""
from contextlib import closing
from datetime import datetime, timezone
import http.client
import json
import math
import os
from pathlib import Path
import re
import sqlite3
import ssl
import time
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
UPSTREAM_BASE = 'https://ninwinios.info/api/'
MAX_RESPONSE_BYTES = 32 * 1024 * 1024
_ROUTE = re.compile(r'(?:genre/all|country/all|(?:movie|serie)/by/filtres/0/created/[0-9]{1,6}'
r'|movie/by/[1-9][0-9]{0,9}|season/by/serie/[1-9][0-9]{0,9})')
class SyncError(RuntimeError):
"""Safe, key-free error suitable for a scheduler log."""
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise SyncError('Upstream redirect denied')
def _invalid_constant(_value):
raise ValueError('Non-finite JSON number')
class Upstream:
"""Single-worker rate-limited client. ``attempts`` is total attempts, <=10.
``opener`` is an injectable trusted urllib-compatible opener for tests.
``cache_dir`` is reserved; responses are intentionally not cached on disk,
so a subsequent run always refreshes metadata. No response/key is logged.
"""
def __init__(self, key, interval=0.25, timeout=30, attempts=3, opener=None, cache_dir=None):
if not isinstance(key, str) or not key or len(key) > 4096:
raise ValueError('Upstream key required')
if (type(attempts) is not int or not 1 <= attempts <= 10
or not isinstance(interval, (int, float)) or not math.isfinite(interval) or interval < 0
or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0):
raise ValueError('Invalid upstream retry or timing configuration')
self._key = key
self.interval, self.timeout, self.attempts = interval, timeout, attempts
self._opener = opener if opener is not None else build_opener(_NoRedirect())
self.requests_count = 0
self._last_request = None
def get(self, path):
if not isinstance(path, str) or not _ROUTE.fullmatch(path):
raise ValueError('Unsupported upstream metadata route')
url = UPSTREAM_BASE + path + '/' + quote(self._key, safe='') + '/'
parsed = urlsplit(url)
if (parsed.scheme, parsed.netloc, parsed.query, parsed.fragment) != ('https', 'ninwinios.info', '', ''):
raise ValueError('Unsupported upstream origin')
request = Request(url, headers={'Accept': 'application/json', 'Accept-Encoding': 'identity'}, method='GET')
for attempt in range(self.attempts):
if self._last_request is not None:
delay = self.interval - (time.monotonic() - self._last_request)
if delay > 0:
time.sleep(delay)
self._last_request = time.monotonic()
self.requests_count += 1
try:
with self._opener.open(request, timeout=self.timeout) as response:
status = response.status
if status == 429 or 500 <= status <= 599:
raise HTTPError(url, status, 'Upstream temporarily unavailable', {}, None)
if status != 200 or response.geturl() != url:
raise SyncError('Upstream status or redirect denied')
media_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
# The original PHP API labels valid JSON as text/html.
# Only allow this known legacy label; parsing/schema checks still apply.
if media_type and media_type not in {'application/json', 'text/html'} and not media_type.endswith('+json'):
raise SyncError('Upstream did not return JSON metadata')
declared = response.headers.get('Content-Length')
if declared is not None:
if not re.fullmatch(r'[0-9]{1,12}', declared) or int(declared) > MAX_RESPONSE_BYTES:
raise SyncError('Invalid upstream metadata length')
body = response.read(MAX_RESPONSE_BYTES + 1)
if len(body) > MAX_RESPONSE_BYTES:
raise SyncError('Upstream metadata response exceeds safety limit')
if declared is not None and len(body) != int(declared):
raise SyncError('Truncated upstream metadata response')
value = json.loads(body, parse_constant=_invalid_constant)
detail = path.startswith('movie/by/') and '/filtres/' not in path
if (detail and not isinstance(value, dict)) or (not detail and not isinstance(value, list)):
raise SyncError('Unexpected upstream metadata shape')
return value
except HTTPError as error:
retry = error.code == 429 or 500 <= error.code <= 599
error.close()
if not retry or attempt + 1 == self.attempts:
raise SyncError('Upstream HTTP request failed') from None
except (ssl.SSLError, ValueError, RecursionError):
raise SyncError('Invalid upstream TLS or JSON metadata') from None
except (URLError, OSError, http.client.HTTPException) as error:
if isinstance(getattr(error, 'reason', None), ssl.SSLError) or attempt + 1 == self.attempts:
raise SyncError('Upstream transport request failed') from None
if attempt + 1 < self.attempts:
time.sleep(min(2 ** attempt, 8))
raise SyncError('Upstream request exhausted')
try:
from .catalog_api import allowed_text
except ImportError: # Also support `PYTHONPATH=server; import catalog_sync`.
from catalog_api import allowed_text
def _encode(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False)
def _id(value):
if type(value) is not int or not 1 <= value <= 9999999999:
raise SyncError('Malformed metadata identifier')
return value
def _objects(value):
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
raise SyncError('Malformed metadata list')
return value
def _merge(old, incoming):
result = dict(old)
for key, value in incoming.items():
if key in {'sources', 'episodes', 'genres', 'country'}:
result[key] = _merge_sequence(old.get(key, []), value)
elif key not in result or (value is not None and value != ''):
result[key] = value
return result
def _merge_sequence(old, incoming):
"""Retain old positions; stable IDs win, missing IDs fall back to index."""
old, incoming = _objects(old), _objects(incoming)
result = list(old)
by_id = {item['id']: index for index, item in enumerate(old) if item.get('id') is not None}
seen_ids, matched = set(), set()
for index, item in enumerate(incoming):
identity = item.get('id')
if identity is not None:
_id(identity)
if identity in seen_ids:
raise SyncError('Duplicate nested metadata identifier')
seen_ids.add(identity)
target = by_id.get(identity)
if target is None and index < len(old) and old[index].get('id') is None:
target = index
else:
target = index if index < len(old) else None
if target is not None:
if target in matched:
raise SyncError('Ambiguous nested metadata identity')
matched.add(target)
result[target] = _merge(old[target], item)
else:
result.append(_merge({}, item))
return result
def _validate_record(item, kind, require_title=False):
if not isinstance(item, dict) or item.get('error') or item.get('errors'):
raise SyncError('Malformed metadata record')
if item.get('id') is not None:
_id(item['id'])
if kind in {'season', 'episode', 'source'} and item.get('id') is None:
identity = item.get('url') if kind == 'source' else item.get('title')
if not isinstance(identity, str) or not identity.strip():
raise SyncError('Nested metadata has no usable identity')
if require_title and (not isinstance(item.get('title'), str) or not item['title'].strip()):
raise SyncError('New title has no usable name')
for field in ('title', 'description', 'duration', 'classification', 'image', 'cover',
'downloadas', 'playas', 'type', 'quality', 'url'):
if field in item and item[field] is not None and not isinstance(item[field], str):
raise SyncError('Malformed text metadata field')
for field in ('year', 'imdb', 'rating'):
value = item.get(field)
if value is not None and (type(value) not in {int, float} or not math.isfinite(value)):
raise SyncError('Malformed numeric metadata field')
if 'comment' in item and item['comment'] is not None and type(item['comment']) not in {bool, int}:
raise SyncError('Malformed comment metadata flag')
for field, nested_kind in [('sources', 'source'), ('episodes', 'episode'),
('genres', 'reference'), ('country', 'reference')]:
if field in item:
for nested in _objects(item[field]):
_validate_record(nested, nested_kind)
def _reference(db, table, item):
_validate_record(item, 'reference')
_id(item.get('id'))
columns = ['id', 'title', 'image'] if table == 'countries' else ['id', 'title']
db.execute('INSERT INTO ' + table + '(' + ','.join(columns) + ') VALUES (' +
','.join('?' for _ in columns) + ') ON CONFLICT(id) DO UPDATE SET ' +
','.join(c + '=COALESCE(excluded.' + c + ',' + table + '.' + c + ')' for c in columns[1:]),
[item.get(c) for c in columns])
def _save_title(db, incoming):
title_id = _id(incoming.get('id'))
record = db.execute('SELECT raw_json FROM titles WHERE id=?', (title_id,)).fetchone()
_validate_record(incoming, 'title', require_title=not record)
old = json.loads(record[0]) if record else {}
merged = _merge(old, incoming)
raw = _encode(merged)
if record and old == merged:
return False
fields = ['id', 'type', 'title', 'description', 'year', 'imdb', 'rating', 'duration',
'classification', 'image', 'cover', 'downloadas', 'playas', 'comments_enabled', 'raw_json']
values = [merged.get(c) for c in fields]
values[-2], values[-1] = merged.get('comment'), raw
db.execute('INSERT INTO titles(' + ','.join(fields) + ') VALUES (' + ','.join('?' for _ in fields) +
') ON CONFLICT(id) DO UPDATE SET ' + ','.join(c + '=excluded.' + c for c in fields[1:]), values)
for field, table, junction, column in [('genres', 'genres', 'title_genres', 'genre_id'),
('country', 'countries', 'title_countries', 'country_id')]:
for tag in _objects(merged.get(field, [])):
_reference(db, table, tag)
db.execute('INSERT OR IGNORE INTO ' + junction + '(title_id,' + column + ') VALUES (?,?)',
(title_id, tag['id']))
for index, source in enumerate(_objects(merged.get('sources', []))):
db.execute('INSERT INTO movie_sources VALUES (?,?,?,?,?,?,?) ON CONFLICT(title_id,source_index) '
'DO UPDATE SET source_id=excluded.source_id,quality=excluded.quality,'
'media_type=excluded.media_type,url=excluded.url,raw_json=excluded.raw_json',
(title_id, index, source.get('id'), source.get('quality'), source.get('type'),
source.get('url'), _encode(source)))
return True
def _save_seasons(db, series_id, incoming):
for season in _objects(incoming):
_validate_record(season, 'season')
previous = db.execute('SELECT season_index,raw_json FROM seasons WHERE series_id=? ORDER BY season_index',
(series_id,)).fetchall()
old = [json.loads(raw) for _, raw in previous]
merged = _merge_sequence(old, incoming)
changed = False
# Existing indices are retained even if an exported snapshot has gaps.
indices = [index for index, _ in previous]
next_index = max(indices, default=-1) + 1
indices += list(range(next_index, next_index + len(merged) - len(indices)))
for position, season in enumerate(merged):
if position < len(old) and season == old[position]:
continue
changed = True
si = indices[position]
db.execute('INSERT INTO seasons VALUES (?,?,?,?,?) ON CONFLICT(series_id,season_index) DO UPDATE SET '
'id=excluded.id,title=excluded.title,raw_json=excluded.raw_json',
(series_id, si, season.get('id'), season.get('title'), _encode(season)))
for ei, episode in enumerate(_objects(season.get('episodes', []))):
raw = _encode(episode)
current = db.execute('SELECT raw_json FROM episodes WHERE series_id=? AND season_index=? AND episode_index=?',
(series_id, si, ei)).fetchone()
if current and json.loads(current[0]) == episode:
continue
db.execute('INSERT INTO episodes VALUES (?,?,?,?,?,?,?,?,?,?) '
'ON CONFLICT(series_id,season_index,episode_index) DO UPDATE SET '
'id=excluded.id,title=excluded.title,description=excluded.description,duration=excluded.duration,'
'downloadas=excluded.downloadas,playas=excluded.playas,raw_json=excluded.raw_json',
(series_id, si, ei, episode.get('id'), episode.get('title'), episode.get('description'),
episode.get('duration'), episode.get('downloadas'), episode.get('playas'), raw))
for source_index, source in enumerate(_objects(episode.get('sources', []))):
db.execute('INSERT INTO episode_sources VALUES (?,?,?,?,?,?,?,?,?) '
'ON CONFLICT(series_id,season_index,episode_index,source_index) DO UPDATE SET '
'source_id=excluded.source_id,quality=excluded.quality,media_type=excluded.media_type,'
'url=excluded.url,raw_json=excluded.raw_json',
(series_id, si, ei, source_index, source.get('id'), source.get('quality'),
source.get('type'), source.get('url'), _encode(source)))
db.execute('INSERT INTO series_fetch_status VALUES (?,?,?,?) ON CONFLICT(series_id) DO UPDATE SET '
'status=excluded.status,season_count=excluded.season_count,source_url=excluded.source_url',
(series_id, 'fetched', len(merged), UPSTREAM_BASE + f'season/by/serie/{series_id}/'))
return changed
def _counts(db):
tables = ('titles', 'genres', 'countries', 'movie_sources', 'seasons', 'episodes', 'episode_sources')
counts = {name: db.execute('SELECT COUNT(*) FROM ' + name).fetchone()[0] for name in tables}
for name, kind in [('movies', 'movie'), ('series', 'serie')]:
counts[name] = db.execute('SELECT COUNT(*) FROM titles WHERE type=?', (kind,)).fetchone()[0]
counts['sources'] = counts['movie_sources'] + counts['episode_sources']
counts['visible_titles'] = sum(1 for kind, title, description in db.execute('SELECT type,title,description FROM titles')
if kind in {'movie', 'serie'} and allowed_text(title) and allowed_text(description))
return counts
def synchronize(source_db: Path, staged_db: Path, upstream, max_pages=2000, progress=None) -> dict:
"""Copy a snapshot into an exclusively created candidate and refresh metadata.
Never publishes or mutates the source. A failed candidate is removed. The
caller owns locking, free-space checks, atomic publication and API reload.
``progress`` accepts dictionaries containing only phase/count/total.
Both catalogue types must reach an empty page within ``max_pages`` each;
all visible series are refreshed, including those absent from those pages.
The returned updated-series IDs are a sorted sample of at most 100, with
``updated_series_count`` reporting the full count. No media is fetched.
"""
if type(max_pages) is not int or not 1 <= max_pages <= 1000000:
raise ValueError('Invalid catalogue page limit')
source_db, staged_db = Path(source_db), Path(staged_db)
if source_db.resolve() == staged_db.resolve():
raise ValueError('Candidate must differ from source')
created, db = False, None
start_requests = getattr(upstream, 'requests_count', None)
def emit(phase, count, total):
if progress is not None:
progress({'phase': phase, 'count': count, 'total': total})
try:
emit('copy', 0, 1)
with closing(sqlite3.connect(source_db.resolve().as_uri() + '?mode=ro', uri=True)) as source:
fd = os.open(staged_db, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
created = True
os.close(fd)
db = sqlite3.connect(staged_db)
source.backup(db)
emit('copy', 1, 1)
db.execute('PRAGMA foreign_keys=ON')
db.execute('PRAGMA journal_mode=DELETE')
db.execute('BEGIN IMMEDIATE')
before = _counts(db)
metadata = {k: json.loads(v) for k, v in db.execute('SELECT * FROM metadata')}
for count, (route, table) in enumerate([('genre/all', 'genres'), ('country/all', 'countries')], 1):
for item in _objects(upstream.get(route)):
_reference(db, table, item)
emit('references', count, 2)
updated_series = set()
for kind in ('movie', 'serie'):
seen = set()
for page in range(max_pages):
items = _objects(upstream.get(f'{kind}/by/filtres/0/created/{page}'))
emit(kind + '_pages', page + 1, max_pages)
if not items:
break
for item in items:
title_id = _id(item.get('id'))
if title_id in seen:
continue
seen.add(title_id)
if item.get('type') == 'channel':
continue
if item.get('type') != kind:
raise SyncError('Unexpected catalogue title type')
if not allowed_text(item.get('title')) or not allowed_text(item.get('description')):
continue
exists = db.execute('SELECT type FROM titles WHERE id=?', (title_id,)).fetchone()
if exists and exists[0] != kind:
raise SyncError('Catalogue title type changed')
if kind == 'movie' and not exists:
detail = upstream.get(f'movie/by/{title_id}')
if not isinstance(detail, dict) or detail.get('id') != title_id or detail.get('type') != kind:
raise SyncError('Malformed movie detail')
item = _merge(item, detail)
if not allowed_text(item.get('title')) or not allowed_text(item.get('description')):
continue
if _save_title(db, item) and kind == 'serie':
updated_series.add(title_id)
else:
raise SyncError('Catalogue pagination did not reach an empty terminal page')
series = [row[0] for row in db.execute("SELECT id,title,description FROM titles WHERE type='serie'")
if allowed_text(row[1]) and allowed_text(row[2])]
for index, series_id in enumerate(series, 1):
seasons = _objects(upstream.get(f'season/by/serie/{series_id}'))
if _save_seasons(db, series_id, seasons):
updated_series.add(series_id)
emit('series', index, len(series))
after = _counts(db)
completed = datetime.now(timezone.utc).isoformat()
report = {'status': 'ok', 'counts': after,
'added': {name: after[name] - before[name] for name in
('titles', 'movies', 'series', 'episodes', 'sources')},
'updated_series_ids': sorted(updated_series)[:100], 'updated_series_count': len(updated_series),
'media_files_downloaded': False, 'synchronized_at_utc': completed,
'upstream_base': UPSTREAM_BASE}
end_requests = getattr(upstream, 'requests_count', None)
if type(start_requests) is int and type(end_requests) is int:
report['requests_count'] = end_requests - start_requests
metadata.setdefault('original_snapshot_built_at_utc', metadata.get('built_at_utc'))
metadata.update({'built_at_utc': completed, 'synchronized_at_utc': completed,
'sync_interval_seconds': 259200, 'media_files_downloaded': False,
'counts': after, 'last_sync': report, 'catalog_pagination_complete': True,
'series_episode_fetch_complete': True})
db.executemany('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json',
[(k, _encode(v)) for k, v in metadata.items()])
if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise SyncError('Candidate integrity check failed')
if db.execute('PRAGMA foreign_key_check').fetchall():
raise SyncError('Candidate foreign key check failed')
db.commit()
db.close()
db = None
emit('complete', 1, 1)
return report
except BaseException as error:
if db is not None:
db.close()
if created:
for suffix in ('', '-journal', '-wal', '-shm'):
Path(str(staged_db) + suffix).unlink(missing_ok=True)
if isinstance(error, (KeyboardInterrupt, SystemExit, FileExistsError, SyncError)):
raise
raise SyncError('Catalogue synchronization failed; candidate discarded') from None
+228
View File
@@ -0,0 +1,228 @@
"""Stage, validate and atomically publish read-only catalogue updates."""
import argparse
from contextlib import closing
from datetime import datetime, timezone
import fcntl
import json
import os
from pathlib import Path
import sqlite3
import stat
import subprocess
import time
import uuid
class UpdateError(Exception):
pass
def now():
return datetime.now(timezone.utc).isoformat()
def fsync_directory(path):
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def atomic_json(path, value):
temp = path.with_name('.' + path.name + '.' + uuid.uuid4().hex)
try:
with temp.open('x', encoding='utf-8') as stream:
os.chmod(temp, 0o600)
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write('\n')
stream.flush()
os.fsync(stream.fileno())
os.replace(temp, path)
fsync_directory(path.parent)
finally:
temp.unlink(missing_ok=True)
def check_candidate(source, stage):
with closing(sqlite3.connect(source.resolve().as_uri() + '?mode=ro', uri=True)) as old:
with closing(sqlite3.connect(stage.resolve().as_uri() + '?mode=ro', uri=True)) as candidate:
if candidate.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
raise UpdateError('Candidate integrity check failed')
if candidate.execute('PRAGMA foreign_key_check').fetchone() is not None:
raise UpdateError('Candidate relation check failed')
for table in ('titles', 'genres', 'countries', 'title_genres', 'title_countries',
'series_fetch_status', 'seasons', 'episodes',
'movie_sources', 'episode_sources'):
before = old.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0]
after = candidate.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0]
if after < before:
raise UpdateError('Candidate would remove existing catalogue entries')
source_counts = (
'SELECT title_id, COUNT(*) FROM movie_sources GROUP BY title_id',
'''SELECT e.series_id, e.id,
CASE WHEN e.id IS NULL THEN e.season_index END,
CASE WHEN e.id IS NULL THEN e.episode_index END, COUNT(*)
FROM episodes AS e JOIN episode_sources AS s
USING (series_id, season_index, episode_index)
GROUP BY 1, 2, 3, 4''',
)
for query in source_counts:
after = {row[:-1]: row[-1] for row in candidate.execute(query)}
for row in old.execute(query):
if after.get(row[:-1], 0) < row[-1]:
raise UpdateError('Candidate would remove existing catalogue sources')
row = candidate.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()
if row is None or not isinstance(json.loads(row[0]), str):
raise UpdateError('Candidate timestamp missing')
return json.loads(row[0])
def run_update(database, state_dir, upstream, *, synchronize_fn=None,
restart_fn=None, health_fn=None, max_pages=2000, progress=None):
database, state_dir = Path(database).resolve(), Path(state_dir).resolve()
state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
if synchronize_fn is None:
from catalog_sync import synchronize
synchronize_fn = synchronize
restart_fn = restart_fn or restart_api
health_fn = health_fn or wait_for_api
with (state_dir / 'sync.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise UpdateError('A catalogue update is already running') from None
original = database.stat()
identity = (original.st_ino, original.st_size, original.st_mtime_ns)
stage = database.with_name('.catalog-sync-' + uuid.uuid4().hex + '.sqlite')
backup = database.with_name('catalog.previous.sqlite')
backup_temp = database.with_name('.catalog-backup-' + uuid.uuid4().hex)
published = False
started = now()
atomic_json(state_dir / 'last-result.json', {'status': 'running', 'started_at_utc': started})
try:
with closing(sqlite3.connect(database.as_uri() + '?mode=ro', uri=True)) as old:
row = old.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()
if row is None or not isinstance(json.loads(row[0]), str):
raise UpdateError('Previous catalogue timestamp missing')
previous_stamp = json.loads(row[0])
report = synchronize_fn(database, stage, upstream, max_pages=max_pages, progress=progress)
stamp = check_candidate(database, stage)
current = database.stat()
if identity != (current.st_ino, current.st_size, current.st_mtime_ns):
raise UpdateError('Live catalogue changed during synchronisation')
os.chown(stage, original.st_uid, original.st_gid)
os.chmod(stage, stat.S_IMODE(original.st_mode))
with stage.open('rb') as stream:
os.fsync(stream.fileno())
os.link(database, backup_temp)
os.replace(backup_temp, backup)
fsync_directory(database.parent)
os.replace(stage, database)
published = True
fsync_directory(database.parent)
restart_fn()
health_fn(stamp)
result = {**report, 'status': 'success', 'started_at_utc': started,
'completed_at_utc': now(), 'api_health_verified': True,
'media_files_downloaded': False}
atomic_json(state_dir / 'last-success.json', result)
atomic_json(state_dir / 'last-result.json', result)
return result
except Exception as error:
rollback_failed = False
if published:
try:
os.replace(backup, database)
fsync_directory(database.parent)
restart_fn()
health_fn(previous_stamp)
except Exception:
rollback_failed = True
atomic_json(state_dir / 'last-result.json', {
'status': 'failed', 'started_at_utc': started, 'completed_at_utc': now(),
'error_type': type(error).__name__, 'rollback_failed': rollback_failed,
'media_files_downloaded': False,
})
if rollback_failed:
raise UpdateError('Update failed and API restart needs operator attention') from None
raise UpdateError('Update failed; the previous catalogue was retained') from None
finally:
for suffix in ('', '-journal', '-wal', '-shm'):
Path(str(stage) + suffix).unlink(missing_ok=True)
backup_temp.unlink(missing_ok=True)
def restart_api():
subprocess.run(['/bin/systemctl', 'restart', 'reelbox-api.service'],
check=True, capture_output=True, timeout=120)
def wait_for_api(expected_stamp):
from urllib.request import ProxyHandler, build_opener
opener = build_opener(ProxyHandler({}))
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
try:
with opener.open('http://127.0.0.1:18765/api/health', timeout=5) as response:
health = json.loads(response.read(65536))
if (health.get('status') == 'ok' and health.get('media_proxy') is False
and health.get('snapshot_built_at_utc') == expected_stamp):
return
except Exception:
pass
time.sleep(2)
raise UpdateError('Updated API did not become healthy in time')
def emit_progress(event):
fields = {key: event[key] for key in ('phase', 'total') if key in event}
if 'completed' in event or 'count' in event:
fields['completed'] = event.get('completed', event.get('count'))
print(json.dumps({'progress': fields}), flush=True)
def probe(upstream):
genres = upstream.get('genre/all')
movies = upstream.get('movie/by/filtres/0/created/0')
series = upstream.get('serie/by/filtres/0/created/0')
if not all(isinstance(rows, list) and rows for rows in (genres, movies, series)):
raise UpdateError('Upstream metadata probe returned an unexpected shape')
movie_id = movies[0]['id']
series_id = series[0]['id']
detail = upstream.get('movie/by/' + str(movie_id))
seasons = upstream.get('season/by/serie/' + str(series_id))
if not isinstance(detail, dict) or not isinstance(seasons, list):
raise UpdateError('Upstream detail or season probe failed')
return {'status': 'probe_ok', 'movie_id': movie_id, 'series_id': series_id,
'movie_sources': len(detail.get('sources', [])), 'seasons': len(seasons),
'media_files_downloaded': False}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--database', type=Path, default=Path('/var/lib/reelbox-api/catalog.sqlite'))
parser.add_argument('--state-dir', type=Path, default=Path('/var/lib/reelbox-sync'))
parser.add_argument('--interval', type=float, default=0.25)
parser.add_argument('--max-pages', type=int, default=2000)
parser.add_argument('--probe', action='store_true')
args = parser.parse_args()
from catalog_sync import Upstream
key = os.environ.get('REELBOX_UPSTREAM_KEY', '')
if not key:
print('REELBOX_UPSTREAM_KEY is required; no requests sent', flush=True)
return 2
try:
upstream = Upstream(key, interval=max(0.25, args.interval))
result = probe(upstream) if args.probe else run_update(
args.database, args.state_dir, upstream, max_pages=args.max_pages, progress=emit_progress)
print(json.dumps(result, ensure_ascii=False), flush=True)
return 0
except Exception as error:
print(json.dumps({'status': 'failed', 'error_type': type(error).__name__,
'message': str(error) if isinstance(error, UpdateError) else 'Metadata update failed; see safe status record'}), flush=True)
return 1
if __name__ == '__main__':
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
{
"format_version": 1,
"built_at_utc": "2026-11-02T07:00:18.228980+00:00",
"provenance": "API snapshot reconstructed from the read-only endpoints used by the downloaded Simba apps; not an original server-side database dump.",
"api_base": "https://ninwinios.info/api/",
"discovery_url": "http://ninnies-server.info/api-user/newapi/config-server186.txt",
"download_page": "https://zil.ink/movieboxapp",
"counts": {
"titles": 29931,
"genres": 50,
"countries": 84,
"movie_sources": 53853,
"seasons": 19132,
"episodes": 348897,
"episode_sources": 441945,
"movie": 20435,
"serie": 9496,
"series_seasons_fetched": 9496,
"series_seasons_pending": 0,
"movie_titles_without_sources": 8
},
"catalog_coverage": {
"movie": {
"kind": "movie",
"first_empty_page": 682,
"pages": 682,
"records": 20435,
"unique_ids": 20435,
"last_nonempty_page_count": 5
},
"serie": {
"kind": "serie",
"first_empty_page": 317,
"pages": 317,
"records": 9496,
"unique_ids": 9496,
"last_nonempty_page_count": 16
}
},
"catalog_pagination_complete": true,
"series_episode_fetch_complete": true,
"media_files_downloaded": false,
"media_urls_playback_checked": false,
"notes": [
"API pagination was exhausted, but the service is live and does not offer transactional snapshots or independent total counts.",
"Titles and media URLs are preserved as returned; dead URLs and catalog omissions may exist.",
"No accounts, private profiles, user data, comments or payment data were exported.",
"Extraction does not establish rights to redistribute metadata or stream the referenced videos."
],
"artifacts": {
"catalog.sqlite": {
"bytes": 598843392,
"sha256": "45213410c51eeee984a02883823e312272bd6c6a44ec75452e9f90f74db1a9d8"
},
"catalog.jsonl.gz": {
"bytes": 13425106,
"sha256": "a3ef9cba408e554e4b18cd4f8ededb2d69010972a02d7f0182f15fedc669eeae"
},
"seasons.jsonl.gz": {
"bytes": 6530014,
"sha256": "c1dc5e96a40f33587c6bcaf0dd1a3573d92a693c0756bbef11778ccc91ae6668"
},
"schema.sql": {
"bytes": 2375,
"sha256": "fd6deb8ed09540aeae8ebb00a621cbfefff19d4fd86e48d33edb28117e298066"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"download_page": "https://zil.ink/movieboxapp",
"inspection": "Static archive extraction; neither app was executed",
"binaries": [
{
"file": "Simba-v82.apk",
"url": "https://ninamacos.info/apk/Simba-v82.apk",
"bytes": 21415761,
"sha256": "089b742354ef868cdd1b49e0bb8f593f7c1c0df1e35d8a893da3fff1c05bea3c"
},
{
"file": "Simba-windows.zip",
"url": "https://ninamacos.info/apk/windows/Simba-Setup-1.3.5-x64.zip",
"bytes": 191886111,
"sha256": "e7904367e539f832b69991b067c489d4174b46a1918277e2bfc366e36f748754"
}
]
}
+23
View File
@@ -0,0 +1,23 @@
"""Install API routing on the existing Iran ReelBox HTTPS server."""
from datetime import datetime, timezone
from pathlib import Path
import shutil
import subprocess
config = Path('/etc/reelbox/nginx.conf')
original = config.read_text()
include = ' include /etc/reelbox/api-locations.conf;\n'
if include not in original:
anchor = ' root /var/www/reelbox/current;\n'
if original.count(anchor) != 1:
raise RuntimeError('Unexpected site configuration; refusing to alter it')
backup = config.with_name('nginx.conf.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '.backup')
shutil.copy2(config, backup)
config.write_text(original.replace(anchor, include + anchor))
try:
subprocess.run(['/usr/sbin/nginx', '-t', '-c', str(config)], check=True)
except Exception:
config.write_text(original)
raise
subprocess.run(['systemctl', 'reload', 'reelbox-web'], check=True)
print('ReelBox metadata API routes enabled; website root and existing listeners unchanged')
+42
View File
@@ -0,0 +1,42 @@
# Include inside the ReelBox HTTPS server on 443 (legacy 8443 remains served).
# Preserve website/download routes and unrelated services; inspect each host.
location /api/ {
access_log off;
# Public metadata only; low compression level trades little CPU for fewer
# transferred bytes. Nginx handles negotiation, HEAD and chunked framing.
gzip on;
gzip_types application/json;
gzip_min_length 1024;
gzip_comp_level 2;
gzip_vary on;
gzip_proxied no-store;
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:18765;
proxy_http_version 1.0;
proxy_set_header Host localhost;
proxy_set_header Connection close;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
proxy_send_timeout 10s;
proxy_max_temp_file_size 0;
}
location /api-user/ {
access_log off;
# Public metadata only; low compression level trades little CPU for fewer
# transferred bytes. Nginx handles negotiation, HEAD and chunked framing.
gzip on;
gzip_types application/json;
gzip_min_length 1024;
gzip_comp_level 2;
gzip_vary on;
gzip_proxied no-store;
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:18765;
proxy_http_version 1.0;
proxy_set_header Host localhost;
proxy_set_header Connection close;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
proxy_send_timeout 10s;
proxy_max_temp_file_size 0;
}
+36
View File
@@ -0,0 +1,36 @@
[Unit]
Description=ReelBox read-only offline Catalogue API
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/reelbox-api
ExecStart=/usr/bin/python3 -B /opt/reelbox-api/catalog_api.py
Environment=REELBOX_CATALOG_DB=/var/lib/reelbox-api/catalog.sqlite
Environment=PYTHONUNBUFFERED=1
Restart=on-failure
RestartSec=3
TimeoutStartSec=120
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_UNIX
IPAddressDeny=any
IPAddressAllow=localhost
MemoryMax=256M
TasksMax=24
LimitNOFILE=256
UMask=0077
[Install]
WantedBy=multi-user.target
+40
View File
@@ -0,0 +1,40 @@
[Unit]
Description=Refresh ReelBox catalogue metadata from the original API
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=1d
StartLimitBurst=3
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/reelbox-api
EnvironmentFile=/etc/reelbox/sync.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 -B /opt/reelbox-api/catalog_sync_runner.py
TimeoutStartSec=8h
Restart=on-failure
RestartSec=1h
StateDirectory=reelbox-sync
StateDirectoryMode=0700
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/var/lib/reelbox-api /var/lib/reelbox-sync
MemoryMax=512M
TasksMax=16
LimitNOFILE=512
Nice=10
IOSchedulingClass=idle
StandardOutput=journal
StandardError=journal
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Refresh ReelBox catalogue every three days
[Timer]
OnBootSec=15min
OnUnitActiveSec=3d
AccuracySec=1min
Unit=reelbox-sync.service
[Install]
WantedBy=timers.target
+37
View File
@@ -0,0 +1,37 @@
PRAGMA foreign_keys=ON;
CREATE TABLE metadata(key TEXT PRIMARY KEY,value_json TEXT NOT NULL);
CREATE TABLE titles(
id INTEGER PRIMARY KEY,type TEXT NOT NULL,title TEXT,description TEXT,
year INTEGER,imdb REAL,rating REAL,duration TEXT,classification TEXT,
image TEXT,cover TEXT,downloadas TEXT,playas TEXT,comments_enabled INTEGER,
raw_json TEXT NOT NULL);
CREATE INDEX titles_type ON titles(type);
CREATE INDEX titles_title ON titles(title COLLATE NOCASE);
CREATE INDEX titles_year ON titles(year);
CREATE TABLE genres(id INTEGER PRIMARY KEY,title TEXT);
CREATE TABLE countries(id INTEGER PRIMARY KEY,title TEXT,image TEXT);
CREATE TABLE title_genres(title_id INTEGER REFERENCES titles(id),genre_id INTEGER REFERENCES genres(id),PRIMARY KEY(title_id,genre_id));
CREATE TABLE title_countries(title_id INTEGER REFERENCES titles(id),country_id INTEGER REFERENCES countries(id),PRIMARY KEY(title_id,country_id));
CREATE TABLE movie_sources(
title_id INTEGER NOT NULL REFERENCES titles(id),source_index INTEGER NOT NULL,
source_id INTEGER,quality TEXT,media_type TEXT,url TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(title_id,source_index));
CREATE INDEX movie_sources_title ON movie_sources(title_id);
CREATE TABLE series_fetch_status(
series_id INTEGER PRIMARY KEY REFERENCES titles(id),status TEXT NOT NULL,
season_count INTEGER,source_url TEXT NOT NULL);
CREATE TABLE seasons(
series_id INTEGER NOT NULL REFERENCES titles(id),season_index INTEGER NOT NULL,
id INTEGER,title TEXT,raw_json TEXT NOT NULL,PRIMARY KEY(series_id,season_index));
CREATE TABLE episodes(
series_id INTEGER NOT NULL,season_index INTEGER NOT NULL,episode_index INTEGER NOT NULL,
id INTEGER,title TEXT,description TEXT,duration TEXT,downloadas TEXT,playas TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(series_id,season_index,episode_index),
FOREIGN KEY(series_id,season_index) REFERENCES seasons(series_id,season_index));
CREATE TABLE episode_sources(
series_id INTEGER NOT NULL,season_index INTEGER NOT NULL,episode_index INTEGER NOT NULL,source_index INTEGER NOT NULL,
source_id INTEGER,quality TEXT,media_type TEXT,url TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(series_id,season_index,episode_index,source_index),
FOREIGN KEY(series_id,season_index,episode_index) REFERENCES episodes(series_id,season_index,episode_index));
CREATE INDEX episode_sources_series ON episode_sources(series_id);
+98
View File
@@ -0,0 +1,98 @@
import json
import os
from pathlib import Path
import tempfile
import unittest
from test_catalog_api import fixture, load_service
class BannerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.path = fixture(self.temp.name)
self.api = load_service()
self.catalog = self.api.Catalog(self.path)
def tearDown(self):
self.catalog.close()
self.temp.cleanup()
def test_local_config_reloads_safe_bounded_ads_without_admin_endpoint(self):
config = Path(self.temp.name) / 'banners.json'
self.catalog.close()
self.catalog = self.api.Catalog(self.path, banner_config=config)
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
ad = {'title_en': 'Fixture sponsor', 'title_fa': 'حامی آزمایشی',
'subtitle_en': 'Test only', 'target_url': 'https://sponsor.example/offer',
'image_url': 'https://sponsor.example/banner.jpg', 'secret': 'not public'}
config.write_text(json.dumps({'banners': [ad]}))
result = self.catalog.dispatch('/api/banners/')['banners']
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['title_fa'], ad['title_fa'])
self.assertEqual(result[0]['image_url'], ad['image_url'])
self.assertNotIn('secret', result[0])
config.write_text('{invalid')
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for target in ['http://evil.example/', 'javascript:alert(1)', '//evil.example/',
'https://a:b@evil.example/', 'https://evil.example:444/',
'https://evil.example/ bad', 'https://evil.example/\\\\bad',
'https://evil.example/%0abad', 'https://',
'https://-bad.example/', 'https://bad..example/', 'https://bad.example./',
'https://evil.example/%zz', 'https://evil.example/' + 'x' * 2048]:
config.write_text(json.dumps({'banners': [{**ad, 'target_url': target}, ad]}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 1, target)
for row in [None, True, 5, {}, {**ad, 'title_en': 'x' * 81},
{**ad, 'subtitle_en': 'x' * 181}, {**ad, 'title_fa': 'bad\u202etext'},
{**ad, 'image_url': 'http://evil.example/banner.jpg'}]:
config.write_text(json.dumps({'banners': [row, ad]}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 1, row)
config.write_text(json.dumps({'banners': [ad] * 30}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 5)
config.write_text(json.dumps({'banners': [{}] * 20 + [ad]}))
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
config.write_text(' ' * 65537)
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for value in [None, [], {'banners': {}}, {'banners': []}]:
config.write_text(json.dumps(value))
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for route in ['/api/banners/admin/', '/api/banners/config/']:
with self.assertRaises(self.api.ApiError) as error:
self.catalog.dispatch(route)
self.assertEqual(error.exception.status, 404)
def test_http_banner_endpoint_is_json_no_store_and_rejects_writes(self):
from http.client import HTTPConnection
import threading
server = self.api.make_server(self.catalog, port=0)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
for method, expected in [('GET', 200), ('HEAD', 200), ('POST', 405), ('PUT', 405)]:
client = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
client.request(method, '/api/banners/')
response = client.getresponse()
self.assertEqual(response.status, expected)
self.assertEqual(response.getheader('Cache-Control'), 'no-store')
self.assertIn('application/json', response.getheader('Content-Type'))
body = response.read()
if method == 'GET':
self.assertEqual(json.loads(body), {'banners': []})
if method == 'HEAD':
self.assertEqual(body, b'')
client.close()
finally:
server.shutdown()
server.server_close()
worker.join(timeout=5)
def test_read_only_banner_route_defaults_to_empty_ad_list(self):
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
self.assertEqual(self.catalog.dispatch('/api/banners'), {'banners': []})
with self.assertRaises(self.api.ApiError) as error:
self.catalog.dispatch('/api/banners/?url=https://evil.example/')
self.assertEqual(error.exception.status, 400)
if __name__ == '__main__':
unittest.main()
+83
View File
@@ -0,0 +1,83 @@
"""Regression coverage for app advertisements disguised as movie records."""
import json
import sqlite3
import unittest
from urllib.parse import urlencode
import test_catalog_api as fixtures
class CatalogueAdTests(unittest.TestCase):
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def test_app_promotions_and_typographic_variants_are_rejected(self):
self.open()
for text in [
'نورتون', 'نُوْرتـو\u200cن', 'N O R T O N',
'آموزش زبان به سبک موسوی‌باکس',
'آموزش زبان به سبک مووی باکس',
'آموزش زبان به سبک مُووی‌ باكْس',
'نسخه جدید هورتون', 'نسخه‌ جدید هُورتـون',
'اپلیکیشن هورتون', 'نرم افزار هورتون',
]:
with self.subTest(text=text):
self.assertFalse(self.api.allowed_text(text))
for text in [
'Horton Hears a Who!', 'نام پارسی انیمیشن: هورتون',
'آموزش زبان New Headway', 'The Marriage App',
'فیلمی درباره یک اپلیکیشن خیالی', 'بازیگران: Alex Horton',
]:
with self.subTest(text=text):
self.assertTrue(self.api.allowed_text(text))
def test_ads_are_absent_from_every_title_surface_and_direct_lookup(self):
with sqlite3.connect(self.path) as db:
original = json.loads(db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
records = [
(66, 'نورتون', ''),
(67, 'آموزش زبان به سبک مووی باکس', ''),
(68, 'آموزش زبان به سبک موسوی‌باکس', ''),
(69, 'نسخه جدید هورتون', ''),
(70, 'Horton Hears a Who!', 'نام پارسی انیمیشن: هورتون'),
(71, 'آموزش زبان New Headway', 'مجموعه ویدیویی آموزش مهارت مکالمه'),
(72, 'The Marriage App', 'فیلمی درباره یک اپلیکیشن خیالی'),
(73, 'Language companion', 'اپلیکیشن هورتون'),
]
for title_id, title, description in records:
raw = {**original, 'id': title_id, 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(title_id, 'movie', title, description, 2026, 9.9, 5, json.dumps(raw)))
db.execute('INSERT INTO title_genres VALUES (?,2)', (title_id,))
db.execute('INSERT INTO title_countries VALUES (?,1)', (title_id,))
catalog = self.open()
ads = {66, 67, 68, 69, 73}
actual = set()
for page in range(4):
for order in ['created', 'imdb', 'rating', 'year', 'title', 'random']:
for path in [f'/api/movie/by/filtres/0/{order}/{page}/public/',
f'/api/poster/by/filtres/2/1/{order}/{page}/public/']:
rows = catalog.dispatch(path)
self.assertFalse(ads & {row['id'] for row in rows}, path)
actual.update(row['id'] for row in rows)
self.assertTrue({70, 71, 72} <= actual)
for group in catalog.dispatch('/api/first/public/')['genres']:
self.assertFalse(ads & {row['id'] for row in group['posters']})
for action in ['best-rand-top', 'best-rand-year']:
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': action}))
self.assertFalse(ads & {row['id'] for row in rows})
for query in ['نورتون', 'هورتون', 'آموزش زبان', 'موسوی', 'مووی', 'Language companion']:
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': 'search-movie', 'q': query}))
self.assertFalse(ads & {row['id'] for row in rows}, query)
rows = catalog.dispatch('/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1')
self.assertFalse(ads & {row['id'] for row in rows})
for title_id in ads:
for path in [f'/api/movie/by/{title_id}/public/', f'/api/season/by/serie/{title_id}/public/']:
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(path)
self.assertEqual(error.exception.status, 404)
for title_id in [70, 71, 72]:
self.assertEqual(catalog.dispatch(f'/api/movie/by/{title_id}/public/')['id'], title_id)
detail = catalog.dispatch('/api/movie/by/1/public/')
self.assertEqual(detail['sources'], original['sources'])
+147
View File
@@ -0,0 +1,147 @@
"""Upstream notices are not catalogue films, including after later scrapes."""
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import tempfile
from typing import Any
import unittest
from urllib.parse import urlencode
import test_catalog_api as fixtures
from test_catalog_api import load_service
from test_catalog_sync import FakeUpstream, load_sync
class AnnouncementPolicyTests(unittest.TestCase):
def test_generic_notice_labels_are_blocked_without_hiding_real_notice_films(self):
api = load_service()
for text in [
'اطلاعیه جدید', 'اطلاعیه مهم ', 'اطلاعیه خیلی مهم',
'📢 **اطلاعـیّهٔ جدید**', 'اطلاع يه جديد', 'اطلاع‌رسانی جدید',
'اعلان جدید', 'اعلان مهم شماره ۲',
'به روز رسانی جدید', 'به‌روزرسانی برنامه', 'بروز رسانی نسخه ویندوز',
'آپدیت جدید برنامه', 'New announcement', 'IMPORTANT NOTICE!',
'Urgent announcement: service changes', 'App update',
]:
with self.subTest(blocked=text):
self.assertFalse(api.allowed_text(text))
for text in [
'Red Notice', 'SAS: Red Notice', 'Burn Notice', 'Notice to Quit',
'Two Weeks Notice', 'The Announcement', 'اعلان قرمز', 'Upgrade',
'News of the World', 'نام پارسی فیلم: اعلان قرمز',
'A detective reads an important notice before leaving town.',
'در این فیلم یک اطلاعیه جدید، زندگی شخصیت اصلی را تغییر می‌دهد.',
]:
with self.subTest(allowed=text):
self.assertTrue(api.allowed_text(text))
class AnnouncementSurfaceTests(unittest.TestCase):
path: Path
temp: tempfile.TemporaryDirectory
api: Any
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def test_notices_are_absent_from_all_catalogue_routes_and_direct_lookup(self):
bad = {70, 71, 72, 73, 74}
with closing(sqlite3.connect(self.path)) as db:
original = json.loads(db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
records = [
(70, 'movie', 'اطلاعیه جدید', ''),
(71, 'movie', 'اطلاعیه خیلی مهم', ''),
(72, 'movie', 'به روز رسانی جدید', ''),
(73, 'movie', 'Service changes', 'Important announcement: app changes'),
(74, 'serie', 'اعلان جدید', ''),
(75, 'movie', 'Red Notice', 'نام پارسی فیلم: اعلان قرمز'),
(76, 'serie', 'Burn Notice', 'An ordinary spy series'),
]
for ident, kind, title, description in records:
raw = {**original, 'id': ident, 'type': kind, 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(ident, kind, title, description, 2026, 9.9, 5, json.dumps(raw)))
db.execute('INSERT INTO title_genres VALUES (?,2)', (ident,))
db.execute('INSERT INTO title_countries VALUES (?,1)', (ident,))
db.commit()
catalog = self.open()
found = set()
for kind in ('movie', 'serie', 'poster'):
for order in ('created', 'updated', 'imdb', 'rating', 'year', 'title', 'random'):
for page in range(4):
for path in (f'/api/{kind}/by/filtres/0/{order}/{page}/public/',
f'/api/{kind}/by/filtres/2/1/{order}/{page}/public/'):
ids = {row['id'] for row in catalog.dispatch(path)}
self.assertFalse(bad & ids, path)
found.update(ids)
self.assertTrue({75, 76} <= found)
for group in catalog.dispatch('/api/first/public/')['genres']:
self.assertFalse(bad & {row['id'] for row in group['posters']})
for action in ('best-rand-top', 'best-rand-year'):
self.assertFalse(bad & {row['id'] for row in catalog.dispatch(
'/api-user/newapi/like.php?' + urlencode({'action': action}))})
for action in ('search-movie', 'search-movie-en'):
for query in ('اطلاعیه', 'به روز', 'announcement', 'Notice'):
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': action, 'q': query}))
self.assertFalse(bad & {row['id'] for row in rows})
self.assertFalse(bad & {row['id'] for row in catalog.dispatch(
'/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1')})
for ident in bad:
for path in (f'/api/movie/by/{ident}/public/', f'/api/season/by/serie/{ident}/public/'):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(path)
self.assertEqual(error.exception.status, 404)
self.assertEqual(catalog.dispatch('/api/movie/by/75/public/')['title'], 'Red Notice')
self.assertEqual(catalog.dispatch('/api/movie/by/76/public/')['title'], 'Burn Notice')
def test_repeated_scrapes_keep_old_and_new_notice_ids_out_of_the_api(self):
# Old raw rows stay for snapshot/source preservation, but are never served.
with closing(sqlite3.connect(self.path)) as db:
item = {'id': 90, 'type': 'movie', 'title': 'اطلاعیه مهم', 'description': ''}
db.execute('INSERT INTO titles(id,type,title,description,raw_json) VALUES (?,?,?,?,?)',
(90, 'movie', item['title'], '', json.dumps(item)))
db.commit()
source_bytes = self.path.read_bytes()
source = self.path
sync = load_sync()
for run, offset in enumerate((0, 100), 1):
new_notice, series_notice, detail_notice = 91 + offset, 92 + offset, 93 + offset
rows = [
{'id': 90, 'type': 'movie', 'title': 'اطلاعیه جدید'},
{'id': new_notice, 'type': 'movie', 'title': '📢 اطلاعیه جدید'},
{'id': detail_notice, 'type': 'movie', 'title': 'Looks like a film'},
{'id': 94, 'type': 'movie', 'title': 'Red Notice'},
]
upstream = FakeUpstream({
'movie/by/filtres/0/created/0': rows,
f'movie/by/{detail_notice}': {'id': detail_notice, 'type': 'movie', 'title': 'به روز رسانی جدید'},
'movie/by/94': {'id': 94, 'type': 'movie', 'title': 'Red Notice', 'sources': []},
'serie/by/filtres/0/created/0': [
{'id': series_notice, 'type': 'serie', 'title': 'Important announcement'}],
})
stage = Path(self.temp.name) / f'scrape-{run}.sqlite'
sync.synchronize(source, stage, upstream)
self.assertNotIn(f'movie/by/{new_notice}', upstream.calls)
self.assertNotIn('movie/by/90', upstream.calls)
self.assertNotIn(f'season/by/serie/{series_notice}', upstream.calls)
with closing(sqlite3.connect(stage)) as db:
self.assertEqual(db.execute('SELECT COUNT(*) FROM titles WHERE id IN (?,?,?)',
(new_notice, series_notice, detail_notice)).fetchone()[0], 0)
self.assertEqual(db.execute('SELECT title FROM titles WHERE id=90').fetchone()[0], 'اطلاعیه مهم')
api = load_service()
catalog = api.Catalog(stage)
try:
for ident in (90, new_notice, series_notice, detail_notice):
with self.assertRaises(api.ApiError):
catalog.dispatch(f'/api/movie/by/{ident}/public/')
self.assertEqual(catalog.dispatch('/api/movie/by/94/public/')['title'], 'Red Notice')
self.assertEqual(catalog.dispatch('/api/season/by/serie/2/public/')[0]['episodes'][0]['id'], 501)
finally:
catalog.close()
source = stage
self.assertEqual(self.path.read_bytes(), source_bytes)
if __name__ == '__main__':
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
import importlib.util
import json
import os
from pathlib import Path
import sqlite3
import tempfile
import unittest
SERVER = Path(__file__).resolve().parents[1]
def load_service():
spec = importlib.util.spec_from_file_location('catalog_api', SERVER / 'catalog_api.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def fixture(directory):
path = Path(directory) / 'catalog.sqlite'
con = sqlite3.connect(path)
con.executescript((SERVER / 'tests' / 'schema.sql').read_text())
metadata = {'built_at_utc': '2026-11-02T07:00:18+00:00',
'provenance': 'Realistic test snapshot',
'catalog_pagination_complete': True, 'series_episode_fetch_complete': True}
con.executemany('INSERT INTO metadata VALUES (?,?)',
[(k, json.dumps(v)) for k, v in metadata.items()])
con.executemany('INSERT INTO genres VALUES (?,?)', [(1, 'Horror'), (2, 'Action'), (32, 'Top 250'), (48, 'Live')])
con.execute('INSERT INTO countries VALUES (1,?,?)', ('America', 'http://art.test/a%2Fb.jpg?token=Ab+Cd'))
for i in range(1, 66):
kind = 'serie' if i == 2 else 'movie'
title = 'Alpha' if i == 1 else 'Warrior' if i == 2 else 'Film ' + str(i)
if i == 63:
title = 'نسخه جدید ملودی باکس'
if i == 64:
kind = 'channel'
if i == 65:
title = 'Telegram @channelname'
item = {'id': i, 'type': kind, 'title': title, 'description': 'Actor Example بازیگران',
'year': 2020 + i % 7, 'imdb': i / 10, 'rating': 4.1,
'image': 'http://art.test/a%2Fb.jpg?token=Ab+Cd',
'cover': 'https://art.test/a b.jpg', 'comment': True,
'genres': [{'id': 2, 'title': 'Action'}], 'country': [{'id': 1, 'title': 'America'}]}
if kind == 'movie':
source = {'id': 100 + i, 'type': 'mkv', 'quality': '720',
'url': 'http://media.test/Movie%20Name%2F.mkv?token=Ab+Cd&x=%2f#fragment'}
item['sources'] = [source]
con.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(i, kind, title, item['description'], item['year'], item['imdb'], item['rating'], json.dumps(item)))
if kind == 'movie':
con.execute('INSERT INTO movie_sources VALUES (?,?,?,?,?,?,?)',
(i, 0, source['id'], '720', 'mkv', source['url'], json.dumps(source)))
con.execute('INSERT INTO title_genres VALUES (?,2)', (i,))
con.execute('INSERT INTO title_countries VALUES (?,1)', (i,))
con.execute('INSERT INTO title_genres VALUES (1,32)')
episode = {'id': 501, 'title': 'Episode 1', 'sources': [{'id': 201, 'type': 'mkv',
'quality': None, 'url': 'http://media.test/a%20b.mkv?signature=%2f+Ab'}]}
season = {'id': 1001, 'title': 'Season 1', 'episodes': [episode]}
con.execute('INSERT INTO seasons VALUES (2,0,1001,?,?)', ('Season 1', json.dumps(season)))
con.execute('INSERT INTO episodes(series_id,season_index,episode_index,id,title,raw_json) VALUES (2,0,0,501,?,?)',
('Episode 1', json.dumps(episode)))
con.execute('INSERT INTO episode_sources VALUES (2,0,0,0,201,NULL,?,?,?)',
('mkv', episode['sources'][0]['url'], json.dumps(episode['sources'][0])))
con.execute('INSERT INTO series_fetch_status VALUES (2,?,1,?)', ('ok', 'https://old.test/api/season/2'))
con.commit()
con.close()
return path
class CatalogTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.path = fixture(self.temp.name)
def tearDown(self):
if hasattr(self, 'catalog'):
self.catalog.close()
self.temp.cleanup()
def open(self):
self.api = load_service()
self.catalog = self.api.Catalog(self.path)
return self.catalog
def test_health_is_real_snapshot_counts_and_freshness(self):
catalog = self.open()
health = catalog.dispatch('/api/health')
self.assertEqual(health['status'], 'ok')
self.assertEqual(health['counts']['titles'], 65)
self.assertEqual(health['counts']['episodes'], 1)
self.assertEqual(health['counts']['visible_titles'], 62)
self.assertEqual(health['snapshot_built_at_utc'], '2026-11-02T07:00:18+00:00')
self.assertEqual(health['provenance'], 'Realistic test snapshot')
self.assertFalse(health['media_proxy'])
self.assertTrue(health['offline'])
self.assertGreaterEqual(health['snapshot_age_seconds'], 0)
with self.assertRaises(sqlite3.OperationalError):
catalog.db.execute('DELETE FROM titles')
def test_catalog_filters_pages_and_original_media_links(self):
catalog = self.open()
first = catalog.dispatch('/api/movie/by/filtres/0/created/0/public/')
second = catalog.dispatch('/api/movie/by/filtres/0/created/1/public/')
self.assertEqual(len(first), 30)
self.assertEqual(len(second), 30)
self.assertFalse({r['id'] for r in first} & {r['id'] for r in second})
self.assertTrue(all(r['id'] <= 62 and r['type'] == 'movie' for r in first + second))
self.assertEqual([r['id'] for r in catalog.dispatch('/api/movie/by/filtres/0/created/2/public/')], [1])
self.assertEqual(catalog.dispatch('/api/movie/by/filtres/0/created/3/public/'), [])
self.assertEqual([r['id'] for r in catalog.dispatch('/api/poster/by/filtres/32/1/imdb/0/public/')], [1])
self.assertEqual([r['id'] for r in catalog.dispatch('/api/serie/by/filtres/0/year/0/public/')], [2])
film = catalog.dispatch('/api/movie/by/1/public/')
original = json.loads(catalog.db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
self.assertEqual(film['sources'], original['sources'])
self.assertEqual(film['image'], original['image'])
self.assertNotIn('comment', film)
seasons = catalog.dispatch('/api/season/by/serie/2/public/')
self.assertEqual(seasons[0]['episodes'][0]['sources'][0]['url'], 'http://media.test/a%20b.mkv?signature=%2f+Ab')
self.assertEqual(catalog.dispatch('/api/genre/all/public/')[0]['id'], 1)
self.assertNotIn(48, [r['id'] for r in catalog.dispatch('/api/genre/all/public/')])
self.assertEqual(catalog.dispatch('/api/country/all/public/')[0]['title'], 'America')
for title_id in (63, 64, 65, 999):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(f'/api/movie/by/{title_id}/public/')
self.assertEqual(error.exception.status, 404)
def test_home_search_recommendations_curated_and_unexported_metadata(self):
catalog = self.open()
home = catalog.dispatch('/api/first/public/')
self.assertTrue(home['genres'][0]['posters'])
self.assertEqual(home['actors'], [])
self.assertEqual(home['countries'][0]['id'], 1)
search = catalog.dispatch('/api-user/newapi/like.php?action=search-movie-en&q=alpha&pageno=1')
self.assertEqual([r['id'] for r in search], [1])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=no-match&pageno=1'), [])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=%27%20OR%201%3D1--'), [])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=best-rand-top&pageno=1')[0]['id'], 62)
self.assertTrue(catalog.dispatch('/api-user/newapi/like.php?action=best-rand-year&pageno=1'))
curated = catalog.dispatch('/api-user/newapi/pishnahad.php')
self.assertTrue(curated)
self.assertEqual(len(curated[0]['title']), len(curated[0]['link']))
self.assertTrue(catalog.dispatch(curated[0]['link'][0] + '1'))
self.assertEqual([r['id'] for r in catalog.dispatch('/api-user/newapi/filter.php?action=pishnahad&garid=32&pageno=1')], [1])
for target in ['/api-user/newapi/like.php?action=all-actor&pageno=1',
'/api-user/newapi/like.php?action=colection&pageno=1',
'/api-user/filmahang/star.php?key=public&action=starsearch&q=Example&pageno=1',
'/api-user/filmahang/star.php?key=public&action=mazmon&pageno=1',
'/api-user/filmahang/star.php?key=public&action=share&pageno=1',
'/api/role/by/poster/1/public/', '/api/movie/by/actor/12/public/',
'/api/subtitles/by/movie/1/public/', '/api/subtitles/by/episode/501/public/']:
self.assertEqual(catalog.dispatch(target), [], target)
def test_rejects_proxy_comments_traversal_and_unbounded_inputs(self):
catalog = self.open()
for target in ['/api/proxy?url=http://169.254.169.254/', '/api/download/movie.mkv',
'/api/comments/by/poster/1/public/', '/api/../catalog.sqlite',
'/api/%2e%2e/catalog.sqlite', '//evil.test/api/health',
'/api/movie/by/1/public/?url=https://media.test/movie.mkv',
'/api-user/newapi/like.php?action=comments',
'/api-user/newapi/like.php?action=search-movie&q=a&q=b',
'/api-user/newapi/like.php?action=search-movie&q=' + 'a' * 201,
'/api-user/newapi/like.php?action=search-movie&pageno=-1',
'/api-user/newapi/like.php?action=search-movie&pageno=100002',
'/api/movie/by/filtres/0/views/0/public/']:
with self.assertRaises(self.api.ApiError, msg=target):
catalog.dispatch(target)
def test_raw_controls_and_malformed_urls_are_rejected_before_parsing(self):
catalog = self.open()
for target in ['/api/\thealth', '\n/api/health', '/api/health\r', 'http://[',
'/api-user/newapi/like.php?action=search-movie&q=a\tb']:
with self.subTest(target=repr(target)):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(target)
self.assertEqual(error.exception.status, 400)
def test_real_http_is_read_only_bounded_and_returns_direct_source_urls(self):
import threading
from urllib.request import Request, urlopen
from urllib.error import HTTPError
catalog = self.open()
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
origin = 'http://127.0.0.1:' + str(server.server_port)
try:
with urlopen(origin + '/api/movie/by/1/public/', timeout=3) as response:
detail = json.load(response)
self.assertEqual(response.headers['Content-Type'], 'application/json; charset=utf-8')
self.assertNotIn('Server', response.headers)
self.assertEqual(detail['sources'][0]['url'], 'http://media.test/Movie%20Name%2F.mkv?token=Ab+Cd&x=%2f#fragment')
for method in ['POST', 'PUT', 'DELETE', 'PATCH', 'CONNECT']:
with self.assertRaises(HTTPError) as error:
urlopen(Request(origin + '/api/health', method=method), timeout=3)
self.assertEqual(error.exception.code, 405)
error.exception.close()
with self.assertRaises(HTTPError) as error:
urlopen(origin + '/api/proxy?url=https://media.test/a.mkv', timeout=3)
self.assertIn(error.exception.code, (400, 404))
error.exception.close()
with urlopen(Request(origin + '/api/health', method='HEAD'), timeout=3) as response:
self.assertEqual(response.status, 200)
self.assertEqual(response.read(), b'')
finally:
server.shutdown()
server.server_close()
thread.join(3)
def test_worker_saturation_rejects_extra_connections_and_releases_slots(self):
import socket
import threading
import time
catalog = self.open()
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
sockets = []
def wait_slots(value):
deadline = time.monotonic() + 4
while server.slots._value != value and time.monotonic() < deadline:
time.sleep(.01)
self.assertEqual(server.slots._value, value)
try:
for _ in range(16):
sock = socket.create_connection(server.server_address, timeout=2)
sock.sendall(b'GET /api/health HTTP/1.0\r\n')
sockets.append(sock)
wait_slots(0)
with socket.create_connection(server.server_address, timeout=2) as extra:
try:
self.assertEqual(extra.recv(1), b'')
except ConnectionResetError:
pass
for sock in sockets:
sock.close()
sockets.clear()
wait_slots(16)
with socket.create_connection(server.server_address, timeout=2) as recovered:
recovered.sendall(b'GET /api/health HTTP/1.0\r\n\r\n')
self.assertIn(b'200', recovered.recv(100))
finally:
for sock in sockets:
sock.close()
server.shutdown()
server.server_close()
thread.join(3)
if __name__ == '__main__':
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
"""Operation-count regressions; timings are recorded separately, never asserted."""
import json
import threading
import unittest
from unittest.mock import patch
from urllib.request import Request, urlopen
from urllib.error import HTTPError
import test_catalog_api as fixtures
class CatalogPerformanceTests(unittest.TestCase):
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def http_server(self, catalog):
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def stop():
server.shutdown()
server.server_close()
thread.join(3)
self.addCleanup(stop)
return 'http://127.0.0.1:' + str(server.server_port)
def test_repeated_snapshot_http_read_does_not_query_or_serialize_again(self):
catalog = self.open()
origin = self.http_server(catalog)
with urlopen(origin + '/api/first/public/', timeout=3) as response:
first = response.read()
queries = []
catalog.db.set_trace_callback(queries.append)
with patch.object(self.api.json, 'dumps', wraps=json.dumps) as encode:
with urlopen(origin + '/api/first/public/', timeout=3) as response:
self.assertEqual(response.read(), first)
self.assertEqual(queries, [], 'Warm snapshot HTTP responses must reuse encoded bytes')
self.assertEqual(encode.call_count, 0)
def test_cache_bounds_lru_and_large_response_bypass(self):
catalog = self.open()
catalog.RESPONSE_CACHE_ENTRIES = 2
one = '/api/movie/by/1/public/'
two = '/api/movie/by/2/public/'
three = '/api/movie/by/3/public/'
first = catalog.response(one)
catalog.response(two)
self.assertIs(catalog.response(one), first)
catalog.response(three)
self.assertEqual(list(catalog._responses), [one, three])
self.assertLessEqual(catalog._response_bytes, catalog.RESPONSE_CACHE_BYTES)
catalog.RESPONSE_CACHE_ITEM_BYTES = 1
catalog.response(two)
self.assertNotIn(two, catalog._responses)
# Byte budget, not just number of entries, forces eviction.
catalog.RESPONSE_CACHE_ITEM_BYTES = 1024 * 1024
catalog.RESPONSE_CACHE_BYTES = len(catalog.response(three)) + len(three) + 1
catalog.response(two)
self.assertLessEqual(catalog._response_bytes, catalog.RESPONSE_CACHE_BYTES)
self.assertEqual(len(catalog._responses), 1)
def test_dynamic_responses_errors_and_queries_are_never_retained(self):
catalog = self.open()
with patch.object(catalog, 'health', side_effect=[{'age': 1}, {'age': 2}]):
self.assertNotEqual(catalog.response('/api/health'), catalog.response('/api/health'))
with patch.object(self.api, 'load_banners', side_effect=[{'banners': [1]}, {'banners': [2]}]):
self.assertNotEqual(catalog.response('/api/banners'), catalog.response('/api/banners'))
catalog.response('/api-user/newapi/like.php?action=search-movie&q=Alpha')
for _ in range(2):
with self.assertRaises(self.api.ApiError):
catalog.response('/api/movie/by/999/public/')
self.assertEqual(list(catalog._responses), [])
def test_warm_head_reuses_get_bytes_and_body_requests_still_fail(self):
catalog = self.open()
origin = self.http_server(catalog)
with urlopen(origin + '/api/first/public/', timeout=3) as response:
first = response.read()
with urlopen(Request(origin + '/api/first/public/', method='HEAD'), timeout=3) as response:
self.assertEqual(response.read(), b'')
self.assertEqual(int(response.headers['Content-Length']), len(first))
with self.assertRaises(HTTPError) as error:
urlopen(Request(origin + '/api/first/public/', method='GET', data=b'x'), timeout=3)
self.assertEqual(error.exception.code, 400)
error.exception.close()
def test_normalized_search_matches_fallback_with_unicode_and_pagination(self):
catalog = self.open()
indexed = catalog.search_text
self.assertLessEqual(catalog.search_index_bytes, 64 * 1024 * 1024)
for query in ['Actor', 'بازیگران', 'بازيگران', 'ALPHA', 'not-present', 'Film 4']:
for page in [0, 1, 2]:
catalog.search_text = indexed
expected = catalog.titles(query=query, page=page)
catalog.search_text = {}
self.assertEqual(catalog.titles(query=query, page=page), expected)
catalog.search_text = indexed
def test_search_normalizes_query_not_every_snapshot_row(self):
catalog = self.open()
with patch.object(self.api, 'normal', wraps=self.api.normal) as normalize:
catalog.db.create_function('normal', 1, normalize, deterministic=True)
result = catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=ACTOR&pageno=1')
self.assertEqual([row['id'] for row in result], list(range(62, 32, -1)))
self.assertLessEqual(normalize.call_count, 2,
'Immutable title text must not be normalized on every search')
if __name__ == '__main__':
unittest.main()
+463
View File
@@ -0,0 +1,463 @@
"""Synchronizer tests use the exported schema, never the upstream service."""
import copy
from contextlib import closing
import importlib.util
import io
import json
import os
import sys
from pathlib import Path
import sqlite3
import ssl
import tempfile
import unittest
from unittest.mock import patch
from urllib.error import HTTPError, URLError
from test_catalog_api import SERVER, fixture, load_service
def load_sync():
spec = importlib.util.spec_from_file_location('catalog_sync', SERVER / 'catalog_sync.py')
module = importlib.util.module_from_spec(spec)
with patch.object(sys, 'path', [str(SERVER)] + sys.path):
spec.loader.exec_module(module)
return module
class Response(io.BytesIO):
status = 200
headers = {}
def __init__(self, payload=b'[]', url=None, status=200):
super().__init__(payload)
self.url, self.status = url, status
def geturl(self):
return self.url
class QueueOpener:
def __init__(self, *responses):
self.responses = list(responses)
self.calls = []
def open(self, request, timeout):
self.calls.append((request.full_url, request.get_method(), timeout))
response = self.responses.pop(0)
if isinstance(response, Exception):
raise response
if response.url is None:
response.url = request.full_url
return response
class UpstreamTests(unittest.TestCase):
def test_exact_fixed_origin_metadata_urls_and_quoted_key(self):
sync = load_sync()
opener = QueueOpener(*(Response(b'{}' if p.startswith('movie/by/3') else b'[]') for p in
['genre/all', 'country/all', 'movie/by/35721',
'movie/by/filtres/0/created/0', 'serie/by/filtres/0/created/12',
'season/by/serie/35716']))
client = sync.Upstream('test secret/+#?', interval=0, opener=opener)
paths = ['genre/all', 'country/all', 'movie/by/35721', 'movie/by/filtres/0/created/0',
'serie/by/filtres/0/created/12', 'season/by/serie/35716']
for path in paths:
client.get(path)
self.assertEqual(opener.calls, [('https://ninwinios.info/api/' + p +
'/test%20secret%2F%2B%23%3F/', 'GET', 30) for p in paths])
self.assertEqual(client.requests_count, 6)
for bad in ['https://evil.test/api/genre/all', '//evil.test/a', '/genre/all',
'genre/all/', 'genre/../all', 'genre/%2e%2e/all', 'genre/all?url=x',
'genre/all#x', 'movie/by/1/surprise', 'download/movie.mkv',
'movie/by/1\n', 'movie/by/0', 'movie/by/-1', 'movie/by/filtres/0/views/0']:
with self.subTest(path=repr(bad)), self.assertRaises(ValueError):
client.get(bad)
self.assertEqual(client.requests_count, 6)
def test_real_upstream_legacy_html_label_still_requires_valid_json(self):
sync = load_sync()
response = Response(b'[{"id":1,"title":"Action"}]')
response.headers = {'Content-Type': 'text/html; charset=UTF-8'}
client = sync.Upstream('k', interval=0, opener=QueueOpener(response))
self.assertEqual(client.get('genre/all'), [{'id': 1, 'title': 'Action'}])
html = Response(b'<html>Service unavailable</html>')
html.headers = {'Content-Type': 'text/html; charset=UTF-8'}
opener = QueueOpener(html)
with self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
def test_wrong_content_type_and_truncated_length_are_not_retried(self):
sync = load_sync()
for headers in ({'Content-Type': 'video/mp4'}, {'Content-Type': 'image/jpeg'},
{'Content-Length': '999'}, {'Content-Length': 'bad'},
{'Content-Length': str(32 * 1024 * 1024 + 1)}):
response = Response(b'[]')
response.headers = headers
opener = QueueOpener(response)
with self.subTest(headers=headers), self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
def test_bounded_retry_and_rate_limit(self):
sync = load_sync()
for status in (429, 503):
error = HTTPError('https://secret.test/key', status, 'private key', {}, None)
opener = QueueOpener(error, Response(b'[]'))
client = sync.Upstream('private-key', interval=.25, opener=opener)
with patch.object(sync.time, 'sleep') as sleep:
self.assertEqual(client.get('genre/all'), [])
self.assertGreaterEqual(sleep.call_count, 1)
self.assertEqual(client.requests_count, 2)
opener = QueueOpener(URLError('private URL'), Response(b'[]'))
with patch.object(sync.time, 'sleep'):
self.assertEqual(sync.Upstream('k', interval=0, opener=opener).get('genre/all'), [])
opener = QueueOpener(Response(), Response())
client = sync.Upstream('k', interval=.25, opener=opener)
with patch.object(sync.time, 'monotonic', return_value=100), patch.object(sync.time, 'sleep') as sleep:
client.get('genre/all')
client.get('country/all')
sleep.assert_called_once_with(.25)
def test_auth_cert_redirect_json_and_size_fail_closed_without_secrets(self):
sync = load_sync()
for failure in [HTTPError('https://secret.test/private-key', code, 'private-key', {}, None)
for code in (301, 302, 307, 308, 401, 403, 404)] + [
URLError(ssl.SSLCertVerificationError('private-key')),
ssl.SSLCertVerificationError('private-key')]:
opener = QueueOpener(failure)
client = sync.Upstream('private-key', interval=0, opener=opener)
with self.assertRaises(sync.SyncError) as error:
client.get('genre/all')
self.assertNotIn('private-key', str(error.exception))
self.assertNotIn('https:', str(error.exception))
self.assertEqual(len(opener.calls), 1)
for response in [Response(b'not JSON'), Response(b'NaN'), Response(b'{}'),
Response(b'null'), Response(b'[]', url='https://evil.test/video.mkv'),
Response(b'[]', status=302), Response(b'[' * (32 * 1024 * 1024 + 1))]:
opener = QueueOpener(response)
with self.assertRaises(sync.SyncError):
sync.Upstream('private-key', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
client = sync.Upstream('k', interval=0)
from urllib.request import HTTPRedirectHandler
handlers = [h for h in client._opener.handlers if isinstance(h, HTTPRedirectHandler)]
self.assertEqual(len(handlers), 1)
with self.assertRaises(sync.SyncError):
handlers[0].redirect_request(None, None, 302, 'moved', {}, 'https://evil.test/video')
def test_retry_exhaustion_and_configuration_are_bounded(self):
sync = load_sync()
opener = QueueOpener(*(HTTPError('https://secret/key', 503, 'key', {}, None) for _ in range(3)))
with patch.object(sync.time, 'sleep'), self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 3)
for kwargs in ({'attempts': 0}, {'attempts': 11}, {'interval': -1}, {'timeout': 0},
{'interval': float('nan')}, {'timeout': float('inf')}):
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
sync.Upstream('k', **kwargs)
class FakeUpstream:
def __init__(self, routes=None):
self.routes = {'genre/all': [], 'country/all': [],
'movie/by/filtres/0/created/0': [],
'movie/by/filtres/0/created/1': [],
'serie/by/filtres/0/created/0': [],
'serie/by/filtres/0/created/1': [], 'season/by/serie/2': []}
self.routes.update(routes or {})
self.calls = []
self.requests_count = 0
def get(self, path):
self.calls.append(path)
self.requests_count += 1
if path not in self.routes:
raise AssertionError('Unexpected metadata path: ' + path)
value = self.routes[path]
if isinstance(value, Exception):
raise value
return copy.deepcopy(value)
class SynchronizeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.source = fixture(self.temp.name)
self.stage = Path(self.temp.name) / 'stage.sqlite'
self.sync = load_sync()
def tearDown(self):
self.temp.cleanup()
def rows(self, path, sql, args=()):
with closing(sqlite3.connect(path)) as db:
return db.execute(sql, args).fetchall()
def metadata(self, path):
return {key: json.loads(value) for key, value in self.rows(path, 'SELECT * FROM metadata')}
def test_all_known_visible_series_refresh_and_series_samples_are_bounded(self):
with closing(sqlite3.connect(self.source)) as db:
for title_id, title, description in [(90, 'An older series', 'Normal description'),
(91, 'Telegram @channelname', ''),
(92, 'Disguised advertisement', 'ملودی باکس')]:
item = {'id': title_id, 'type': 'serie', 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,raw_json) VALUES (?,?,?,?,?)',
(title_id, 'serie', title, description, json.dumps(item)))
db.commit()
upstream = FakeUpstream({'season/by/serie/90': [{'id': 900, 'title': 'Season 1', 'episodes': [
{'id': 901, 'title': 'New episode on an old series'}]}]})
report = self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(report['added']['episodes'], 1)
self.assertIn('season/by/serie/2', upstream.calls)
self.assertIn('season/by/serie/90', upstream.calls)
self.assertNotIn('season/by/serie/91', upstream.calls)
self.assertNotIn('season/by/serie/92', upstream.calls)
self.assertEqual(report['counts']['series'], 4)
self.assertEqual(self.rows(self.stage, 'SELECT COUNT(*) FROM titles WHERE id IN (91,92)'), [(2,)])
self.stage.unlink()
records = [{'id': i, 'type': 'serie', 'title': 'New Series ' + str(i)} for i in range(100, 201)]
routes = {'serie/by/filtres/0/created/0': records, 'season/by/serie/90': []}
routes.update({f'season/by/serie/{item["id"]}': [] for item in records})
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['updated_series_count'], 101)
self.assertEqual(len(report['updated_series_ids']), 100)
self.assertEqual(report['updated_series_ids'], list(range(100, 200)))
def test_progress_failure_discards_candidate_and_missing_source_is_not_created(self):
original = self.source.read_bytes()
def reject(_event):
raise RuntimeError('private-key')
with self.assertRaises(self.sync.SyncError) as error:
self.sync.synchronize(self.source, self.stage, FakeUpstream(), progress=reject)
self.assertNotIn('private-key', str(error.exception))
self.assertEqual(self.source.read_bytes(), original)
self.assertFalse(self.stage.exists())
missing = Path(self.temp.name) / 'missing.sqlite'
with self.assertRaises(self.sync.SyncError):
self.sync.synchronize(missing, self.stage, FakeUpstream())
self.assertFalse(missing.exists())
self.assertFalse(self.stage.exists())
def test_malformed_metadata_discards_candidate_not_source(self):
original = self.source.read_bytes()
cases = [
{'genre/all': {'error': 'not a list'}},
{'country/all': [{'error': 'bad metadata'}]},
{'movie/by/filtres/0/created/0': {'error': 'failed'}},
{'serie/by/filtres/0/created/0': [None]},
{'serie/by/filtres/0/created/0': [{'id': 70, 'type': 'serie'}]},
{'serie/by/filtres/0/created/0': [{'id': 70, 'type': 'serie', 'title': {'bad': 'title'}}]},
{'movie/by/filtres/0/created/0': [{'id': True, 'type': 'movie', 'title': 'Bad'}]},
{'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Alpha', 'sources': None}]},
{'season/by/serie/2': {'error': 'not seasons'}},
{'season/by/serie/2': [{'error': 'not a season'}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': {'error': 'not episodes'}}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'error': 'not an episode'}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': [{}]}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': [{'id': 201, 'url': []}]}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': 'not sources'}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501}, {'id': 501}]}]},
{'season/by/serie/2': ValueError('https://private.test/private-key')},
]
for routes in cases:
with self.subTest(routes=routes):
with self.assertRaises(self.sync.SyncError) as error:
self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertNotIn('private-key', str(error.exception))
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
def test_429_503_exhaustion_rolls_back_staged_writes(self):
original = self.source.read_bytes()
for status in (429, 503):
opener = QueueOpener(Response(b'[{"id":99,"title":"A new genre"}]'), Response(),
*(HTTPError('https://private.test/private-key', status, 'private-key', {}, None)
for _ in range(3)))
upstream = self.sync.Upstream('private-key', interval=0, opener=opener)
with patch.object(self.sync.time, 'sleep'), self.assertRaises(self.sync.SyncError):
self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(len(opener.calls), 5)
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
def test_page_limit_and_existing_candidates_are_never_published_or_overwritten(self):
original = self.source.read_bytes()
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Updated'}]}
with self.assertRaises(self.sync.SyncError):
self.sync.synchronize(self.source, self.stage, FakeUpstream(routes), max_pages=1)
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
self.stage.write_bytes(b'existing candidate')
with self.assertRaises(FileExistsError):
self.sync.synchronize(self.source, self.stage, FakeUpstream())
self.assertEqual(self.stage.read_bytes(), b'existing candidate')
with self.assertRaises(ValueError):
self.sync.synchronize(self.source, self.source, FakeUpstream())
self.assertEqual(self.source.read_bytes(), original)
def test_existing_id_does_not_end_pagination_and_missing_ids_use_indices(self):
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Alpha'}],
'movie/by/filtres/0/created/1': [{'id': 70, 'type': 'movie', 'title': 'Next page'}],
'movie/by/filtres/0/created/2': [],
'movie/by/70': {'id': 70, 'type': 'movie', 'title': 'Next page', 'sources': []},
'season/by/serie/2': [{'title': 'Season 1 refreshed', 'episodes': [
{'title': 'Episode 1 refreshed', 'sources': []}, {'title': 'Episode without ID', 'sources': []}]}]}
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['added']['movies'], 1)
self.assertEqual(report['added']['episodes'], 1)
self.assertEqual(self.rows(self.stage, 'SELECT id,title FROM seasons'), [(1001, 'Season 1 refreshed')])
self.assertEqual(self.rows(self.stage, 'SELECT id,title FROM episodes ORDER BY episode_index'),
[(501, 'Episode 1 refreshed'), (None, 'Episode without ID')])
self.assertEqual(self.rows(self.stage, 'SELECT source_id FROM episode_sources'), [(201,)])
def test_advertisements_channels_and_detail_ads_are_not_imported(self):
items = [
{'id': 70, 'type': 'movie', 'title': 'Telegram @channelname'},
{'id': 71, 'type': 'movie', 'title': 'Innocent', 'description': 'نسخه جدید ملودی باکس'},
{'id': 72, 'type': 'channel', 'title': 'Live channel'},
{'id': 73, 'type': 'movie', 'title': 'Looks like a movie'}]
upstream = FakeUpstream({'movie/by/filtres/0/created/0': items,
'movie/by/filtres/0/created/1': [],
'movie/by/73': {'id': 73, 'type': 'movie', 'title': 'Telegram @channelname'}})
report = self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(report['added']['titles'], 0)
self.assertNotIn('movie/by/70', upstream.calls)
self.assertNotIn('movie/by/71', upstream.calls)
self.assertNotIn('movie/by/72', upstream.calls)
self.assertEqual(self.rows(self.stage, 'SELECT COUNT(*) FROM titles'), [(65,)])
def test_reordered_stable_ids_and_truncated_nested_lists_retain_every_old_entry(self):
first = {'season/by/serie/2': [
{'id': 1001, 'episodes': [{'id': 502, 'title': 'Episode 2', 'sources': [
{'id': 202, 'url': 'https://video.test/old2'}]}]},
{'id': 1002, 'title': 'Season 2', 'episodes': [{'id': 601, 'title': 'S2 E1'}]}]}
self.sync.synchronize(self.source, self.stage, FakeUpstream(first))
second = Path(self.temp.name) / 'second.sqlite'
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Updated Alpha',
'sources': [], 'genres': []}],
'movie/by/filtres/0/created/1': [],
'season/by/serie/2': [
{'id': 1002, 'title': 'Season Two', 'episodes': []},
{'id': 1001, 'episodes': [{'id': 502, 'title': 'Updated Episode 2', 'sources': [
{'id': 202, 'url': 'https://video.test/refreshed'}]},
{'id': 501, 'sources': []}]}]}
report = self.sync.synchronize(self.stage, second, FakeUpstream(routes))
self.assertEqual(report['added'], dict.fromkeys(report['added'], 0))
self.assertEqual(self.rows(second, 'SELECT season_index,id FROM seasons ORDER BY season_index'),
[(0, 1001), (1, 1002)])
self.assertEqual(self.rows(second, 'SELECT season_index,episode_index,id FROM episodes ORDER BY 1,2'),
[(0, 0, 501), (0, 1, 502), (1, 0, 601)])
self.assertEqual(self.rows(second, 'SELECT url FROM episode_sources WHERE source_id=202'),
[('https://video.test/refreshed',)])
for table in ('seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertGreaterEqual(self.rows(second, 'SELECT COUNT(*) FROM ' + table)[0][0],
self.rows(self.stage, 'SELECT COUNT(*) FROM ' + table)[0][0])
third = Path(self.temp.name) / 'third.sqlite'
self.sync.synchronize(second, third, FakeUpstream())
for table in ('titles', 'seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertEqual(self.rows(second, 'SELECT * FROM ' + table), self.rows(third, 'SELECT * FROM ' + table))
season = json.loads(self.rows(second, 'SELECT raw_json FROM seasons WHERE id=1001')[0][0])
self.assertEqual([e['id'] for e in season['episodes']], [501, 502])
self.assertEqual(season['episodes'][1]['sources'][0]['url'], 'https://video.test/refreshed')
movie = json.loads(self.rows(second, 'SELECT raw_json FROM titles WHERE id=1')[0][0])
self.assertTrue(movie['sources'])
self.assertTrue(movie['genres'])
def test_old_series_absent_from_catalogue_and_new_series_receive_episodes_idempotently(self):
new_series = {'id': 70, 'type': 'serie', 'title': 'New Series'}
old_episode = {'id': 501, 'title': 'Episode 1', 'sources': []}
new_episode = {'id': 502, 'title': 'Episode 2', 'sources': [
{'id': 202, 'quality': '1080', 'type': 'mkv', 'url': 'https://video.test/e2.mkv'}]}
routes = {'serie/by/filtres/0/created/0': [new_series],
'serie/by/filtres/0/created/1': [],
'season/by/serie/2': [{'id': 1001, 'title': 'Season 1', 'episodes': [old_episode, new_episode]}],
'season/by/serie/70': [{'id': 1070, 'title': 'New Season', 'episodes': [
{'id': 701, 'title': 'Pilot', 'sources': [
{'id': 702, 'url': 'https://video.test/pilot.mp4', 'type': 'mp4'}]}]}]}
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['added'], {'titles': 1, 'movies': 0, 'series': 1, 'episodes': 2, 'sources': 2})
self.assertEqual(report['updated_series_ids'], [2, 70])
self.assertEqual(report['updated_series_count'], 2)
self.assertEqual(self.rows(self.stage, 'SELECT id FROM episodes ORDER BY id'), [(501,), (502,), (701,)])
raw = json.loads(self.rows(self.stage, 'SELECT raw_json FROM seasons WHERE series_id=2')[0][0])
self.assertEqual(len(raw['episodes']), 2)
self.assertEqual(raw['episodes'][0]['sources'][0]['id'], 201)
self.assertEqual(raw['episodes'][1], new_episode)
catalog = load_service().Catalog(self.stage)
try:
served = catalog.dispatch('/api/season/by/serie/2/public/')
self.assertEqual(served[0]['episodes'][1], new_episode)
finally:
catalog.close()
second_stage = Path(self.temp.name) / 'second.sqlite'
again = self.sync.synchronize(self.stage, second_stage, FakeUpstream(routes))
self.assertEqual(again['added'], dict.fromkeys(report['added'], 0))
self.assertEqual(again['updated_series_ids'], [])
for table in ('titles', 'seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertEqual(self.rows(self.stage, 'SELECT * FROM ' + table),
self.rows(second_stage, 'SELECT * FROM ' + table))
self.assertEqual(self.metadata(second_stage)['original_snapshot_built_at_utc'],
self.metadata(self.source)['built_at_utc'])
for (url,) in self.rows(second_stage, 'SELECT source_url FROM series_fetch_status'):
self.assertTrue(url.startswith('https://ninwinios.info/api/season/by/serie/'))
self.assertNotIn('key', url)
def test_new_movie_details_staged_source_untouched_and_real_counts(self):
original = self.source.read_bytes()
movie = {'id': 70, 'type': 'movie', 'title': 'New Film', 'year': 2026,
'genres': [{'id': 5, 'title': 'Drama'}], 'country': [{'id': 3, 'title': 'Iran'}],
'image': 'https://images.test/never-fetch.jpg'}
detail = dict(movie, description='New description', sources=[
{'id': 700, 'type': 'mkv', 'url': 'https://video.test/never-fetch.mkv?key=opaque'}])
upstream = FakeUpstream({'genre/all': [{'id': 5, 'title': 'Drama'}],
'country/all': [{'id': 3, 'title': 'Iran'}],
'movie/by/filtres/0/created/0': [movie, movie],
'movie/by/filtres/0/created/1': [], 'movie/by/70': detail})
progress = []
report = self.sync.synchronize(self.source, self.stage, upstream, progress=progress.append)
self.assertEqual(self.source.read_bytes(), original)
self.assertEqual(report['added']['titles'], 1)
self.assertEqual(report['added']['movies'], 1)
self.assertEqual(report['added']['series'], 0)
self.assertEqual(report['added']['sources'], 1)
self.assertEqual(report['counts']['titles'], 66)
self.assertEqual(report['counts']['episodes'], 1)
self.assertEqual(report['requests_count'], upstream.requests_count)
self.assertFalse(report['media_files_downloaded'])
self.assertEqual(upstream.calls.count('movie/by/70'), 1)
self.assertIn('season/by/serie/2', upstream.calls)
self.assertTrue(all('http' not in path and '.mkv' not in path and '.jpg' not in path
for path in upstream.calls))
raw = json.loads(self.rows(self.stage, 'SELECT raw_json FROM titles WHERE id=70')[0][0])
self.assertEqual(raw, detail)
self.assertEqual(self.rows(self.stage, 'SELECT url FROM movie_sources WHERE title_id=70')[0][0],
detail['sources'][0]['url'])
catalog = load_service().Catalog(self.stage)
try:
served = catalog.dispatch('/api/movie/by/70/public/')
self.assertEqual(served, detail)
finally:
catalog.close()
self.assertEqual(self.rows(self.stage, 'PRAGMA integrity_check'), [('ok',)])
self.assertEqual(self.rows(self.stage, 'PRAGMA foreign_key_check'), [])
metadata = self.metadata(self.stage)
self.assertEqual(metadata['original_snapshot_built_at_utc'], self.metadata(self.source)['built_at_utc'])
self.assertNotEqual(metadata['built_at_utc'], self.metadata(self.source)['built_at_utc'])
self.assertEqual(metadata['built_at_utc'], metadata['synchronized_at_utc'])
self.assertEqual(metadata['sync_interval_seconds'], 259200)
self.assertEqual(metadata['counts']['titles'], 66)
self.assertFalse(metadata['media_files_downloaded'])
self.assertEqual(metadata['last_sync']['upstream_base'], 'https://ninwinios.info/api/')
self.assertEqual(json.loads(json.dumps(report)), report)
self.assertTrue(progress)
for item in progress:
self.assertLessEqual(set(item), {'phase', 'count', 'total'})
self.assertIsInstance(item['phase'], str)
self.assertIsInstance(item['count'], int)
if __name__ == '__main__':
unittest.main()
+271
View File
@@ -0,0 +1,271 @@
"""Atomic publication and rollback for the catalogue updater."""
import grp
import hashlib
from contextlib import closing, redirect_stdout
import importlib.util
import io
import json
import os
from pathlib import Path
import sqlite3
import stat
import tempfile
import unittest
import test_catalog_api as fixtures
SERVER = Path(__file__).resolve().parents[1]
def runner_module():
spec = importlib.util.spec_from_file_location('catalog_sync_runner', SERVER / 'catalog_sync_runner.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class SyncRunnerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.database = fixtures.fixture(self.root)
self.state = self.root / 'state'
self.stamp = '2026-11-03T19:00:00+00:00'
self.api = runner_module()
def tearDown(self):
self.temp.cleanup()
def candidate(self, source, stage, upstream, **kwargs):
with closing(sqlite3.connect(source)) as src, closing(sqlite3.connect(stage)) as dest:
src.backup(dest)
dest.execute('UPDATE metadata SET value_json=? WHERE key=?',
(json.dumps(self.stamp), 'built_at_utc'))
dest.commit()
return {'added_movies': 0, 'added_episodes': 0, 'built_at_utc': self.stamp}
def digest(self):
return hashlib.sha256(self.database.read_bytes()).hexdigest()
def test_progress_normalizes_core_count_without_logging_other_fields(self):
for event, completed in (({'count': 0}, 0), ({'count': 3}, 3),
({'completed': 4, 'count': 5}, 4)):
with self.subTest(event=event):
output = io.StringIO()
with redirect_stdout(output):
self.api.emit_progress({**event, 'phase': 'movies', 'total': 7,
'api_key': 'never-print-this'})
self.assertEqual(json.loads(output.getvalue()),
{'progress': {'phase': 'movies', 'completed': completed, 'total': 7}})
def test_publish_is_atomic_and_records_success_after_health(self):
before = self.digest()
events = []
def restart():
events.append('restart')
with closing(sqlite3.connect(self.database)) as db:
stamp = json.loads(db.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0])
self.assertEqual(stamp, self.stamp)
def health(stamp):
self.assertEqual(stamp, self.stamp)
self.assertFalse((self.state / 'last-success.json').exists())
events.append('healthy')
result = self.api.run_update(self.database, self.state, object(),
synchronize_fn=self.candidate, restart_fn=restart, health_fn=health)
self.assertEqual(events, ['restart', 'healthy'])
self.assertNotEqual(self.digest(), before)
backup = self.database.with_name('catalog.previous.sqlite')
self.assertEqual(hashlib.sha256(backup.read_bytes()).hexdigest(), before)
self.assertEqual(result['status'], 'success')
self.assertEqual(json.loads((self.state / 'last-success.json').read_text())['status'], 'success')
@unittest.skipUnless(os.geteuid() == 0, 'requires root to set group ownership')
def test_publish_preserves_group_readable_database_ownership_and_mode(self):
group = grp.getgrnam('www-data').gr_gid
os.chown(self.database, 0, group)
os.chmod(self.database, 0o640)
observed = []
def restart():
published = self.database.stat()
observed.append((published.st_uid, published.st_gid, stat.S_IMODE(published.st_mode)))
self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate,
restart_fn=restart, health_fn=lambda _: None)
self.assertEqual(observed, [(0, group, 0o640)])
published = self.database.stat()
self.assertEqual((published.st_uid, published.st_gid), (0, group))
self.assertEqual(stat.S_IMODE(published.st_mode), 0o640)
def test_fetch_failure_never_changes_live_database_or_restarts(self):
before = self.digest()
def fail(*_args, **_kwargs):
raise RuntimeError('network unavailable')
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(), synchronize_fn=fail,
restart_fn=lambda: self.fail('must not restart'), health_fn=lambda _: None)
self.assertEqual(self.digest(), before)
self.assertFalse((self.state / 'last-success.json').exists())
self.assertEqual(json.loads((self.state / 'last-result.json').read_text())['status'], 'failed')
self.assertEqual(list(self.root.glob('.catalog-sync-*.sqlite*')), [])
def test_failed_health_rolls_back_before_restarting_old_catalogue(self):
before = self.digest()
with closing(sqlite3.connect(self.database)) as db:
old_stamp = json.loads(db.execute(
"SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0])
restarts = []
health_checks = []
def restart():
restarts.append(self.digest())
def fail_health(stamp):
health_checks.append(stamp)
if stamp == self.stamp:
raise RuntimeError('new API not healthy')
self.assertEqual(self.digest(), before)
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate,
restart_fn=restart, health_fn=fail_health)
self.assertEqual(len(restarts), 2)
self.assertNotEqual(restarts[0], before)
self.assertEqual(restarts[1], before)
self.assertEqual(health_checks, [self.stamp, old_stamp])
self.assertEqual(self.digest(), before)
self.assertFalse((self.state / 'last-success.json').exists())
self.assertFalse(json.loads((self.state / 'last-result.json').read_text())['rollback_failed'])
def test_failed_rollback_health_requires_operator_attention(self):
before = self.digest()
with closing(sqlite3.connect(self.database)) as db:
old_stamp = json.loads(db.execute(
"SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0])
health_checks = []
restarts = []
def fail_health(stamp):
health_checks.append(stamp)
raise RuntimeError('API not healthy')
with self.assertRaisesRegex(self.api.UpdateError, 'operator attention'):
self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate,
restart_fn=lambda: restarts.append(self.digest()), health_fn=fail_health)
self.assertEqual(health_checks, [self.stamp, old_stamp])
self.assertEqual(len(restarts), 2)
self.assertEqual(restarts[1], before)
self.assertEqual(self.digest(), before)
self.assertFalse((self.state / 'last-success.json').exists())
self.assertTrue(json.loads((self.state / 'last-result.json').read_text())['rollback_failed'])
def test_concurrent_run_is_rejected_before_fetching(self):
import fcntl
self.state.mkdir()
with (self.state / 'sync.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(),
synchronize_fn=lambda *_a, **_k: self.fail('overlapping fetch'),
restart_fn=lambda: None, health_fn=lambda _: None)
def assert_candidate_rejected(self, mutate):
before = self.digest()
restarts = []
def candidate(source, stage, upstream, **kwargs):
report = self.candidate(source, stage, upstream, **kwargs)
with closing(sqlite3.connect(stage)) as db:
mutate(db)
db.commit()
return report
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate,
restart_fn=lambda: restarts.append(True), health_fn=lambda _: None)
self.assertEqual(restarts, [])
self.assertEqual(self.digest(), before)
self.assertFalse((self.state / 'last-success.json').exists())
def test_required_reference_and_source_tables_cannot_be_removed_or_emptied(self):
for table in ('genres', 'countries', 'title_genres', 'title_countries',
'series_fetch_status', 'movie_sources', 'episode_sources'):
for statement in ('DELETE FROM ', 'DROP TABLE '):
with self.subTest(table=table, statement=statement):
self.assert_candidate_rejected(lambda db: db.execute(statement + table))
def test_new_movie_sources_cannot_mask_existing_title_source_loss(self):
def replace(db):
db.execute("INSERT INTO titles(id,type,raw_json) VALUES (66,'movie','{}')")
db.execute('UPDATE movie_sources SET title_id=66 WHERE title_id=1')
self.assert_candidate_rejected(replace)
def test_other_title_sources_cannot_mask_existing_title_source_loss(self):
def replace(db):
db.execute('UPDATE movie_sources SET title_id=3,source_index=1 WHERE title_id=1')
self.assert_candidate_rejected(replace)
def test_new_episode_sources_cannot_mask_existing_episode_source_loss(self):
def replace(db):
db.execute("INSERT INTO episodes(series_id,season_index,episode_index,id,raw_json) "
"VALUES (2,0,1,502,'{}')")
db.execute('UPDATE episode_sources SET episode_index=1')
self.assert_candidate_rejected(replace)
def test_null_id_episode_sources_are_preserved_by_index(self):
with closing(sqlite3.connect(self.database)) as db:
db.execute('UPDATE episodes SET id=NULL')
db.commit()
def replace(db):
db.execute("INSERT INTO episodes(series_id,season_index,episode_index,raw_json) "
"VALUES (2,0,1,'{}')")
db.execute('UPDATE episode_sources SET episode_index=1')
self.assert_candidate_rejected(replace)
def test_source_url_replacement_with_stable_ids_can_be_published(self):
replacement = 'https://media.test/replacement.mkv?token=new'
def candidate(source, stage, upstream, **kwargs):
report = self.candidate(source, stage, upstream, **kwargs)
with closing(sqlite3.connect(stage)) as db:
for table in ('movie_sources', 'episode_sources'):
raw = json.loads(db.execute(
'SELECT raw_json FROM ' + table + ' WHERE source_id=?',
(101 if table == 'movie_sources' else 201,)).fetchone()[0])
raw['url'] = replacement
db.execute('UPDATE ' + table + ' SET url=?,raw_json=? WHERE source_id=?',
(replacement, json.dumps(raw), raw['id']))
for table, title_id in (('titles', 1), ('episodes', 501)):
raw = json.loads(db.execute('SELECT raw_json FROM ' + table + ' WHERE id=?',
(title_id,)).fetchone()[0])
raw['sources'][0]['url'] = replacement
db.execute('UPDATE ' + table + ' SET raw_json=? WHERE id=?',
(json.dumps(raw), title_id))
season = json.loads(db.execute('SELECT raw_json FROM seasons').fetchone()[0])
season['episodes'][0]['sources'][0]['url'] = replacement
db.execute('UPDATE seasons SET raw_json=?', (json.dumps(season),))
db.commit()
return report
result = self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate,
restart_fn=lambda: None, health_fn=lambda _: None)
self.assertEqual(result['status'], 'success')
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual(db.execute('SELECT source_id,url FROM movie_sources WHERE title_id=1').fetchone(),
(101, replacement))
self.assertEqual(db.execute('SELECT source_id,url FROM episode_sources').fetchone(),
(201, replacement))
def test_episode_sources_follow_stable_id_when_episode_index_changes(self):
def candidate(source, stage, upstream, **kwargs):
report = self.candidate(source, stage, upstream, **kwargs)
with closing(sqlite3.connect(stage)) as db:
db.execute('UPDATE episodes SET episode_index=1 WHERE id=501')
db.execute('UPDATE episode_sources SET episode_index=1')
db.commit()
return report
result = self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate,
restart_fn=lambda: None, health_fn=lambda _: None)
self.assertEqual(result['status'], 'success')
def test_incomplete_candidate_cannot_replace_the_catalogue(self):
before = self.digest()
def truncate(source, stage, upstream, **kwargs):
result = self.candidate(source, stage, upstream, **kwargs)
with closing(sqlite3.connect(stage)) as db:
db.execute('DELETE FROM titles WHERE id=65')
db.commit()
return result
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(), synchronize_fn=truncate,
restart_fn=lambda: self.fail('invalid candidate'), health_fn=lambda _: None)
self.assertEqual(self.digest(), before)
+38
View File
@@ -0,0 +1,38 @@
"""Operational boundaries of the installed updater schedule."""
import configparser
from pathlib import Path
import unittest
SERVER = Path(__file__).resolve().parents[1]
class SyncUnitTests(unittest.TestCase):
def unit(self, name):
config = configparser.ConfigParser(interpolation=None, strict=False)
config.optionxform = str
config.read(SERVER / name)
return config
def test_timer_uses_three_day_interval_and_boot_catch_up(self):
timer = self.unit('reelbox-sync.timer')
self.assertEqual(timer['Timer']['OnUnitActiveSec'], '3d')
self.assertEqual(timer['Timer']['OnBootSec'], '15min')
self.assertEqual(timer['Timer']['Unit'], 'reelbox-sync.service')
self.assertNotIn('OnCalendar', timer['Timer'])
self.assertEqual(timer['Install']['WantedBy'], 'timers.target')
def test_updater_is_bounded_and_separate_from_public_api(self):
service = self.unit('reelbox-sync.service')['Service']
self.assertEqual(service['Type'], 'oneshot')
self.assertEqual(service['TimeoutStartSec'], '8h')
self.assertEqual(service['RestartSec'], '1h')
self.assertEqual(service['ProtectSystem'], 'strict')
self.assertEqual(service['ProtectHome'], 'true')
self.assertEqual(service['EnvironmentFile'], '/etc/reelbox/sync.env')
self.assertEqual(service['UMask'], '0077')
self.assertIn('catalog_sync_runner.py', service['ExecStart'])
self.assertEqual(service['ReadWritePaths'], '/var/lib/reelbox-api /var/lib/reelbox-sync')
api = self.unit('reelbox-api.service')['Service']
self.assertEqual(api['IPAddressDeny'], 'any')
self.assertEqual(api['IPAddressAllow'], 'localhost')
self.assertNotIn('sync.env', api.get('EnvironmentFile', ''))