651 lines
26 KiB
Python
651 lines
26 KiB
Python
import hashlib
|
|
import json
|
|
import re
|
|
import uuid
|
|
from datetime import datetime
|
|
from hashlib import md5
|
|
from typing import Optional, Union, Generator
|
|
from http.cookiejar import CookieJar
|
|
|
|
import click
|
|
import requests
|
|
import xmltodict
|
|
from langcodes import Language
|
|
|
|
from unshackle.core.constants import AnyTrack
|
|
from unshackle.core.credential import Credential
|
|
from unshackle.core.manifests import DASH
|
|
from unshackle.core.search_result import SearchResult
|
|
from unshackle.core.service import Service
|
|
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
from unshackle.core.tracks import Chapter, Chapters, Subtitle, Tracks, Video
|
|
|
|
|
|
class MAX(Service):
|
|
"""
|
|
Service code for MAX's streaming service (https://max.com).
|
|
Version: 1.0.0
|
|
|
|
Authorization: Cookies
|
|
Security: UHD@L1 FHD@L1 HD@L3
|
|
|
|
Use full URL or title ID with type.
|
|
Examples:
|
|
- https://play.hbomax.com/movie/urn:hbo:movie:GUID
|
|
- https://play.hbomax.com/show/urn:hbo:series:GUID
|
|
- movie/GUID
|
|
- show/GUID
|
|
|
|
Note: This service is designed for users who have legal access to MAX content.
|
|
Ensure you have proper subscription and authentication before use.
|
|
"""
|
|
|
|
ALIASES = ("MAX", "max", "hbomax")
|
|
GEOFENCE = ("US",)
|
|
|
|
TITLE_RE = r"^(?:https?://(?:www\.|play\.)?hbomax\.com/)?(?P<type>[^/]+)/(?P<id>[^/]+)"
|
|
|
|
VIDEO_CODEC_MAP = {
|
|
"H264": ["avc1"],
|
|
"H265": ["hvc1", "dvh1"]
|
|
}
|
|
|
|
AUDIO_CODEC_MAP = {
|
|
"AAC": "mp4a",
|
|
"AC3": "ac-3",
|
|
"EC3": "ec-3"
|
|
}
|
|
|
|
@staticmethod
|
|
@click.command(name="MAX", short_help="https://max.com")
|
|
@click.argument("title", type=str)
|
|
@click.option("-vcodec", "--video-codec", default=None, help="Video codec preference")
|
|
@click.option("-acodec", "--audio-codec", default=None, help="Audio codec preference")
|
|
@click.pass_context
|
|
def cli(ctx, **kwargs):
|
|
return MAX(ctx, **kwargs)
|
|
|
|
def __init__(self, ctx, title, video_codec, audio_codec):
|
|
super().__init__(ctx)
|
|
|
|
self.title = title
|
|
self.vcodec = video_codec
|
|
self.acodec = audio_codec
|
|
|
|
# Get range parameter for HDR support
|
|
range_param = ctx.parent.params.get("range_")
|
|
self.range = range_param[0].name if range_param else "SDR"
|
|
|
|
if self.range == 'HDR10':
|
|
self.vcodec = "H265"
|
|
|
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
super().authenticate(cookies, credential)
|
|
if not cookies:
|
|
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
|
|
# Extract authentication tokens from cookies
|
|
try:
|
|
token = next(cookie.value for cookie in cookies if cookie.name == "st")
|
|
session_data = next(cookie.value for cookie in cookies if cookie.name == "session")
|
|
device_id = json.loads(session_data)
|
|
except (StopIteration, json.JSONDecodeError):
|
|
raise EnvironmentError("Required authentication cookies not found.")
|
|
|
|
# Configure headers based on device type
|
|
self.session.headers.update({
|
|
'User-Agent': 'BEAM-Android/1.0.0.104 (SONY/XR-75X95EL)',
|
|
'Accept': 'application/json, text/plain, */*',
|
|
'Content-Type': 'application/json',
|
|
'x-disco-client': 'SAMSUNGTV:124.0.0.0:beam:4.0.0.118',
|
|
'x-disco-params': 'realm=bolt,bid=beam,features=ar',
|
|
'x-device-info': 'beam/4.0.0.118 (Samsung/Samsung-Unknown; Tizen/124.0.0.0; f198a6c1-c582-4725-9935-64eb6b17c3cd/87a996fa-4917-41ae-9b6d-c7f521f0cb78)',
|
|
'traceparent': '00-315ac07a3de9ad1493956cf1dd5d1313-988e057938681391-01',
|
|
'tracestate': f'wbd=session:{device_id}',
|
|
'Origin': 'https://play.hbomax.com',
|
|
'Referer': 'https://play.hbomax.com/',
|
|
})
|
|
|
|
# Get device token
|
|
auth_token = self._get_device_token()
|
|
self.session.headers.update({
|
|
"x-wbd-session-state": auth_token
|
|
})
|
|
|
|
def search(self) -> Generator[SearchResult, None, None]:
|
|
"""
|
|
Search for content on MAX platform.
|
|
Note: This is a basic implementation - MAX's search API may require additional parameters.
|
|
"""
|
|
# Basic search implementation - you may need to adjust based on actual API
|
|
search_url = "https://default.prd.api.hbomax.com/search"
|
|
|
|
try:
|
|
response = self.session.get(search_url, params={"q": self.title})
|
|
response.raise_for_status()
|
|
|
|
search_data = response.json()
|
|
|
|
# Parse search results - adjust based on actual API response structure
|
|
for result in search_data.get("results", []):
|
|
yield SearchResult(
|
|
id_=result.get("id"),
|
|
title=result.get("title", "Unknown"),
|
|
label=result.get("type", "UNKNOWN").upper(),
|
|
url=f"https://play.hbomax.com/{result.get('type', 'content')}/{result.get('id')}"
|
|
)
|
|
|
|
except Exception as e:
|
|
self.log.warning(f"Search functionality not fully implemented: {e}")
|
|
# Return empty generator if search fails
|
|
return
|
|
yield # This makes it a generator function
|
|
|
|
def get_titles(self) -> Titles_T:
|
|
# Parse title input
|
|
match = re.match(self.TITLE_RE, self.title)
|
|
if not match:
|
|
raise ValueError("Invalid title format. Expected format: type/id or full URL")
|
|
|
|
content_type = match.group('type')
|
|
external_id = match.group('id')
|
|
|
|
response = self.session.get(
|
|
self.config['endpoints']['contentRoutes'] % (content_type, external_id)
|
|
)
|
|
response.raise_for_status()
|
|
|
|
try:
|
|
content_data = [x for x in response.json()["included"] if "attributes" in x and "title" in
|
|
x["attributes"] and x["attributes"]["alias"] == "generic-%s-blueprint-page" % (re.sub(r"-", "", content_type))][0]["attributes"]
|
|
content_title = content_data["title"]
|
|
except:
|
|
content_data = [x for x in response.json()["included"] if "attributes" in x and "alternateId" in
|
|
x["attributes"] and x["attributes"]["alternateId"] == external_id and x["attributes"].get("originalName")][0]["attributes"]
|
|
content_title = content_data["originalName"]
|
|
|
|
if content_type == "sport" or content_type == "event":
|
|
included_dt = response.json()["included"]
|
|
|
|
for included in included_dt:
|
|
for key, data in included.items():
|
|
if key == "attributes":
|
|
for k, d in data.items():
|
|
if d == "VOD":
|
|
event_data = included
|
|
|
|
release_date = event_data["attributes"].get("airDate") or event_data["attributes"].get("firstAvailableDate")
|
|
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
|
|
|
return Movies([
|
|
Movie(
|
|
id_=external_id,
|
|
service=self.__class__,
|
|
name=content_title.title(),
|
|
year=year,
|
|
data=event_data,
|
|
)
|
|
])
|
|
|
|
if content_type == "movie" or content_type == "standalone":
|
|
metadata = self.session.get(
|
|
url=self.config['endpoints']['moviePages'] % external_id
|
|
).json()['data']
|
|
|
|
try:
|
|
edit_id = metadata['relationships']['edit']['data']['id']
|
|
except:
|
|
for x in response.json()["included"]:
|
|
if x.get("type") == "video" and x.get("relationships", {}).get("show", {}).get("data", {}).get("id") == external_id:
|
|
metadata = x
|
|
|
|
release_date = metadata["attributes"].get("airDate") or metadata["attributes"].get("firstAvailableDate")
|
|
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
|
|
|
return Movies([
|
|
Movie(
|
|
id_=external_id,
|
|
service=self.__class__,
|
|
name=content_title,
|
|
year=year,
|
|
data=metadata,
|
|
)
|
|
])
|
|
|
|
if content_type in ["show", "mini-series", "topical"]:
|
|
episodes = []
|
|
if content_type == "mini-series":
|
|
alias = "generic-miniseries-page-rail-episodes"
|
|
elif content_type == "topical":
|
|
alias = "generic-topical-show-page-rail-episodes"
|
|
else:
|
|
alias = "-%s-page-rail-episodes-tabbed-content" % (content_type)
|
|
|
|
included_dt = response.json()["included"]
|
|
|
|
season_data = [data for included in included_dt for key, data in included.items()
|
|
if key == "attributes" for k, d in data.items() if alias in str(d).lower()][0]
|
|
|
|
season_data = season_data["component"]["filters"][0]
|
|
|
|
seasons = [int(season["value"]) for season in season_data["options"]]
|
|
|
|
season_parameters = [(int(season["value"]), season["parameter"]) for season in season_data["options"]
|
|
for season_number in seasons if int(season["value"]) == int(season_number)]
|
|
|
|
if not season_parameters:
|
|
raise ValueError("No seasons found")
|
|
|
|
for (value, parameter) in season_parameters:
|
|
data = self.session.get(
|
|
url=self.config['endpoints']['showPages'] % (external_id, parameter)
|
|
).json()
|
|
|
|
try:
|
|
episodes_dt = sorted([dt for dt in data["included"] if "attributes" in dt and "videoType" in
|
|
dt["attributes"] and dt["attributes"]["videoType"] == "EPISODE"
|
|
and int(dt["attributes"]["seasonNumber"]) == int(parameter.split("=")[-1])],
|
|
key=lambda x: x["attributes"]["episodeNumber"])
|
|
except KeyError:
|
|
raise ValueError("Season episodes were not found")
|
|
|
|
episodes.extend(episodes_dt)
|
|
|
|
episode_titles = []
|
|
release_date = episodes[0]["attributes"].get("airDate") or episodes[0]["attributes"].get("firstAvailableDate")
|
|
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
|
|
|
season_map = {int(item[1].split("=")[-1]): item[0] for item in season_parameters}
|
|
|
|
for episode in episodes:
|
|
episode_titles.append(
|
|
Episode(
|
|
id_=episode['id'],
|
|
service=self.__class__,
|
|
title=content_title,
|
|
season=season_map.get(episode['attributes'].get('seasonNumber')),
|
|
number=episode['attributes']['episodeNumber'],
|
|
name=episode['attributes']['name'],
|
|
year=year,
|
|
data=episode
|
|
)
|
|
)
|
|
|
|
return Series(episode_titles)
|
|
|
|
def get_tracks(self, title: Title_T) -> Tracks:
|
|
edit_id = title.data['relationships']['edit']['data']['id']
|
|
|
|
response = self.session.post(
|
|
url=self.config['endpoints']['playbackInfo'],
|
|
json={
|
|
'appBundle': 'beam',
|
|
'consumptionType': 'streaming',
|
|
'deviceInfo': {
|
|
'deviceId': '2dec6cb0-eb34-45f9-bbc9-a0533597303c',
|
|
'browser': {
|
|
'name': 'chrome',
|
|
'version': '113.0.0.0',
|
|
},
|
|
'make': 'Microsoft',
|
|
'model': 'XBOX-Unknown',
|
|
'os': {
|
|
'name': 'Windows',
|
|
'version': '113.0.0.0',
|
|
},
|
|
'platform': 'XBOX',
|
|
'deviceType': 'xbox',
|
|
'player': {
|
|
'sdk': {
|
|
'name': 'Beam Player Console',
|
|
'version': '1.0.2.4',
|
|
},
|
|
'mediaEngine': {
|
|
'name': 'GLUON_BROWSER',
|
|
'version': '1.20.1',
|
|
},
|
|
'playerView': {
|
|
'height': 1080,
|
|
'width': 1920,
|
|
},
|
|
},
|
|
},
|
|
'editId': edit_id,
|
|
'capabilities': {
|
|
'manifests': {
|
|
'formats': {
|
|
'dash': {},
|
|
},
|
|
},
|
|
'codecs': {
|
|
'video': {
|
|
'hdrFormats': [
|
|
'hlg',
|
|
'hdr10',
|
|
'dolbyvision5',
|
|
'dolbyvision8',
|
|
],
|
|
'decoders': [
|
|
{
|
|
'maxLevel': '6.2',
|
|
'codec': 'h265',
|
|
'levelConstraints': {
|
|
'width': {
|
|
'min': 1920,
|
|
'max': 3840,
|
|
},
|
|
'height': {
|
|
'min': 1080,
|
|
'max': 2160,
|
|
},
|
|
'framerate': {
|
|
'min': 15,
|
|
'max': 60,
|
|
},
|
|
},
|
|
'profiles': [
|
|
'main',
|
|
'main10',
|
|
],
|
|
},
|
|
{
|
|
'maxLevel': '4.2',
|
|
'codec': 'h264',
|
|
'levelConstraints': {
|
|
'width': {
|
|
'min': 640,
|
|
'max': 3840,
|
|
},
|
|
'height': {
|
|
'min': 480,
|
|
'max': 2160,
|
|
},
|
|
'framerate': {
|
|
'min': 15,
|
|
'max': 60,
|
|
},
|
|
},
|
|
'profiles': [
|
|
'high',
|
|
'main',
|
|
'baseline',
|
|
],
|
|
},
|
|
],
|
|
},
|
|
'audio': {
|
|
'decoders': [
|
|
{
|
|
'codec': 'aac',
|
|
'profiles': [
|
|
'lc',
|
|
'he',
|
|
'hev2',
|
|
'xhe',
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'devicePlatform': {
|
|
'network': {
|
|
'lastKnownStatus': {
|
|
'networkTransportType': 'unknown',
|
|
},
|
|
'capabilities': {
|
|
'protocols': {
|
|
'http': {
|
|
'byteRangeRequests': True,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'videoSink': {
|
|
'lastKnownStatus': {
|
|
'width': 1290,
|
|
'height': 2796,
|
|
},
|
|
'capabilities': {
|
|
'colorGamuts': [
|
|
'standard',
|
|
'wide',
|
|
],
|
|
'hdrFormats': [
|
|
'dolbyvision',
|
|
'hdr10plus',
|
|
'hdr10',
|
|
'hlg',
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'gdpr': False,
|
|
'firstPlay': False,
|
|
'playbackSessionId': str(uuid.uuid4()),
|
|
'applicationSessionId': str(uuid.uuid4()),
|
|
'userPreferences': {},
|
|
'features': [],
|
|
}
|
|
)
|
|
response.raise_for_status()
|
|
|
|
playback_data = response.json()
|
|
|
|
# Get video info for language
|
|
video_info = next(x for x in playback_data['videos'] if x['type'] == 'main')
|
|
title.language = Language.get(video_info['defaultAudioSelection']['language'])
|
|
|
|
fallback_url = playback_data["fallback"]["manifest"]["url"]
|
|
fallback_url = fallback_url.replace('fly', 'akm').replace('gcp', 'akm')
|
|
|
|
try:
|
|
self.wv_license_url = playback_data["drm"]["schemes"]["widevine"]["licenseUrl"]
|
|
except (KeyError, IndexError):
|
|
self.wv_license_url = None
|
|
|
|
try:
|
|
self.pr_license_url = playback_data["drm"]["schemes"]["playready"]["licenseUrl"]
|
|
except (KeyError, IndexError):
|
|
self.pr_license_url = None
|
|
|
|
manifest_url = fallback_url.replace('_fallback', '')
|
|
self.log.debug(f"MPD URL: {manifest_url}")
|
|
self.log.debug(f"Fallback URL: {fallback_url}")
|
|
self.log.debug(f"Widevine License URL: {self.wv_license_url}")
|
|
self.log.debug(f"PlayReady License URL: {self.pr_license_url}")
|
|
|
|
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
|
|
|
|
self.log.debug(tracks)
|
|
|
|
tracks.videos = self._dedupe(tracks.videos)
|
|
tracks.audio = self._dedupe(tracks.audio)
|
|
|
|
# Remove partial subs and get VTT subtitles
|
|
tracks.subtitles.clear()
|
|
|
|
subtitles = self._get_subtitles(manifest_url, fallback_url)
|
|
|
|
for subtitle in subtitles:
|
|
tracks.add(
|
|
Subtitle(
|
|
id_=md5(subtitle["url"].encode()).hexdigest()[0:6],
|
|
url=subtitle["url"],
|
|
codec=Subtitle.Codec.from_mime(subtitle['format']),
|
|
language=Language.get(subtitle["language"]),
|
|
forced=subtitle['name'] == 'Forced',
|
|
sdh=subtitle['name'] == 'SDH'
|
|
)
|
|
)
|
|
|
|
# Apply codec filters
|
|
if self.vcodec:
|
|
tracks.videos = [x for x in tracks.videos if (x.codec or "")[:4] in self.VIDEO_CODEC_MAP[self.vcodec]]
|
|
|
|
if self.acodec:
|
|
tracks.audio = [x for x in tracks.audio if (x.codec or "")[:4] == self.AUDIO_CODEC_MAP[self.acodec]]
|
|
|
|
# Set track properties
|
|
for track in tracks:
|
|
if isinstance(track, Video):
|
|
codec = track.data.get("dash", {}).get("representation", {}).get("codecs", "")
|
|
track.hdr10 = track.range == Video.Range.HDR10
|
|
track.dv = codec[:4] in ("dvh1", "dvhe")
|
|
if isinstance(track, Subtitle) and not track.codec:
|
|
track.codec = Subtitle.Codec.WebVTT
|
|
|
|
# Store video info for chapters
|
|
title.data['info'] = video_info
|
|
|
|
# Mark descriptive audio tracks
|
|
for track in tracks.audio:
|
|
if hasattr(track, 'data') and track.data.get("dash", {}).get("adaptation_set"):
|
|
role = track.data["dash"]["adaptation_set"].find("Role")
|
|
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
|
track.descriptive = True
|
|
|
|
self.log.debug(tracks)
|
|
|
|
return tracks
|
|
|
|
def get_chapters(self, title: Title_T) -> Chapters:
|
|
chapters = []
|
|
video_info = title.data.get('info', {})
|
|
if 'annotations' in video_info:
|
|
chapters.append(Chapter(timestamp=0.0, name='Chapter 1'))
|
|
chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['start']), name='Credits'))
|
|
chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['end']), name='Chapter 2'))
|
|
|
|
return Chapters(chapters)
|
|
|
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
if not self.wv_license_url:
|
|
return None
|
|
|
|
response = self.session.post(
|
|
url=self.wv_license_url,
|
|
data=challenge
|
|
)
|
|
response.raise_for_status()
|
|
return response.content
|
|
|
|
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
|
if not self.pr_license_url:
|
|
return None
|
|
|
|
# Handle both bytes and string challenge formats
|
|
if isinstance(challenge, bytes):
|
|
decoded_challenge = challenge.decode('utf-8')
|
|
else:
|
|
decoded_challenge = str(challenge)
|
|
|
|
response = self.session.post(
|
|
url=self.pr_license_url,
|
|
data=decoded_challenge,
|
|
headers={
|
|
'Content-Type': 'text/xml; charset=utf-8',
|
|
'SOAPAction': 'http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense'
|
|
}
|
|
)
|
|
|
|
response.raise_for_status()
|
|
return response.content
|
|
|
|
def _get_device_token(self):
|
|
response = self.session.post(self.config['endpoints']['bootstrap'])
|
|
response.raise_for_status()
|
|
return response.headers.get('x-wbd-session-state')
|
|
|
|
@staticmethod
|
|
def _convert_timecode(time_seconds):
|
|
"""Convert seconds to timestamp."""
|
|
return float(time_seconds)
|
|
|
|
def _get_subtitles(self, mpd_url, fallback_url):
|
|
base_url = "/".join(fallback_url.split("/")[:-1]) + "/"
|
|
xml = xmltodict.parse(requests.get(mpd_url).text)
|
|
|
|
try:
|
|
tracks = xml["MPD"]["Period"][0]["AdaptationSet"]
|
|
except KeyError:
|
|
tracks = xml["MPD"]["Period"]["AdaptationSet"]
|
|
|
|
subs_tracks_js = []
|
|
for subs_tracks in tracks:
|
|
if subs_tracks.get('@contentType') == 'text':
|
|
for x in self._force_instance(subs_tracks, "Representation"):
|
|
try:
|
|
path = re.search(r'(t/\w+/)', x["SegmentTemplate"]["@media"])[1]
|
|
except (AttributeError, KeyError):
|
|
path = 't/sub/'
|
|
|
|
is_sdh = False
|
|
is_forced = False
|
|
|
|
role_value = subs_tracks.get("Role", {}).get("@value", "")
|
|
|
|
if role_value == "caption":
|
|
url = base_url + path + subs_tracks['@lang'] + ('_sdh.vtt' if 'sdh' in subs_tracks.get("Label", "").lower() else '_cc.vtt')
|
|
is_sdh = True
|
|
elif role_value == "forced-subtitle":
|
|
url = base_url + path + subs_tracks['@lang'] + '_forced.vtt'
|
|
is_forced = True
|
|
elif role_value == "subtitle":
|
|
url = base_url + path + subs_tracks['@lang'] + '_sub.vtt'
|
|
else:
|
|
continue
|
|
|
|
subs_tracks_js.append({
|
|
"url": url,
|
|
"format": "vtt",
|
|
"language": subs_tracks["@lang"],
|
|
"name": "SDH" if is_sdh else "Forced" if is_forced else "Full",
|
|
})
|
|
|
|
return self._remove_dupe(subs_tracks_js)
|
|
|
|
@staticmethod
|
|
def _force_instance(data, variable):
|
|
if isinstance(data[variable], list):
|
|
return data[variable]
|
|
else:
|
|
return [data[variable]]
|
|
|
|
@staticmethod
|
|
def _remove_dupe(items):
|
|
seen = set()
|
|
new_items = []
|
|
for item in items:
|
|
url = item['url']
|
|
if url not in seen:
|
|
new_items.append(item)
|
|
seen.add(url)
|
|
return new_items
|
|
|
|
@staticmethod
|
|
def _dedupe(items: list) -> list:
|
|
if not items:
|
|
return items
|
|
if isinstance(items[0].url, list):
|
|
return items
|
|
|
|
# Create a more specific key for deduplication that includes resolution/bitrate
|
|
seen = {}
|
|
for item in items:
|
|
# For video tracks, use codec + resolution + bitrate as key
|
|
if hasattr(item, 'width') and hasattr(item, 'height'):
|
|
key = f"{item.codec}_{item.width}x{item.height}_{item.bitrate}"
|
|
# For audio tracks, use codec + language + bitrate + channels as key
|
|
elif hasattr(item, 'channels'):
|
|
key = f"{item.codec}_{item.language}_{item.bitrate}_{item.channels}"
|
|
# Fallback to URL for other track types
|
|
else:
|
|
key = item.url
|
|
|
|
# Keep the item if we haven't seen this exact combination
|
|
if key not in seen:
|
|
seen[key] = item
|
|
|
|
return list(seen.values())
|