mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-03 06:22:23 +02:00
644 lines
24 KiB
Python
644 lines
24 KiB
Python
# streaming_providers/providers/discovery/vod_manager.py
|
|
"""
|
|
Discovery+ VOD Manager
|
|
|
|
Implements get_vod_category(path) for the DiscoveryProvider.
|
|
|
|
Tree structure
|
|
--------------
|
|
[] → 4 root VodCategories (Sports, Genres, Franchises, Editorial)
|
|
["/sports"] → VodCategory per sport (content_id = "/sports/{alternateId}")
|
|
["/sports/alpine-skiing"] → VodItem per video (content_id = edit.id)
|
|
["/genre"] → VodCategory per genre taxonomyNode
|
|
["/genre/true-crime"] → VodCategory per show
|
|
["/show/{uuid}"] → VodItem per episode video
|
|
["/franchise"] → VodCategory per franchise show
|
|
["/editorial"] → VodCategory per editorial taxonomyNode
|
|
["/editorial/binge"] → VodCategory per show
|
|
...
|
|
|
|
Every non-root level works identically:
|
|
1. Treat content_id as a CMS route path.
|
|
2. GET /cms/routes{path} — the response contains the full page object and
|
|
all included items; no second fetch is needed.
|
|
3. Swap data → the page object from included so _parse_page sees a
|
|
standard page structure.
|
|
4. Parse included items and dispatch on type.
|
|
|
|
Root is the only special case — it returns four hardcoded bucket categories.
|
|
"""
|
|
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
from ...base.models.vod import VodCategory, VodItem
|
|
from ...base.utils.logger import logger
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CMS base URL (same subdomain as the sports.txt sample URL)
|
|
# ---------------------------------------------------------------------------
|
|
_CMS_BASE = "https://default.any-any.prd.api.discoveryplus.com"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Collections to read on a show page (episode rails only)
|
|
# ---------------------------------------------------------------------------
|
|
_EPISODE_ALIAS_KEYWORDS = ("episode",)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Root bucket prefixes — used to group taxonomy nodes from the home endpoint
|
|
# into labelled top-level categories.
|
|
#
|
|
# /sports, /genre, /franchise, /editorial do NOT exist as CMS routes —
|
|
# calling them directly returns 404. The real browsable categories live one
|
|
# level deeper (e.g. /genre/true-crime, /franchise/gold-rush).
|
|
# We discover them by fetching /home and collecting all taxonomyNode routes
|
|
# that start with one of these prefixes.
|
|
# ---------------------------------------------------------------------------
|
|
_ROOT_PREFIXES = [
|
|
("/sports", "Sports"),
|
|
("/genre", "Genres"),
|
|
("/franchise", "Franchises"),
|
|
("/editorial", "Editorial"),
|
|
]
|
|
|
|
# Home endpoint — used to discover the actual top-level categories
|
|
_HOME_ROUTE = "/home"
|
|
|
|
|
|
class DiscoveryVodManager:
|
|
"""
|
|
Handles VOD browsing for Discovery+.
|
|
|
|
Instantiated and owned by DiscoveryProvider.
|
|
Requires a reference to the provider so it can reuse
|
|
get_auth_headers() and http_manager.
|
|
"""
|
|
|
|
def __init__(self, provider):
|
|
self._provider = provider
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public entry point — called by DiscoveryProvider.get_vod_category
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_vod_category(
|
|
self, content_id: str = "", **kwargs
|
|
) -> List[Union[VodCategory, VodItem]]:
|
|
"""
|
|
Return the children of the VOD node identified by *content_id*.
|
|
|
|
content_id is a single opaque token — the CMS route path returned by
|
|
a previous call (e.g. "/sports", "/sports/nordic-combined").
|
|
Empty string → root.
|
|
"""
|
|
if not content_id:
|
|
return self._root()
|
|
|
|
# Restore leading slash stripped by Bottle's :path wildcard.
|
|
if "/" in content_id and not content_id.startswith("/") and ":" not in content_id:
|
|
content_id = "/" + content_id
|
|
|
|
logger.debug(f"DiscoveryVodManager: get_vod_category({content_id!r})")
|
|
return self._fetch_children(content_id)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Root — dynamically discovered from the /home endpoint
|
|
# ------------------------------------------------------------------
|
|
|
|
def _root(self) -> List[VodCategory]:
|
|
"""
|
|
Return top-level VOD categories by fetching /home and extracting all
|
|
taxonomyNode routes grouped under the known prefixes.
|
|
|
|
Discovery+ does not expose /genre, /franchise, or /editorial as
|
|
valid CMS routes — those paths return 404. The actual browsable
|
|
top-level categories (e.g. /genre/true-crime, /franchise/gold-rush)
|
|
are embedded in the /home response as taxonomyNode objects with
|
|
canonical route URLs. We collect them here so the rest of the tree
|
|
can use real content_ids that resolve correctly.
|
|
|
|
Falls back to an empty list (with a logged error) if the home fetch
|
|
fails, so callers always receive a list.
|
|
"""
|
|
try:
|
|
home_data = self._resolve_route(_HOME_ROUTE)
|
|
except Exception as e:
|
|
logger.error(f"DiscoveryVodManager: failed to fetch home route: {e}")
|
|
return []
|
|
|
|
included: List[dict] = home_data.get("included", [])
|
|
index: Dict[str, dict] = {obj["id"]: obj for obj in included}
|
|
|
|
# Build a map of route-URL → (alternateId, localisedName) from all
|
|
# taxonomyNode objects present in the home response.
|
|
# A single taxonomyNode may have multiple route refs; we walk all of
|
|
# them so we don't miss canonical vs non-canonical variants.
|
|
route_url_to_node_attrs: Dict[str, dict] = {}
|
|
for obj in included:
|
|
if obj.get("type") != "taxonomyNode":
|
|
continue
|
|
attrs = obj.get("attributes", {})
|
|
for ref in obj.get("relationships", {}).get("routes", {}).get("data", []):
|
|
route_obj = index.get(ref["id"])
|
|
if not route_obj:
|
|
continue
|
|
url = route_obj.get("attributes", {}).get("url", "")
|
|
if url:
|
|
route_url_to_node_attrs[url] = attrs
|
|
|
|
# Group discovered URLs under each root prefix and deduplicate.
|
|
results: List[VodCategory] = []
|
|
seen_urls: set = set()
|
|
|
|
for prefix, label in _ROOT_PREFIXES:
|
|
# Collect all URLs that belong to this prefix
|
|
children: List[VodCategory] = []
|
|
for url, attrs in sorted(route_url_to_node_attrs.items()):
|
|
if not url.startswith(prefix + "/"):
|
|
continue
|
|
if url in seen_urls:
|
|
continue
|
|
seen_urls.add(url)
|
|
|
|
# Use alternateId as the canonical name (matches URL slug exactly),
|
|
# falling back to the last path segment or the localised display name.
|
|
alternate_id = attrs.get("alternateId", "").strip()
|
|
segment = url.rstrip("/").rsplit("/", 1)[-1]
|
|
name = alternate_id or segment or attrs.get("name", "")
|
|
|
|
children.append(
|
|
VodCategory(
|
|
name=name,
|
|
content_id=url,
|
|
provider="discovery",
|
|
description=attrs.get("name") or None,
|
|
)
|
|
)
|
|
|
|
# Only emit the group entry when we actually found children.
|
|
# (Sports is fetched differently and always has entries; the others
|
|
# depend on what the home page exposes for this territory.)
|
|
results.extend(children)
|
|
logger.debug(
|
|
f"DiscoveryVodManager: root — '{label}' has {len(children)} categories"
|
|
)
|
|
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# Generic page fetch + parse
|
|
# ------------------------------------------------------------------
|
|
|
|
def _fetch_children(
|
|
self, route: str
|
|
) -> List[Union[VodCategory, VodItem]]:
|
|
"""
|
|
Resolve a CMS route path and parse its children.
|
|
|
|
The /cms/routes response already contains the full page object and
|
|
all included items — no second /cms/pages fetch is needed.
|
|
"""
|
|
try:
|
|
route_data = self._resolve_route(route)
|
|
page_data = self._route_to_page_data(route_data, route)
|
|
return self._parse_page(page_data)
|
|
except Exception as e:
|
|
logger.error(f"DiscoveryVodManager: failed to fetch '{route}': {e}")
|
|
return []
|
|
|
|
def _resolve_route(self, route: str) -> dict:
|
|
"""
|
|
GET /cms/routes{route} → full JSON:API response.
|
|
|
|
Returns the raw response dict (data + included). The caller is
|
|
responsible for extracting the page object via _route_to_page_data.
|
|
"""
|
|
url = f"{_CMS_BASE}/cms/routes{route}"
|
|
params = {
|
|
"include": "default",
|
|
"decorators": "viewingHistory,isFavorite,contentAction,badges",
|
|
"page[items.size]": "100",
|
|
}
|
|
response = self._provider.http_manager.get(
|
|
url,
|
|
operation="vod_resolve_route",
|
|
headers=self._provider.get_auth_headers(),
|
|
params=params,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
page_id = (
|
|
data.get("data", {})
|
|
.get("relationships", {})
|
|
.get("target", {})
|
|
.get("data", {})
|
|
.get("id")
|
|
)
|
|
if not page_id:
|
|
raise ValueError(f"No page_id in route response for '{route}'")
|
|
logger.debug(f"DiscoveryVodManager: route '{route}' → page '{page_id}'")
|
|
|
|
return data
|
|
|
|
@staticmethod
|
|
def _route_to_page_data(route_data: dict, route: str) -> dict:
|
|
"""
|
|
The /cms/routes response has data.type == "route". _parse_page
|
|
expects data to be the page object so it can walk
|
|
data.relationships.items.
|
|
|
|
This method swaps data to the actual page object found in included,
|
|
leaving included intact so the full index is available to the parser.
|
|
"""
|
|
included: List[dict] = route_data.get("included", [])
|
|
index: Dict[str, dict] = {obj["id"]: obj for obj in included}
|
|
|
|
# The route's target relationship points to the page id
|
|
page_id = (
|
|
route_data.get("data", {})
|
|
.get("relationships", {})
|
|
.get("target", {})
|
|
.get("data", {})
|
|
.get("id")
|
|
)
|
|
|
|
page_obj = index.get(page_id)
|
|
if not page_obj:
|
|
raise ValueError(
|
|
f"Page object '{page_id}' not found in route included for '{route}'"
|
|
)
|
|
|
|
return {
|
|
"data": page_obj,
|
|
"included": included,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Page parser
|
|
# ------------------------------------------------------------------
|
|
|
|
def _parse_page(
|
|
self, data: dict
|
|
) -> List[Union[VodCategory, VodItem]]:
|
|
"""
|
|
Parse a CMS page response into VodCategory / VodItem entries.
|
|
|
|
Strategy:
|
|
- Build an id→object index from `included`.
|
|
- Collect relevant collectionItems (episode-only filter on show pages).
|
|
- Dispatch each item on the type of its relationship target.
|
|
"""
|
|
included: List[dict] = data.get("included", [])
|
|
index: Dict[str, dict] = {obj["id"]: obj for obj in included}
|
|
|
|
# Collect all collections on this page (via pageItems)
|
|
page_obj = data.get("data", {})
|
|
|
|
page_item_ids = [
|
|
pi["id"]
|
|
for pi in page_obj.get("relationships", {})
|
|
.get("items", {})
|
|
.get("data", [])
|
|
]
|
|
page_items = [index[pid] for pid in page_item_ids if pid in index]
|
|
|
|
# Each pageItem points to a collection
|
|
collections: List[dict] = []
|
|
for pi in page_items:
|
|
col_ref = (
|
|
pi.get("relationships", {})
|
|
.get("collection", {})
|
|
.get("data", {})
|
|
)
|
|
if col_ref and col_ref.get("id") in index:
|
|
collections.append(index[col_ref["id"]])
|
|
|
|
# Decide which collections to read
|
|
is_show_page = self._is_show_page(collections)
|
|
if is_show_page:
|
|
collections = [
|
|
c for c in collections
|
|
if any(
|
|
kw in c.get("attributes", {}).get("alias", "")
|
|
for kw in _EPISODE_ALIAS_KEYWORDS
|
|
)
|
|
]
|
|
logger.debug(
|
|
f"DiscoveryVodManager: show page — using "
|
|
f"{len(collections)} episode collection(s)"
|
|
)
|
|
|
|
# Collect all collectionItem ids from selected collections
|
|
col_item_ids: List[str] = []
|
|
for col in collections:
|
|
for ci_ref in (
|
|
col.get("relationships", {})
|
|
.get("items", {})
|
|
.get("data", [])
|
|
):
|
|
col_item_ids.append(ci_ref["id"])
|
|
|
|
# Resolve nested collection items (collectionItem → collection → items)
|
|
col_item_ids = self._expand_nested_collections(
|
|
col_item_ids, index, is_show_page
|
|
)
|
|
|
|
# Dispatch
|
|
results: List[Union[VodCategory, VodItem]] = []
|
|
seen_ids: set = set()
|
|
|
|
for ci_id in col_item_ids:
|
|
ci = index.get(ci_id)
|
|
if not ci:
|
|
continue
|
|
rel = ci.get("relationships", {})
|
|
|
|
if "video" in rel:
|
|
video_id = rel["video"]["data"]["id"]
|
|
if video_id in seen_ids:
|
|
continue
|
|
seen_ids.add(video_id)
|
|
video = index.get(video_id)
|
|
if video:
|
|
item = self._video_to_vod_item(video, index)
|
|
if item:
|
|
results.append(item)
|
|
|
|
elif "show" in rel:
|
|
show_id = rel["show"]["data"]["id"]
|
|
if show_id in seen_ids:
|
|
continue
|
|
seen_ids.add(show_id)
|
|
show = index.get(show_id)
|
|
if show:
|
|
cat = self._show_to_vod_category(show, index)
|
|
if cat:
|
|
results.append(cat)
|
|
|
|
elif "taxonomyNode" in rel:
|
|
node_id = rel["taxonomyNode"]["data"]["id"]
|
|
if node_id in seen_ids:
|
|
continue
|
|
seen_ids.add(node_id)
|
|
node = index.get(node_id)
|
|
if node:
|
|
cat = self._taxonomy_node_to_vod_category(node, index)
|
|
if cat:
|
|
results.append(cat)
|
|
|
|
# "collection" nested refs are handled by _expand_nested_collections
|
|
# Everything else (channel, creditGroup, …) is silently skipped.
|
|
|
|
logger.debug(
|
|
f"DiscoveryVodManager: parsed {len(results)} children "
|
|
f"({'show page' if is_show_page else 'category page'})"
|
|
)
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _is_show_page(collections: List[dict]) -> bool:
|
|
"""
|
|
A page is treated as a show page if any collection alias contains
|
|
'episode'. This drives the episode-only filter.
|
|
"""
|
|
return any(
|
|
"episode" in c.get("attributes", {}).get("alias", "")
|
|
for c in collections
|
|
)
|
|
|
|
@staticmethod
|
|
def _expand_nested_collections(
|
|
col_item_ids: List[str],
|
|
index: Dict[str, dict],
|
|
is_show_page: bool,
|
|
_visited: Optional[set] = None,
|
|
) -> List[str]:
|
|
"""
|
|
Some collectionItems point to another collection rather than direct
|
|
content (e.g. the tab-group → tab → grid pattern).
|
|
|
|
Recursively expands collection refs until only content items remain.
|
|
A visited set prevents infinite loops on circular references.
|
|
Non-collection items are passed through unchanged.
|
|
"""
|
|
if _visited is None:
|
|
_visited = set()
|
|
|
|
expanded: List[str] = []
|
|
for ci_id in col_item_ids:
|
|
if ci_id in _visited:
|
|
continue
|
|
_visited.add(ci_id)
|
|
|
|
ci = index.get(ci_id)
|
|
if not ci:
|
|
continue
|
|
rel = ci.get("relationships", {})
|
|
if "collection" in rel:
|
|
nested_col_id = rel["collection"]["data"]["id"]
|
|
nested_col = index.get(nested_col_id)
|
|
if nested_col:
|
|
alias = nested_col.get("attributes", {}).get("alias", "")
|
|
# On show pages skip nested collections that aren't episodes
|
|
if is_show_page and not any(
|
|
kw in alias for kw in _EPISODE_ALIAS_KEYWORDS
|
|
):
|
|
continue
|
|
nested_ids = [
|
|
ref["id"]
|
|
for ref in nested_col.get("relationships", {})
|
|
.get("items", {})
|
|
.get("data", [])
|
|
]
|
|
# Recurse to handle arbitrarily deep nesting
|
|
expanded.extend(
|
|
DiscoveryVodManager._expand_nested_collections(
|
|
nested_ids, index, is_show_page, _visited
|
|
)
|
|
)
|
|
else:
|
|
expanded.append(ci_id)
|
|
return expanded
|
|
|
|
@staticmethod
|
|
def _resolve_route_url(
|
|
obj: dict, index: Dict[str, dict]
|
|
) -> Optional[str]:
|
|
"""
|
|
Follow obj → relationships.routes[0] → route.attributes.url
|
|
Returns the canonical URL string or None.
|
|
"""
|
|
route_refs = (
|
|
obj.get("relationships", {})
|
|
.get("routes", {})
|
|
.get("data", [])
|
|
)
|
|
for ref in route_refs:
|
|
route = index.get(ref["id"])
|
|
if route and route.get("attributes", {}).get("canonical", False):
|
|
return route["attributes"].get("url")
|
|
# Fall back to first route if none is canonical
|
|
for ref in route_refs:
|
|
route = index.get(ref["id"])
|
|
if route:
|
|
return route["attributes"].get("url")
|
|
return None
|
|
|
|
@staticmethod
|
|
def _pick_image(
|
|
obj: dict, index: Dict[str, dict], preferred_kind: str = "default"
|
|
) -> Optional[str]:
|
|
"""Return the src of the first image matching preferred_kind, else first image."""
|
|
image_refs = (
|
|
obj.get("relationships", {})
|
|
.get("images", {})
|
|
.get("data", [])
|
|
)
|
|
fallback_src = None
|
|
for ref in image_refs:
|
|
img = index.get(ref["id"])
|
|
if not img:
|
|
continue
|
|
src = img.get("attributes", {}).get("src")
|
|
if not src:
|
|
continue
|
|
if fallback_src is None:
|
|
fallback_src = src
|
|
if img.get("attributes", {}).get("kind") == preferred_kind:
|
|
return src
|
|
return fallback_src
|
|
|
|
# ------------------------------------------------------------------
|
|
# Object → VOD model converters
|
|
# ------------------------------------------------------------------
|
|
|
|
def _video_to_vod_item(
|
|
self, video: dict, index: Dict[str, dict]
|
|
) -> Optional[VodItem]:
|
|
"""
|
|
Convert a video JSON:API object to a VodItem.
|
|
content_id = edit.id (the playback identifier).
|
|
|
|
Name is built as "name — secondaryTitle" when secondaryTitle is present
|
|
so that sibling broadcasts of the same event (e.g. different feeds or
|
|
gender groups) produce distinct slugs and avoid collisions.
|
|
"""
|
|
edit_ref = (
|
|
video.get("relationships", {})
|
|
.get("edit", {})
|
|
.get("data", {})
|
|
)
|
|
edit_id = edit_ref.get("id") if edit_ref else None
|
|
if not edit_id:
|
|
logger.debug(
|
|
f"DiscoveryVodManager: video '{video.get('id')}' has no edit — skipping"
|
|
)
|
|
return None
|
|
|
|
attrs = video.get("attributes", {})
|
|
|
|
# Only finished content is eligible for VOD.
|
|
# videoType == "LIVE" means the event is upcoming or ongoing — skip it.
|
|
# "STANDALONE_EVENT" → full past broadcast; "CLIP" → highlight reel.
|
|
video_type = attrs.get("videoType", "")
|
|
if video_type == "LIVE":
|
|
logger.debug(
|
|
f"DiscoveryVodManager: video '{video.get('id')}' is LIVE — skipping"
|
|
)
|
|
return None
|
|
|
|
is_highlight = video_type == "CLIP"
|
|
|
|
# Duration: edit has duration_ms; fall back to video attrs
|
|
edit_obj = index.get(edit_id, {})
|
|
duration_ms = edit_obj.get("attributes", {}).get("duration")
|
|
duration_seconds = int(duration_ms / 1000) if duration_ms else None
|
|
|
|
# Build a display name that is unique across sibling broadcasts.
|
|
# Discovery+ often has multiple feeds for the same event (e.g. different
|
|
# language channels or gender groups) that share the same `name`.
|
|
# Appending secondaryTitle (e.g. "Weltcup | Frauen") disambiguates them
|
|
# and prevents slug collisions in the backend.
|
|
base_name = attrs.get("name", "")
|
|
secondary = attrs.get("secondaryTitle", "")
|
|
name = f"{base_name} — {secondary}" if secondary else base_name
|
|
|
|
return VodItem.create_episode(
|
|
name=name,
|
|
content_id=edit_id,
|
|
provider="discovery",
|
|
season_number=attrs.get("seasonNumber"),
|
|
episode_number=attrs.get("episodeNumber"),
|
|
description=attrs.get("description") or attrs.get("longDescription"),
|
|
logo_url=self._pick_image(video, index, "default"),
|
|
duration_seconds=duration_seconds,
|
|
is_highlight=is_highlight,
|
|
)
|
|
|
|
def _show_to_vod_category(
|
|
self, show: dict, index: Dict[str, dict]
|
|
) -> Optional[VodCategory]:
|
|
"""
|
|
Convert a show JSON:API object to a VodCategory.
|
|
content_id = canonical route URL (e.g. /show/{uuid}).
|
|
"""
|
|
route_url = self._resolve_route_url(show, index)
|
|
if not route_url:
|
|
logger.debug(
|
|
f"DiscoveryVodManager: show '{show.get('id')}' has no route — skipping"
|
|
)
|
|
return None
|
|
|
|
attrs = show.get("attributes", {})
|
|
return VodCategory(
|
|
name=attrs.get("name", ""),
|
|
content_id=route_url,
|
|
provider="discovery",
|
|
description=attrs.get("description") or attrs.get("longDescription"),
|
|
logo_url=self._pick_image(show, index, "default"),
|
|
)
|
|
|
|
def _taxonomy_node_to_vod_category(
|
|
self, node: dict, index: Dict[str, dict]
|
|
) -> Optional[VodCategory]:
|
|
"""
|
|
Convert a taxonomyNode JSON:API object to a VodCategory.
|
|
content_id = canonical route URL (e.g. /sports/alpine-skiing).
|
|
|
|
The category name is set to the node's alternateId rather than its
|
|
localised display name. The backend derives URL slugs from the name,
|
|
so using the alternateId (which matches the URL path segment exactly,
|
|
e.g. "nordic-combined") ensures that slug resolution works correctly
|
|
across all locales. The localised name is preserved in description
|
|
so that UI layers can still display it when desired.
|
|
"""
|
|
route_url = self._resolve_route_url(node, index)
|
|
if not route_url:
|
|
logger.debug(
|
|
f"DiscoveryVodManager: taxonomyNode '{node.get('id')}' "
|
|
"has no route — skipping"
|
|
)
|
|
return None
|
|
|
|
attrs = node.get("attributes", {})
|
|
|
|
# Prefer alternateId as the canonical name used for slug matching.
|
|
# Fall back to the last path segment of the route URL, and finally
|
|
# to the localised display name if neither is available.
|
|
alternate_id = attrs.get("alternateId", "").strip()
|
|
route_segment = route_url.rstrip("/").rsplit("/", 1)[-1]
|
|
name = alternate_id or route_segment or attrs.get("name", "")
|
|
|
|
return VodCategory(
|
|
name=name,
|
|
content_id=route_url,
|
|
provider="discovery",
|
|
# Surface the localised display name as description so UI layers
|
|
# can render it in the user's language when needed.
|
|
description=attrs.get("name") or None,
|
|
logo_url=self._pick_image(node, index, "default"),
|
|
) |