Implement retries & Regex search
This commit is contained in:
29
README.md
29
README.md
@@ -5,8 +5,9 @@ A fast, interactive Python tool for searching channels across Xtream Codes IPTV
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🔍 **Interactive Search Mode** - Connect once, search multiple times
|
- 🔍 **Interactive Search Mode** - Connect once, search multiple times
|
||||||
|
- 🎯 **Regex Support** - Use `/pattern/` syntax for powerful pattern matching
|
||||||
- ⚡ **Multithreaded** - Fast concurrent searches across categories
|
- ⚡ **Multithreaded** - Fast concurrent searches across categories
|
||||||
- 📊 **Progress Bars** - Real-time progress with ETA
|
- 📊 **Progress Bars** - Real-time progress with smoothed ETA
|
||||||
- 🎯 **Content Type Filtering** - Search Live, VOD, Series separately or all together
|
- 🎯 **Content Type Filtering** - Search Live, VOD, Series separately or all together
|
||||||
- 🧹 **Clean Output** - Compact, readable results
|
- 🧹 **Clean Output** - Compact, readable results
|
||||||
- 🔧 **Debug Mode** - API connectivity testing and troubleshooting
|
- 🔧 **Debug Mode** - API connectivity testing and troubleshooting
|
||||||
@@ -47,14 +48,34 @@ python xtream-search.py --api-url "http://your-server.com" --username "user" --p
|
|||||||
|
|
||||||
Once in interactive mode, use these search commands:
|
Once in interactive mode, use these search commands:
|
||||||
|
|
||||||
- **`channel_name`** - Search live streams (default)
|
- **`channel_name`** - Search live streams (default, substring match)
|
||||||
|
- **`/pattern/`** - Search using regex pattern (case-insensitive by default)
|
||||||
|
- **`/pattern/flags`** - Search with regex flags (i=case-insensitive, m=multiline, s=dotall)
|
||||||
- **`live:espn`** - Search only live streams
|
- **`live:espn`** - Search only live streams
|
||||||
- **`vod:movie`** - Search only VOD content
|
- **`vod:movie`** - Search only VOD content
|
||||||
- **`series:friends`** - Search only series
|
- **`series:friends`** - Search only series
|
||||||
- **`all:news`** - Search all content types
|
- **`all:news`** - Search all content types
|
||||||
|
- **`all:/sports|news/`** - Search all content types with regex
|
||||||
- **`debug`** - Show API information
|
- **`debug`** - Show API information
|
||||||
- **`quit`** or **`exit`** - Exit program
|
- **`quit`** or **`exit`** - Exit program
|
||||||
|
|
||||||
|
### Regex Search Examples
|
||||||
|
|
||||||
|
- **`/^CNN/`** - Find channels starting with "CNN" (case-insensitive)
|
||||||
|
- **`/^CNN/i`** - Find channels starting with "CNN" (explicitly case-insensitive)
|
||||||
|
- **`/^cnn/`** - Find channels starting with "cnn" (case-insensitive by default)
|
||||||
|
- **`/HD$/`** - Find channels ending with "HD"
|
||||||
|
- **`/sports|news/i`** - Find channels containing "sports" OR "news" (case-insensitive)
|
||||||
|
- **`/\d{4}/`** - Find channels with 4 consecutive digits
|
||||||
|
- **`/^[A-Z]{3}$/`** - Find channels with exactly 3 uppercase letters
|
||||||
|
|
||||||
|
### Supported Regex Flags
|
||||||
|
|
||||||
|
- **`i`** - Case-insensitive matching (applied by default if no flags specified)
|
||||||
|
- **`m`** - Multiline mode (^ and $ match line boundaries)
|
||||||
|
- **`s`** - Dotall mode (. matches newlines)
|
||||||
|
- Multiple flags can be combined: **`/pattern/ims`**
|
||||||
|
|
||||||
## Sample Output
|
## Sample Output
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -66,7 +87,7 @@ Commands: live:<term> | vod:<term> | series:<term> | all:<term> | debug | quit
|
|||||||
|
|
||||||
📡 Fetching live categories...
|
📡 Fetching live categories...
|
||||||
✅ Found 25 categories to search
|
✅ Found 25 categories to search
|
||||||
🔍 Searching live categories: 100%|████████████| 25/25 [00:03<00:00]
|
🔍 Searching live categories: 100%|████████████| 25/25 [Elapsed: 00:03 | ETA: 00:00]
|
||||||
|
|
||||||
✅ Found 3 result(s) for 'cnn':
|
✅ Found 3 result(s) for 'cnn':
|
||||||
1. [Live] News Channels → CNN International
|
1. [Live] News Channels → CNN International
|
||||||
@@ -77,7 +98,7 @@ Commands: live:<term> | vod:<term> | series:<term> | all:<term> | debug | quit
|
|||||||
|
|
||||||
📡 Fetching VOD categories...
|
📡 Fetching VOD categories...
|
||||||
✅ Found 15 VOD categories
|
✅ Found 15 VOD categories
|
||||||
🔍 Searching vod: 100%|████████████| 15/15 [00:02<00:00]
|
🔍 Searching vod: 100%|████████████| 15/15 [Elapsed: 00:02 | ETA: 00:00]
|
||||||
|
|
||||||
✅ Found 5 result(s) for 'action':
|
✅ Found 5 result(s) for 'action':
|
||||||
1. [VOD] Action Movies → Die Hard
|
1. [VOD] Action Movies → Die Hard
|
||||||
|
|||||||
135
xtream-search.py
135
xtream-search.py
@@ -5,6 +5,10 @@ from urllib.parse import urljoin
|
|||||||
import argparse
|
import argparse
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
try:
|
try:
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -13,6 +17,62 @@ except ImportError:
|
|||||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "tqdm"])
|
subprocess.check_call([sys.executable, "-m", "pip", "install", "tqdm"])
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
def is_regex_pattern(pattern):
|
||||||
|
"""Check if pattern is wrapped in forward slashes for regex matching"""
|
||||||
|
return pattern.startswith('/') and len(pattern) > 2 and '/' in pattern[1:]
|
||||||
|
|
||||||
|
def matches_search(stream_name, search_term):
|
||||||
|
"""Check if stream name matches search term (supports regex with /pattern/flags)"""
|
||||||
|
if is_regex_pattern(search_term):
|
||||||
|
# Extract pattern and flags
|
||||||
|
first_slash = 0
|
||||||
|
last_slash = search_term.rfind('/')
|
||||||
|
|
||||||
|
if last_slash <= first_slash:
|
||||||
|
# Invalid pattern, fall back to substring match
|
||||||
|
return search_term.lower() in stream_name.lower()
|
||||||
|
|
||||||
|
pattern = search_term[1:last_slash]
|
||||||
|
flags_str = search_term[last_slash + 1:] if last_slash < len(search_term) - 1 else ''
|
||||||
|
|
||||||
|
# Parse flags
|
||||||
|
flags = 0
|
||||||
|
for flag in flags_str.lower():
|
||||||
|
if flag == 'i':
|
||||||
|
flags |= re.IGNORECASE
|
||||||
|
elif flag == 'm':
|
||||||
|
flags |= re.MULTILINE
|
||||||
|
elif flag == 's':
|
||||||
|
flags |= re.DOTALL
|
||||||
|
# Ignore unknown flags
|
||||||
|
|
||||||
|
# Default to case-insensitive if no flags specified
|
||||||
|
if not flags_str:
|
||||||
|
flags = re.IGNORECASE
|
||||||
|
|
||||||
|
try:
|
||||||
|
return re.search(pattern, stream_name, flags) is not None
|
||||||
|
except re.error:
|
||||||
|
# If regex is invalid, fall back to substring match
|
||||||
|
return search_term.lower() in stream_name.lower()
|
||||||
|
else:
|
||||||
|
# Regular substring match
|
||||||
|
return search_term.lower() in stream_name.lower()
|
||||||
|
|
||||||
|
def create_session_with_retries():
|
||||||
|
"""Create a requests session with retry logic for 503 errors"""
|
||||||
|
session = requests.Session()
|
||||||
|
retry_strategy = Retry(
|
||||||
|
total=2, # Reduced from 3 to 2 retries
|
||||||
|
status_forcelist=[503], # Only retry 503 errors
|
||||||
|
backoff_factor=0.5, # Reduced from 1 to 0.5 seconds
|
||||||
|
allowed_methods=["GET"]
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
def search_category(api_url, username, password, channel_name, category, pbar, pbar_lock):
|
def search_category(api_url, username, password, channel_name, category, pbar, pbar_lock):
|
||||||
"""
|
"""
|
||||||
Search a single category for channels (for multithreading)
|
Search a single category for channels (for multithreading)
|
||||||
@@ -20,8 +80,8 @@ def search_category(api_url, username, password, channel_name, category, pbar, p
|
|||||||
category_id = category.get('category_id')
|
category_id = category.get('category_id')
|
||||||
category_name = category.get('category_name', 'Unknown')
|
category_name = category.get('category_name', 'Unknown')
|
||||||
|
|
||||||
# Create a new session for this thread
|
# Create a new session for this thread with retry logic
|
||||||
session = requests.Session()
|
session = create_session_with_retries()
|
||||||
categories_url = urljoin(api_url.rstrip('/'), '/player_api.php')
|
categories_url = urljoin(api_url.rstrip('/'), '/player_api.php')
|
||||||
|
|
||||||
matches_in_category = []
|
matches_in_category = []
|
||||||
@@ -39,10 +99,10 @@ def search_category(api_url, username, password, channel_name, category, pbar, p
|
|||||||
streams_response.raise_for_status()
|
streams_response.raise_for_status()
|
||||||
streams = streams_response.json()
|
streams = streams_response.json()
|
||||||
|
|
||||||
# Check if channel exists in this category (fuzzy matching)
|
# Check if channel exists in this category (supports regex matching)
|
||||||
for stream in streams:
|
for stream in streams:
|
||||||
stream_name = stream.get('name', '').lower()
|
stream_name = stream.get('name', '')
|
||||||
if channel_name.lower() in stream_name:
|
if matches_search(stream_name, channel_name):
|
||||||
matches_in_category.append({
|
matches_in_category.append({
|
||||||
'group_name': category_name,
|
'group_name': category_name,
|
||||||
'group_id': category_id,
|
'group_id': category_id,
|
||||||
@@ -81,7 +141,7 @@ def find_channel_in_groups(api_url, username, password, channel_name, max_worker
|
|||||||
Returns:
|
Returns:
|
||||||
list: List of categories containing the channel
|
list: List of categories containing the channel
|
||||||
"""
|
"""
|
||||||
session = requests.Session()
|
session = create_session_with_retries()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get all live categories
|
# Get all live categories
|
||||||
@@ -101,14 +161,34 @@ def find_channel_in_groups(api_url, username, password, channel_name, max_worker
|
|||||||
|
|
||||||
matching_groups = []
|
matching_groups = []
|
||||||
pbar_lock = threading.Lock()
|
pbar_lock = threading.Lock()
|
||||||
|
thread_start_counter = 0
|
||||||
|
counter_lock = threading.Lock()
|
||||||
|
|
||||||
|
def search_with_delay(api_url, username, password, channel_name, category, pbar, pbar_lock):
|
||||||
|
"""Wrapper to add small staggered delay for first few requests only"""
|
||||||
|
nonlocal thread_start_counter
|
||||||
|
with counter_lock:
|
||||||
|
# Only delay the first 20 threads to prevent initial thundering herd
|
||||||
|
if thread_start_counter < 20:
|
||||||
|
delay = thread_start_counter * 0.01 # 10ms delay for first 20 threads
|
||||||
|
thread_start_counter += 1
|
||||||
|
else:
|
||||||
|
delay = 0
|
||||||
|
thread_start_counter += 1
|
||||||
|
|
||||||
|
if delay > 0:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
return search_category(api_url, username, password, channel_name, category, pbar, pbar_lock)
|
||||||
|
|
||||||
# Search through categories with multithreading
|
# Search through categories with multithreading
|
||||||
with tqdm(total=len(categories), desc="🔍 Searching live categories", unit="cat",
|
with tqdm(total=len(categories), desc="🔍 Searching live categories", unit="cat",
|
||||||
bar_format="{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]") as pbar:
|
bar_format="{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [Elapsed: {elapsed} | ETA: {remaining}]",
|
||||||
|
smoothing=0.1) as pbar:
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
# Submit all tasks
|
# Submit all tasks
|
||||||
future_to_category = {
|
future_to_category = {
|
||||||
executor.submit(search_category, api_url, username, password,
|
executor.submit(search_with_delay, api_url, username, password,
|
||||||
channel_name, category, pbar, pbar_lock): category
|
channel_name, category, pbar, pbar_lock): category
|
||||||
for category in categories
|
for category in categories
|
||||||
}
|
}
|
||||||
@@ -132,7 +212,7 @@ def find_channel_in_groups(api_url, username, password, channel_name, max_worker
|
|||||||
|
|
||||||
def get_api_info(api_url, username, password):
|
def get_api_info(api_url, username, password):
|
||||||
"""Get basic API information for debugging"""
|
"""Get basic API information for debugging"""
|
||||||
session = requests.Session()
|
session = create_session_with_retries()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Test basic connectivity and get categories
|
# Test basic connectivity and get categories
|
||||||
@@ -185,7 +265,7 @@ def search_content_type_category(api_url, username, password, search_term, categ
|
|||||||
category_id = category.get('category_id')
|
category_id = category.get('category_id')
|
||||||
category_name = category.get('category_name', 'Unknown')
|
category_name = category.get('category_name', 'Unknown')
|
||||||
|
|
||||||
session = requests.Session()
|
session = create_session_with_retries()
|
||||||
api_endpoint = urljoin(api_url.rstrip('/'), '/player_api.php')
|
api_endpoint = urljoin(api_url.rstrip('/'), '/player_api.php')
|
||||||
|
|
||||||
matches_in_category = []
|
matches_in_category = []
|
||||||
@@ -208,8 +288,8 @@ def search_content_type_category(api_url, username, password, search_term, categ
|
|||||||
streams = stream_response.json()
|
streams = stream_response.json()
|
||||||
|
|
||||||
for stream in streams:
|
for stream in streams:
|
||||||
stream_name = stream.get('name', '').lower()
|
stream_name = stream.get('name', '')
|
||||||
if search_term.lower() in stream_name:
|
if matches_search(stream_name, search_term):
|
||||||
matches_in_category.append({
|
matches_in_category.append({
|
||||||
'content_type': content_type,
|
'content_type': content_type,
|
||||||
'group_name': category_name,
|
'group_name': category_name,
|
||||||
@@ -249,7 +329,7 @@ def search_all_content_types(api_url, username, password, search_term, max_worke
|
|||||||
|
|
||||||
for content_type, cat_action, stream_action in content_types:
|
for content_type, cat_action, stream_action in content_types:
|
||||||
try:
|
try:
|
||||||
print(f"\n<EFBFBD> Fetching {content_type} categories...")
|
print(f"\n📺 Fetching {content_type} categories...")
|
||||||
|
|
||||||
# Get categories
|
# Get categories
|
||||||
cat_params = {
|
cat_params = {
|
||||||
@@ -270,14 +350,34 @@ def search_all_content_types(api_url, username, password, search_term, max_worke
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
pbar_lock = threading.Lock()
|
pbar_lock = threading.Lock()
|
||||||
|
thread_start_counter = 0
|
||||||
|
counter_lock = threading.Lock()
|
||||||
|
|
||||||
|
def search_with_delay(api_url, username, password, search_term, category, content_type, stream_action, pbar, pbar_lock):
|
||||||
|
"""Wrapper to add small staggered delay for first few requests only"""
|
||||||
|
nonlocal thread_start_counter
|
||||||
|
with counter_lock:
|
||||||
|
# Only delay the first 20 threads to prevent initial thundering herd
|
||||||
|
if thread_start_counter < 20:
|
||||||
|
delay = thread_start_counter * 0.01 # 10ms delay for first 20 threads
|
||||||
|
thread_start_counter += 1
|
||||||
|
else:
|
||||||
|
delay = 0
|
||||||
|
thread_start_counter += 1
|
||||||
|
|
||||||
|
if delay > 0:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
return search_content_type_category(api_url, username, password, search_term, category, content_type, stream_action, pbar, pbar_lock)
|
||||||
|
|
||||||
# Use multithreading for this content type
|
# Use multithreading for this content type
|
||||||
with tqdm(total=len(categories), desc=f"🔍 Searching {content_type.lower()}", unit="cat",
|
with tqdm(total=len(categories), desc=f"🔍 Searching {content_type.lower()}", unit="cat",
|
||||||
bar_format="{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]") as pbar:
|
bar_format="{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [Elapsed: {elapsed} | ETA: {remaining}]",
|
||||||
|
smoothing=0.1) as pbar:
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
# Submit all tasks for this content type
|
# Submit all tasks for this content type
|
||||||
future_to_category = {
|
future_to_category = {
|
||||||
executor.submit(search_content_type_category, api_url, username, password,
|
executor.submit(search_with_delay, api_url, username, password,
|
||||||
search_term, category, content_type, stream_action, pbar, pbar_lock): category
|
search_term, category, content_type, stream_action, pbar, pbar_lock): category
|
||||||
for category in categories
|
for category in categories
|
||||||
}
|
}
|
||||||
@@ -302,7 +402,8 @@ def search_all_content_types(api_url, username, password, search_term, max_worke
|
|||||||
def interactive_search(api_url, username, password, max_workers=10):
|
def interactive_search(api_url, username, password, max_workers=10):
|
||||||
"""Interactive search mode"""
|
"""Interactive search mode"""
|
||||||
print(f"Connected to: {api_url}")
|
print(f"Connected to: {api_url}")
|
||||||
print("Commands: live:<term> | vod:<term> | series:<term> | all:<term> | debug | quit")
|
print("Commands: live:<term> | vod:<term> | series:<term> | all:<term> | /regex/ | debug | quit")
|
||||||
|
print("Tip: Use /pattern/flags for regex (e.g., /^CNN/i or /HD$/) - flags: i=case-insensitive, m=multiline, s=dotall")
|
||||||
print("-" * 50)
|
print("-" * 50)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -390,7 +491,7 @@ def main():
|
|||||||
# Test connection first
|
# Test connection first
|
||||||
print("🔧 Testing connection to Xtream Codes API...")
|
print("🔧 Testing connection to Xtream Codes API...")
|
||||||
try:
|
try:
|
||||||
session = requests.Session()
|
session = create_session_with_retries()
|
||||||
test_url = urljoin(args.api_url.rstrip('/'), '/player_api.php')
|
test_url = urljoin(args.api_url.rstrip('/'), '/player_api.php')
|
||||||
test_params = {
|
test_params = {
|
||||||
'username': args.username,
|
'username': args.username,
|
||||||
|
|||||||
Reference in New Issue
Block a user