mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-15 21:52:31 +02:00
588 lines
20 KiB
Python
588 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
API Documentation route handlers
|
|
"""
|
|
|
|
import html as html_lib
|
|
import re
|
|
from bottle import response
|
|
from streaming_providers.base.utils import logger
|
|
|
|
|
|
# HTML template with placeholders for dynamic content
|
|
PAGE_TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Ultimate Backend API Documentation</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background: #f5f7fa;
|
|
color: #2d3748;
|
|
}
|
|
.nav-bar {
|
|
background: #2d3748;
|
|
padding: 12px 20px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.nav-bar a {
|
|
color: #cbd5e0;
|
|
text-decoration: none;
|
|
padding: 6px 16px;
|
|
border-radius: 4px;
|
|
transition: all 0.2s;
|
|
font-size: 14px;
|
|
}
|
|
.nav-bar a:hover {
|
|
background: #4a5568;
|
|
color: white;
|
|
}
|
|
.nav-bar a.active {
|
|
background: #4299e1;
|
|
color: white;
|
|
}
|
|
.nav-bar .brand {
|
|
color: white;
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
margin-right: auto;
|
|
}
|
|
h1 {
|
|
color: #2d3748;
|
|
border-bottom: 3px solid #4299e1;
|
|
padding-bottom: 10px;
|
|
}
|
|
.category {
|
|
background: white;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}
|
|
.category-header {
|
|
background: #4299e1;
|
|
color: white;
|
|
padding: 12px 20px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-weight: 600;
|
|
user-select: none;
|
|
}
|
|
.category-header:hover {
|
|
background: #3182ce;
|
|
}
|
|
.category-header .count {
|
|
background: rgba(255,255,255,0.2);
|
|
padding: 2px 12px;
|
|
border-radius: 20px;
|
|
font-size: 14px;
|
|
}
|
|
.endpoint-list {
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.endpoint {
|
|
display: flex;
|
|
padding: 10px 20px;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
align-items: flex-start;
|
|
gap: 15px;
|
|
}
|
|
.endpoint:hover {
|
|
background: #f7fafc;
|
|
}
|
|
.endpoint:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.method {
|
|
font-weight: 700;
|
|
padding: 2px 10px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
min-width: 60px;
|
|
text-align: center;
|
|
margin-top: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
.method.GET { background: #48bb78; color: white; }
|
|
.method.POST { background: #4299e1; color: white; }
|
|
.method.PUT { background: #ed8936; color: white; }
|
|
.method.DELETE { background: #fc8181; color: white; }
|
|
.method.PATCH { background: #9f7aea; color: white; }
|
|
.method.HEAD { background: #a0aec0; color: white; }
|
|
.method.OPTIONS { background: #718096; color: white; }
|
|
|
|
.path {
|
|
font-family: "SF Mono", Monaco, Consolas, monospace;
|
|
font-size: 14px;
|
|
color: #2d3748;
|
|
word-break: break-all;
|
|
flex: 1;
|
|
}
|
|
.description {
|
|
font-size: 13px;
|
|
color: #718096;
|
|
flex: 1.5;
|
|
}
|
|
.search-box {
|
|
width: 100%;
|
|
padding: 12px;
|
|
font-size: 16px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
box-sizing: border-box;
|
|
}
|
|
.search-box:focus {
|
|
outline: none;
|
|
border-color: #4299e1;
|
|
}
|
|
.stats {
|
|
display: flex;
|
|
gap: 20px;
|
|
margin: 10px 0 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.stat {
|
|
background: white;
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
}
|
|
.stat strong {
|
|
color: #4299e1;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 20px;
|
|
}
|
|
.expand-all {
|
|
background: #48bb78;
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
.expand-all:hover {
|
|
background: #38a169;
|
|
}
|
|
.collapse-all {
|
|
background: #fc8181;
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
.collapse-all:hover {
|
|
background: #f56565;
|
|
}
|
|
.endpoint-path {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
flex: 1;
|
|
}
|
|
.parameter-hint {
|
|
color: #a0aec0;
|
|
font-size: 12px;
|
|
font-style: italic;
|
|
}
|
|
.no-results {
|
|
display: none;
|
|
padding: 20px;
|
|
text-align: center;
|
|
color: #718096;
|
|
font-size: 16px;
|
|
}
|
|
.no-results.visible {
|
|
display: block;
|
|
}
|
|
.json-link {
|
|
background: #edf2f7;
|
|
padding: 6px 14px;
|
|
border-radius: 4px;
|
|
font-size: 13px;
|
|
color: #4a5568;
|
|
text-decoration: none;
|
|
transition: all 0.2s;
|
|
}
|
|
.json-link:hover {
|
|
background: #e2e8f0;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.endpoint {
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
.method {
|
|
min-width: 50px;
|
|
font-size: 11px;
|
|
}
|
|
.description {
|
|
flex: 1 1 100%;
|
|
margin-left: 0;
|
|
}
|
|
body {
|
|
padding: 10px;
|
|
}
|
|
.nav-bar {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 8px;
|
|
}
|
|
.nav-bar .brand {
|
|
margin-right: 0;
|
|
text-align: center;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="nav-bar">
|
|
<span class="brand">🚀 Ultimate Backend</span>
|
|
<a href="/config">⚙️ Configuration</a>
|
|
<a href="/api/docs" class="active">📚 API Docs</a>
|
|
<a href="/api/docs/json" class="json-link">🔗 JSON</a>
|
|
</div>
|
|
|
|
<h1>📚 API Documentation</h1>
|
|
<div class="stats">
|
|
<div class="stat">Total Endpoints: <strong>{{TOTAL}}</strong></div>
|
|
<div class="stat">Categories: <strong>{{CATEGORIES_COUNT}}</strong></div>
|
|
</div>
|
|
|
|
<input type="text" class="search-box" id="search" placeholder="🔍 Search endpoints... (e.g., 'm3u', 'drm', 'channels')">
|
|
|
|
<div class="actions">
|
|
<button class="expand-all" onclick="expandAll()">▼ Expand All</button>
|
|
<button class="collapse-all" onclick="collapseAll()">► Collapse All</button>
|
|
</div>
|
|
|
|
<div id="categories">
|
|
{{ENDPOINTS}}
|
|
</div>
|
|
|
|
<div class="no-results" id="no-results">
|
|
🔍 No endpoints match your search. Try a different term.
|
|
</div>
|
|
|
|
<script>
|
|
// ------------------------------------------------------------------
|
|
// Event delegation for category toggles (no inline onclick or IDs)
|
|
// ------------------------------------------------------------------
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Toggle categories when header is clicked
|
|
document.querySelectorAll('.category-header').forEach(function(header) {
|
|
header.addEventListener('click', function() {
|
|
var list = this.nextElementSibling;
|
|
if (list && list.classList.contains('endpoint-list')) {
|
|
list.style.display = list.style.display === 'none' ? 'block' : 'none';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Collapse all by default
|
|
collapseAll();
|
|
|
|
// Search functionality
|
|
var searchInput = document.getElementById('search');
|
|
var noResults = document.getElementById('no-results');
|
|
|
|
searchInput.addEventListener('input', function(e) {
|
|
var query = e.target.value.toLowerCase().trim();
|
|
var endpoints = document.querySelectorAll('.endpoint');
|
|
var anyVisible = false;
|
|
|
|
endpoints.forEach(function(endpoint) {
|
|
var path = (endpoint.dataset.path || '').toLowerCase();
|
|
var method = (endpoint.dataset.method || '').toLowerCase();
|
|
var desc = endpoint.querySelector('.description');
|
|
var descText = desc ? desc.textContent.toLowerCase() : '';
|
|
|
|
var match = !query || path.includes(query) || method.includes(query) || descText.includes(query);
|
|
endpoint.style.display = match ? 'flex' : 'none';
|
|
if (match) anyVisible = true;
|
|
});
|
|
|
|
// Show/hide empty categories
|
|
document.querySelectorAll('.category').forEach(function(category) {
|
|
var visible = Array.from(category.querySelectorAll('.endpoint'))
|
|
.some(function(el) { return el.style.display !== 'none'; });
|
|
category.style.display = visible ? '' : 'none';
|
|
});
|
|
|
|
// Show/hide no results message
|
|
noResults.classList.toggle('visible', !anyVisible && query.length > 0);
|
|
});
|
|
});
|
|
|
|
// Expand all categories
|
|
function expandAll() {
|
|
document.querySelectorAll('.endpoint-list').forEach(function(el) {
|
|
el.style.display = 'block';
|
|
});
|
|
document.getElementById('no-results').classList.remove('visible');
|
|
}
|
|
|
|
// Collapse all categories
|
|
function collapseAll() {
|
|
document.querySelectorAll('.endpoint-list').forEach(function(el) {
|
|
el.style.display = 'none';
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def setup_docs_routes(app, manager=None, service=None):
|
|
"""
|
|
Setup API documentation routes.
|
|
|
|
Args:
|
|
app: Bottle application instance
|
|
manager: Provider manager (unused but kept for consistent signature)
|
|
service: Ultimate service instance (unused but kept for consistent signature)
|
|
"""
|
|
# These are intentionally unused but kept for consistent API with other route modules
|
|
# manager and service may be used in future versions for live API testing
|
|
_ = manager, service # Suppress lint warnings
|
|
|
|
@app.route("/api/docs")
|
|
def api_docs_html():
|
|
"""
|
|
Display all available API routes with descriptions (HTML version)
|
|
|
|
Example: http://localhost:7777/api/docs
|
|
"""
|
|
try:
|
|
# Collect all routes from the Bottle app
|
|
routes = []
|
|
|
|
for route in app.routes:
|
|
# Skip internal routes
|
|
if route.rule.startswith('/_') or route.rule.startswith('/static'):
|
|
continue
|
|
|
|
# Skip OPTIONS and HEAD (usually auto-generated)
|
|
if route.method in ('OPTIONS', 'HEAD'):
|
|
continue
|
|
|
|
# Get route info
|
|
method = route.method
|
|
rule = route.rule
|
|
|
|
# Try to get the docstring from the route's callback
|
|
docstring = None
|
|
if hasattr(route, 'callback'):
|
|
callback = route.callback
|
|
if hasattr(callback, '__doc__'):
|
|
docstring = callback.__doc__
|
|
elif hasattr(callback, '__wrapped__') and hasattr(callback.__wrapped__, '__doc__'):
|
|
docstring = callback.__wrapped__.__doc__
|
|
|
|
# Clean up docstring
|
|
if docstring:
|
|
# Remove leading/trailing whitespace and get first line
|
|
docstring = docstring.strip().split('\n')[0].strip()
|
|
else:
|
|
docstring = 'No description available'
|
|
|
|
routes.append({
|
|
'method': method,
|
|
'path': rule,
|
|
'description': docstring
|
|
})
|
|
|
|
# Sort routes by path then method
|
|
routes.sort(key=lambda r: (r['path'], r['method']))
|
|
|
|
# Group routes by category
|
|
categories = {
|
|
'Providers': [],
|
|
'Channels': [],
|
|
'Streams': [],
|
|
'M3U Playlists': [],
|
|
'DRM & PSSH': [],
|
|
'EPG': [],
|
|
'VOD': [],
|
|
'Events': [],
|
|
'Recordings': [],
|
|
'Timers': [],
|
|
'Bookmarks': [],
|
|
'Favorites': [],
|
|
'Cache': [],
|
|
'Configuration': [],
|
|
'Other': []
|
|
}
|
|
|
|
# Categorize routes
|
|
for route in routes:
|
|
path = route['path']
|
|
|
|
# Check for specific route patterns (order matters - most specific first)
|
|
if '/m3u' in path:
|
|
categories['M3U Playlists'].append(route)
|
|
elif '/drm' in path or '/pssh' in path:
|
|
categories['DRM & PSSH'].append(route)
|
|
elif '/epg' in path:
|
|
categories['EPG'].append(route)
|
|
elif '/cache' in path:
|
|
categories['Cache'].append(route)
|
|
elif '/config' in path:
|
|
categories['Configuration'].append(route)
|
|
elif '/bookmarks' in path:
|
|
categories['Bookmarks'].append(route)
|
|
elif '/favorites' in path:
|
|
categories['Favorites'].append(route)
|
|
elif '/providers' in path:
|
|
# Provider-relative routes
|
|
if '/channels' in path:
|
|
if '/stream' in path:
|
|
categories['Streams'].append(route)
|
|
elif '/epg' in path:
|
|
categories['EPG'].append(route)
|
|
else:
|
|
categories['Channels'].append(route)
|
|
elif '/vod' in path:
|
|
categories['VOD'].append(route)
|
|
elif '/events' in path:
|
|
categories['Events'].append(route)
|
|
elif '/recordings' in path:
|
|
categories['Recordings'].append(route)
|
|
elif '/timers' in path:
|
|
categories['Timers'].append(route)
|
|
elif '/m3u' in path:
|
|
categories['M3U Playlists'].append(route)
|
|
else:
|
|
categories['Providers'].append(route)
|
|
else:
|
|
categories['Other'].append(route)
|
|
|
|
# Remove empty categories
|
|
categories = {k: v for k, v in categories.items() if v}
|
|
|
|
# Build endpoints HTML with proper escaping
|
|
endpoints_html = ""
|
|
total_routes = 0
|
|
|
|
for category, routes_list in categories.items():
|
|
total_routes += len(routes_list)
|
|
category_escaped = html_lib.escape(category)
|
|
|
|
# No inline onclick or IDs - uses event delegation in JS
|
|
endpoints_html += f'<div class="category" data-category="{category_escaped}">'
|
|
endpoints_html += f'<div class="category-header">'
|
|
endpoints_html += f'<span>{category_escaped}</span>'
|
|
endpoints_html += f'<span class="count">{len(routes_list)} endpoints</span>'
|
|
endpoints_html += '</div>'
|
|
endpoints_html += '<div class="endpoint-list">'
|
|
|
|
for route in routes_list:
|
|
method = html_lib.escape(route['method'])
|
|
path = html_lib.escape(route['path'])
|
|
desc = html_lib.escape(route['description'])
|
|
|
|
# Replace path parameters with styled spans
|
|
# Match escaped angle brackets from html.escape
|
|
path_display = re.sub(
|
|
r'<([^:>]+):([^>]+)>',
|
|
r'<span class="parameter-hint">:\2</span>',
|
|
path
|
|
)
|
|
path_display = re.sub(
|
|
r'<([^>]+)>',
|
|
r'<span class="parameter-hint">\1</span>',
|
|
path_display
|
|
)
|
|
|
|
endpoints_html += (
|
|
f'<div class="endpoint" data-method="{method}" data-path="{path}">'
|
|
)
|
|
endpoints_html += f'<span class="method {method}">{method}</span>'
|
|
endpoints_html += f'<div class="endpoint-path"><span class="path">{path_display}</span></div>'
|
|
endpoints_html += f'<span class="description">{desc}</span>'
|
|
endpoints_html += '</div>'
|
|
|
|
endpoints_html += '</div></div>'
|
|
|
|
# Build final page using replace to avoid format() brace issues
|
|
page = PAGE_TEMPLATE
|
|
page = page.replace("{{ENDPOINTS}}", endpoints_html)
|
|
page = page.replace("{{TOTAL}}", str(total_routes))
|
|
page = page.replace("{{CATEGORIES_COUNT}}", str(len(categories)))
|
|
|
|
response.content_type = "text/html; charset=utf-8"
|
|
return page
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error generating API documentation: {e}")
|
|
response.status = 500
|
|
response.content_type = "application/json"
|
|
return {"error": "Failed to generate API documentation", "message": str(e)}
|
|
|
|
@app.route("/api/docs/json")
|
|
def api_docs_json():
|
|
"""
|
|
Get API documentation as JSON (machine-readable format)
|
|
|
|
Example: http://localhost:7777/api/docs/json
|
|
"""
|
|
try:
|
|
routes = []
|
|
|
|
for route in app.routes:
|
|
# Skip internal routes
|
|
if route.rule.startswith('/_') or route.rule.startswith('/static'):
|
|
continue
|
|
|
|
if route.method in ('OPTIONS', 'HEAD'):
|
|
continue
|
|
|
|
docstring = None
|
|
if hasattr(route, 'callback'):
|
|
callback = route.callback
|
|
if hasattr(callback, '__doc__'):
|
|
docstring = callback.__doc__
|
|
elif hasattr(callback, '__wrapped__') and hasattr(callback.__wrapped__, '__doc__'):
|
|
docstring = callback.__wrapped__.__doc__
|
|
|
|
if docstring:
|
|
docstring = docstring.strip().split('\n')[0].strip()
|
|
|
|
routes.append({
|
|
'method': route.method,
|
|
'path': route.rule,
|
|
'description': docstring or 'No description available'
|
|
})
|
|
|
|
routes.sort(key=lambda r: (r['path'], r['method']))
|
|
|
|
response.content_type = "application/json; charset=utf-8"
|
|
return {
|
|
'total': len(routes),
|
|
'routes': routes
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error generating JSON API docs: {e}")
|
|
response.status = 500
|
|
response.content_type = "application/json"
|
|
return {"error": "Failed to generate API documentation", "message": str(e)} |