From 5c28fdbb04e9317c6874dfd150a22fefa9a1b3a8 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Wed, 4 Mar 2026 12:10:23 +0100 Subject: [PATCH] Add discovery --- routes/streams.py | 128 ++++++++++++++++++++++++++++++++++++++++++---- routes/vod.py | 90 +++++++------------------------- 2 files changed, 138 insertions(+), 80 deletions(-) diff --git a/routes/streams.py b/routes/streams.py index 0ba6b58..602467b 100644 --- a/routes/streams.py +++ b/routes/streams.py @@ -35,7 +35,7 @@ from streaming_providers.base.utils import logger # --------------------------------------------------------------------------- CONTENT_TYPE_CHANNEL = "channel" CONTENT_TYPE_EVENT = "event" -# CONTENT_TYPE_VOD = "vod" # uncomment when VOD ops are implemented +CONTENT_TYPE_VOD = "vod" def setup_stream_routes(app, manager, service): @@ -59,10 +59,10 @@ def setup_stream_routes(app, manager, service): return manager.get_event_drm_configs( provider_name=provider, event_id=content_id, **kwargs ) - # elif content_type == CONTENT_TYPE_VOD: - # return manager.get_vod_drm_configs( - # provider_name=provider, vod_id=content_id, **kwargs - # ) + elif content_type == CONTENT_TYPE_VOD: + return manager.get_vod_drm_configs( + provider_name=provider, vod_id=content_id, **kwargs + ) else: raise ValueError(f"Unknown content_type '{content_type}'") @@ -79,10 +79,10 @@ def setup_stream_routes(app, manager, service): return manager.get_event_manifest( provider_name=provider, event_id=content_id, **kwargs ) - # elif content_type == CONTENT_TYPE_VOD: - # return manager.get_vod_manifest( - # provider_name=provider, vod_id=content_id, **kwargs - # ) + elif content_type == CONTENT_TYPE_VOD: + return manager.get_vod_manifest( + provider_name=provider, vod_id=content_id, **kwargs + ) else: raise ValueError(f"Unknown content_type '{content_type}'") @@ -551,6 +551,116 @@ def setup_stream_routes(app, manager, service): response.status = 500 return {"error": f"Internal server error: {str(e)}"} + # ========================================================================= + # VOD ROUTES — identical transport pattern to event routes + # /manifest → returns local stream URL + attaches DRM header + # /stream → proxy-rewrites or redirects to upstream manifest + # /drm → returns raw DRM configs + # ========================================================================= + + @app.route("/api/providers//vod//manifest") + def get_vod_stream_manifest(provider, vod_id): + """ + Returns JSON with a manifest_url pointing to the VOD stream endpoint. + Attaches x-kodi-drm-configs header. + """ + try: + country = request.query.get("country") + base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + stream_url = ( + f"{base_url}/api/providers/{provider}/vod/{vod_id}/stream/index.mpd" + ) + if country: + stream_url += f"?country={country}" + + _build_drm_header(CONTENT_TYPE_VOD, provider, vod_id, country=country) + + return { + "provider": provider, + "vod_id": vod_id, + "manifest_url": stream_url, + } + + except ValueError as e: + logger.error(f"manifest endpoint error for VOD {provider}/{vod_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"manifest endpoint error for VOD {provider}/{vod_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route("/api/providers//vod//stream/index.mpd") + def get_vod_stream(provider, vod_id): + """ + Returns HTTP 302 redirect to the VOD manifest, or a rewritten + manifest body when media proxy is active. + """ + try: + country = request.query.get("country") + return _resolve_stream( + CONTENT_TYPE_VOD, provider, vod_id, country=country + ) + except HTTPResponse: + raise + except ValueError as e: + logger.error(f"stream error for VOD {provider}/{vod_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"stream error for VOD {provider}/{vod_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route( + "/api/providers//vod//stream/decrypted/index.mpd" + ) + def get_vod_stream_decrypted(provider, vod_id): + """Decrypted VOD stream — all quality representations.""" + return _resolve_decrypted_stream( + CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=False + ) + + @app.route( + "/api/providers//vod//stream/decrypted/ffmpeg/index.mpd" + ) + def get_vod_stream_decrypted_ffmpeg(provider, vod_id): + """Decrypted VOD stream — highest quality only, optimised for ffmpeg.""" + return _resolve_decrypted_stream( + CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=True + ) + + @app.route("/api/providers//vod//drm") + def get_vod_drm(provider, vod_id): + """Return DRM configs for a specific VOD item.""" + try: + country = request.query.get("country") + drm_configs = _get_drm_configs( + CONTENT_TYPE_VOD, provider, vod_id, country=country + ) + + merged = {} + for config in drm_configs: + merged.update( + config.to_dict() if hasattr(config, "to_dict") else config + ) + + return { + "provider": provider, + "vod_id": vod_id, + "content_type": CONTENT_TYPE_VOD, + "drm_configs": merged, + } + + except ValueError as e: + logger.error(f"DRM endpoint error for VOD {provider}/{vod_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"DRM endpoint error for VOD {provider}/{vod_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + # ========================================================================= # EPG ROUTES (unchanged from original) # ========================================================================= diff --git a/routes/vod.py b/routes/vod.py index 37cc621..5e4b876 100644 --- a/routes/vod.py +++ b/routes/vod.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # routes/vod.py """ -VOD (Video on Demand) route handlers. +VOD (Video on Demand) browse route handlers. Browse endpoints ---------------- @@ -9,21 +9,26 @@ GET /api/providers//vod Returns the root-level VOD entries (categories and/or items). GET /api/providers//vod/ - Navigates the VOD tree by URL-safe name slugs. - e.g. /api/providers/myprovider/vod/sports/golf/pga/tournament_x + Navigates the VOD tree by URL-safe path segments. + e.g. /api/providers/discovery_de/vod/sports/nordic-combined -Stream/manifest/DRM endpoints ------------------------------- -GET /api/providers//vod//manifest - Returns the manifest URL for the VOD item whose id is the last - segment of . - e.g. /api/providers/discovery_de/vod/sports/nordic-combined/6bbea4ab-.../manifest + Response: + { + "provider": "discovery_de", + "path": "sports/nordic-combined", + "entries": [ + {"type": "vod_category", "id": "...", "name": "...", "slug": "..."}, + {"type": "vod", "id": "...", "name": "...", "slug": "..."} + ], + "count": 12 + } -GET /api/providers//vod//drm - Returns DRM configs for the VOD item whose id is the last segment of . - -Route registration order matters: the explicit /manifest and /drm routes are -registered BEFORE the generic route so Bottle matches them first. +Stream / manifest / DRM endpoints for VodItems are in streams.py: + GET /api/providers//vod//manifest + GET /api/providers//vod//stream/index.mpd + GET /api/providers//vod//drm +These are registered in streams.py (same pattern as channels and events) +and must be set up BEFORE setup_vod_routes() so Bottle matches them first. """ from bottle import response @@ -35,10 +40,6 @@ def setup_vod_routes(app, manager): def _serialize(entries) -> list: return [e.to_dict() for e in entries] - # ------------------------------------------------------------------ - # Root - # ------------------------------------------------------------------ - @app.route("/api/providers//vod", method="GET") def get_vod_root(provider): try: @@ -54,59 +55,6 @@ def setup_vod_routes(app, manager): response.status = 200 return {"provider": provider, "path": "", "entries": serialized, "count": len(serialized)} - # ------------------------------------------------------------------ - # Manifest & DRM — must be registered BEFORE the generic path route - # so Bottle matches /manifest and /drm suffixes here first. - # captures everything before the suffix, e.g. - # "sports/nordic-combined/6bbea4ab-735c-469b-89d2-2a38c195ce07" - # The last segment of that path is the vod_id (edit_id / content_id). - # ------------------------------------------------------------------ - - @app.route("/api/providers//vod//manifest", method="GET") - def get_vod_item_manifest(provider, path): - segments = [s for s in path.split("/") if s] - if not segments: - response.status = 400 - return {"error": "Missing VOD item id", "provider": provider} - vod_id = segments[-1] - try: - manifest_url = manager.get_vod_manifest(provider_name=provider, vod_id=vod_id) - except ValueError as e: - response.status = 404 - return {"error": "Not found", "message": str(e), "provider": provider, "vod_id": vod_id} - except Exception as e: - logger.error(f"Failed to get VOD manifest: {e}") - response.status = 500 - return {"error": "Failed to get manifest", "message": str(e), "provider": provider, "vod_id": vod_id} - if not manifest_url: - response.status = 404 - return {"error": "No manifest available", "provider": provider, "vod_id": vod_id} - response.status = 200 - return {"provider": provider, "vod_id": vod_id, "manifest_url": manifest_url} - - @app.route("/api/providers//vod//drm", method="GET") - def get_vod_item_drm(provider, path): - segments = [s for s in path.split("/") if s] - if not segments: - response.status = 400 - return {"error": "Missing VOD item id", "provider": provider} - vod_id = segments[-1] - try: - drm_configs = manager.get_vod_drm_configs(provider_name=provider, vod_id=vod_id) - except ValueError as e: - response.status = 404 - return {"error": "Not found", "message": str(e), "provider": provider, "vod_id": vod_id} - except Exception as e: - logger.error(f"Failed to get VOD DRM configs: {e}") - response.status = 500 - return {"error": "Failed to get DRM configs", "message": str(e), "provider": provider, "vod_id": vod_id} - response.status = 200 - return {"provider": provider, "vod_id": vod_id, "drm": [d.to_dict() for d in (drm_configs or [])]} - - # ------------------------------------------------------------------ - # Generic browse — registered last so /manifest and /drm win above - # ------------------------------------------------------------------ - @app.route("/api/providers//vod/", method="GET") def get_vod_path(provider, path): slug_segments = [s for s in path.split("/") if s]