""" Discogs API client for music metadata enrichment. Follows the same pattern as iTunesClient/DeezerClient — returns data via the shared Track/Artist/Album dataclasses so all sources are interchangeable. Rate limits: 25 req/min unauthenticated, 60 req/min with personal token. API docs: https://www.discogs.com/developers """ import re import time import threading import requests from core.metadata.cache import get_metadata_cache from typing import List, Dict, Any, Optional from dataclasses import dataclass from functools import wraps from utils.logging_config import get_logger logger = get_logger("discogs_client") # Global rate limiting _last_api_call_time = 0 _api_call_lock = threading.Lock() MIN_API_INTERVAL = 2.5 # 25 req/min unauth = 1 call per 2.4s, padded to 2.5s MIN_API_INTERVAL_AUTH = 1.0 # 60 req/min auth = 1 call per 1.0s _is_authenticated = False def rate_limited(func): """Decorator to enforce rate limiting on Discogs API calls.""" @wraps(func) def wrapper(*args, **kwargs): global _last_api_call_time interval = MIN_API_INTERVAL_AUTH if _is_authenticated else MIN_API_INTERVAL with _api_call_lock: current_time = time.time() time_since_last_call = current_time - _last_api_call_time if time_since_last_call < interval: sleep_time = interval - time_since_last_call time.sleep(sleep_time) _last_api_call_time = time.time() from core.api_call_tracker import api_call_tracker api_call_tracker.record_call('discogs') try: result = func(*args, **kwargs) return result except Exception as e: if "429" in str(e): logger.warning(f"Discogs rate limit hit, backing off: {e}") time.sleep(30) raise e return wrapper # --- Shared dataclasses (same shape as iTunes/Deezer/Spotify) --- @dataclass class Track: id: str name: str artists: List[str] album: str duration_ms: int popularity: int preview_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None image_url: Optional[str] = None release_date: Optional[str] = None track_number: Optional[int] = None disc_number: Optional[int] = None album_type: Optional[str] = None total_tracks: Optional[int] = None @classmethod def from_discogs_track(cls, track_data: Dict[str, Any], release_data: Dict[str, Any] = None) -> 'Track': """Build Track from Discogs release tracklist entry + parent release.""" release = release_data or {} # Parse position (e.g., "A1", "B2", "1", "2-3") position = track_data.get('position', '') track_number = None disc_number = 1 if position: # Handle "1-3" (disc-track) format if '-' in position and position.replace('-', '').isdigit(): parts = position.split('-') disc_number = int(parts[0]) track_number = int(parts[1]) elif position.isdigit(): track_number = int(position) else: # Vinyl side notation: A1 → disc 1 track 1, B2 → disc 1 track 6, etc. try: track_number = int(''.join(c for c in position if c.isdigit()) or '0') or None except ValueError: pass # Duration string "5:23" → ms duration_ms = 0 dur_str = track_data.get('duration', '') if dur_str and ':' in dur_str: parts = dur_str.split(':') try: duration_ms = (int(parts[0]) * 60 + int(parts[1])) * 1000 except (ValueError, IndexError): pass # Artists from track-level or release-level track_artists = [] if track_data.get('artists'): track_artists = [a.get('name', '') for a in track_data['artists'] if a.get('name')] if not track_artists and release.get('artists'): track_artists = [a.get('name', '') for a in release['artists'] if a.get('name')] if not track_artists: track_artists = ['Unknown Artist'] # Image from release image_url = None images = release.get('images', []) if images: # Prefer 'primary' type, fall back to first primary = next((img for img in images if img.get('type') == 'primary'), None) image_url = (primary or images[0]).get('uri') # Album type total_tracks = len(release.get('tracklist', [])) formats = release.get('formats', [{}]) format_name = formats[0].get('name', '') if formats else '' external_urls = {} if release.get('uri'): external_urls['discogs'] = f"https://www.discogs.com{release['uri']}" if release['uri'].startswith('/') else release['uri'] return cls( id=str(release.get('id', '')) + f'_t{track_number or 0}', name=track_data.get('title', ''), artists=track_artists, album=release.get('title', ''), duration_ms=duration_ms, popularity=release.get('community', {}).get('have', 0), external_urls=external_urls if external_urls else None, image_url=image_url, release_date=str(release.get('year', '')) if release.get('year') else None, track_number=track_number, disc_number=disc_number, album_type='album', total_tracks=total_tracks, ) @dataclass class Artist: id: str name: str popularity: int genres: List[str] followers: int image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None @classmethod def from_discogs_artist(cls, artist_data: Dict[str, Any]) -> 'Artist': # Images — prefer primary image_url = None images = artist_data.get('images', []) if images: primary = next((img for img in images if img.get('type') == 'primary'), None) image_url = (primary or images[0]).get('uri') # Search results use 'thumb' or 'cover_image' if not image_url: image_url = artist_data.get('cover_image') or artist_data.get('thumb') if image_url and 'spacer.gif' in image_url: image_url = None external_urls = {} if artist_data.get('uri'): uri = artist_data['uri'] external_urls['discogs'] = f"https://www.discogs.com{uri}" if uri.startswith('/') else uri elif artist_data.get('resource_url'): external_urls['discogs_api'] = artist_data['resource_url'] return cls( id=str(artist_data.get('id', '')), name=artist_data.get('name', artist_data.get('title', '')), popularity=0, genres=[], followers=0, image_url=image_url, external_urls=external_urls if external_urls else None, ) @dataclass class Album: id: str name: str artists: List[str] release_date: str total_tracks: int album_type: str image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None @classmethod def from_discogs_release(cls, release_data: Dict[str, Any]) -> 'Album': # Artists — search results put "Artist - Title" in the title field artists = [] title = release_data.get('title', '') if release_data.get('artists'): artists = [a.get('name', '') for a in release_data['artists'] if a.get('name')] elif release_data.get('artist'): artists = [release_data['artist']] elif ' - ' in title: # Search results: "Radiohead - OK Computer" → artist="Radiohead", title="OK Computer" parts = title.split(' - ', 1) artists = [parts[0].strip()] title = parts[1].strip() if not artists: artists = ['Unknown Artist'] # Image image_url = None images = release_data.get('images', []) if images: primary = next((img for img in images if img.get('type') == 'primary'), None) image_url = (primary or images[0]).get('uri') if not image_url: image_url = release_data.get('cover_image') or release_data.get('thumb') if image_url and 'spacer.gif' in image_url: image_url = None # Track count tracklist = release_data.get('tracklist', []) total_tracks = len(tracklist) if tracklist else (release_data.get('format_quantity', 0) or 0) # Album type from formats array (full release detail) or format string (search/artist releases) formats = release_data.get('formats', []) format_name = formats[0].get('name', '').lower() if formats else '' descriptions = [d.lower() for d in formats[0].get('descriptions', [])] if formats else [] # Also check the 'format' field from search/artist release endpoints # Can be a string "Vinyl, LP, Album" or a list ["Vinyl", "LP", "Album"] raw_format = release_data.get('format') or '' if isinstance(raw_format, list): format_str = ', '.join(raw_format).lower() else: format_str = str(raw_format).lower() if 'single' in descriptions or 'single' in format_name or 'single' in format_str: album_type = 'single' elif 'ep' in descriptions or ', ep' in format_str or format_str.endswith('ep'): album_type = 'ep' elif 'compilation' in descriptions or 'compilation' in format_str or 'compilation' in (release_data.get('type', '') or '').lower(): album_type = 'compilation' elif 'lp' in descriptions or 'lp' in format_str or 'album' in descriptions or 'album' in format_str: album_type = 'album' elif total_tracks <= 3 and total_tracks > 0: album_type = 'single' elif total_tracks <= 6 and total_tracks > 0: album_type = 'ep' else: album_type = 'album' # Year year = release_data.get('year', '') release_date = str(year) if year and year != 0 else '' external_urls = {} if release_data.get('uri'): uri = release_data['uri'] external_urls['discogs'] = f"https://www.discogs.com{uri}" if uri.startswith('/') else uri elif release_data.get('resource_url'): external_urls['discogs_api'] = release_data['resource_url'] return cls( id=str(release_data.get('id', '')), name=title, artists=artists, release_date=release_date, total_tracks=total_tracks, album_type=album_type, image_url=image_url, external_urls=external_urls if external_urls else None, ) class DiscogsClient: """ Discogs API client for music metadata. Full parity with iTunesClient/DeezerClient — same method signatures, same return types (Track, Artist, Album dataclasses). Rate limit: 25 req/min unauthenticated, 60 req/min with personal token. """ BASE_URL = "https://api.discogs.com" def __init__(self, token: str = None): global _is_authenticated self.session = requests.Session() self.session.headers.update({ 'User-Agent': 'SoulSync/2.2 +https://github.com/Nezreka/SoulSync', 'Accept': 'application/json', }) # Load token from config or parameter self.token = token if not self.token: try: from config.settings import config_manager self.token = config_manager.get('discogs.token', '') except Exception as e: logger.debug("load discogs.token from config: %s", e) if self.token: self.session.headers['Authorization'] = f'Discogs token={self.token}' _is_authenticated = True logger.info("Discogs client initialized (authenticated — 60 req/min)") else: _is_authenticated = False logger.info("Discogs client initialized (unauthenticated — 25 req/min)") def is_authenticated(self) -> bool: return bool(self.token) def is_configured(self) -> bool: return True # Works without auth @staticmethod def _normalize_name(name: str) -> str: """Normalize a name for comparison — lowercase, strip parentheticals and punctuation.""" name = name.lower().strip() name = re.sub(r'\s*\(.*?\)\s*', ' ', name) name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() return name # --- Core API Methods --- @rate_limited def _api_get(self, endpoint: str, params: Dict = None) -> Optional[Dict]: """Make a GET request to the Discogs API.""" try: url = f"{self.BASE_URL}{endpoint}" if endpoint.startswith('/') else endpoint resp = self.session.get(url, params=params, timeout=15) if resp.status_code == 429: logger.warning("Discogs rate limit hit") time.sleep(30) return None if resp.status_code != 200: logger.debug(f"Discogs API {endpoint} returned {resp.status_code}") return None return resp.json() except Exception as e: logger.error(f"Discogs API error ({endpoint}): {e}") return None # --- User Collection (powers Your Albums Discogs source) --- def get_authenticated_username(self) -> Optional[str]: """Resolve the username for the configured personal token. Discogs's `/oauth/identity` endpoint returns the user's username when called with a valid token. Cached on the instance so subsequent calls don't re-hit the API. """ if hasattr(self, '_cached_username'): return self._cached_username if not self.is_authenticated(): self._cached_username = None return None data = self._api_get('/oauth/identity') username = data.get('username') if data else None self._cached_username = username return username def get_user_collection(self, username: Optional[str] = None, folder_id: int = 0, per_page: int = 100, max_pages: int = 50) -> List[Dict[str, Any]]: """Fetch a Discogs user's collection (folder 0 = "All"). Returns a list of normalized release dicts ready for ``database.upsert_liked_album``: { 'album_name': str, 'artist_name': str, 'release_id': int, # Discogs release id 'image_url': str | None, 'release_date': str, # 'YYYY' (Discogs only stores year) 'total_tracks': int, } Pagination caps at ``max_pages`` to bound runtime — at 100/page that's 5000 releases, more than enough for typical collections. Authenticated calls only (Discogs collection is private). """ if not self.is_authenticated(): logger.warning("Discogs collection fetch attempted without token") return [] if not username: username = self.get_authenticated_username() if not username: logger.warning("Could not resolve Discogs username for token") return [] results: List[Dict[str, Any]] = [] page = 1 while page <= max_pages: data = self._api_get( f'/users/{username}/collection/folders/{folder_id}/releases', {'page': page, 'per_page': per_page, 'sort': 'added', 'sort_order': 'desc'}, ) if not data: break releases = data.get('releases', []) or [] if not releases: break for entry in releases: info = entry.get('basic_information') or {} release_id = entry.get('id') or info.get('id') if not release_id: continue title = info.get('title') or '' # Discogs `artists` is a list of {name, id, ...}; first is primary. artists = info.get('artists') or [] artist_name = '' if artists and isinstance(artists[0], dict): artist_name = (artists[0].get('name') or '').strip() # Strip trailing "(N)" disambiguation suffix Discogs adds. artist_name = re.sub(r'\s*\(\d+\)$', '', artist_name) if not title or not artist_name: continue # Image URLs: cover_image is the primary, also has thumb. image_url = (info.get('cover_image') or info.get('thumb') or '') year = info.get('year') release_date = str(year) if year and year > 0 else '' results.append({ 'album_name': title.strip(), 'artist_name': artist_name, 'release_id': int(release_id), 'image_url': image_url or None, 'release_date': release_date, 'total_tracks': 0, # Not in basic_information; populated via get_release if needed }) pagination = data.get('pagination') or {} if page >= int(pagination.get('pages') or 1): break page += 1 logger.info(f"Discogs collection: fetched {len(results)} releases for {username}") return results def get_release(self, release_id: int) -> Optional[Dict[str, Any]]: """Fetch full Discogs release detail including tracklist. Returns the raw API response so callers can render rich Discogs context (year, format, label, country, tracklist). """ if not release_id: return None try: release_id = int(release_id) except (TypeError, ValueError): return None return self._api_get(f'/releases/{release_id}') # --- Search Methods (same signatures as iTunes/Deezer) --- def search_artists(self, query: str, limit: int = 10) -> List[Artist]: """Search for artists on Discogs.""" cache = get_metadata_cache() cached_results = cache.get_search_results('discogs', 'artist', query, limit) if cached_results is not None: artists = [] for raw in cached_results: try: artists.append(Artist.from_discogs_artist(raw)) except Exception as e: logger.debug("Artist.from_discogs_artist cache parse: %s", e) if artists: return artists data = self._api_get('/database/search', { 'q': query, 'type': 'artist', 'per_page': min(limit, 50), }) if not data or not data.get('results'): return [] artists = [] raw_items = [] for item in data['results'][:limit]: try: artists.append(Artist.from_discogs_artist(item)) raw_items.append(item) except Exception as e: logger.debug(f"Error parsing Discogs artist: {e}") if raw_items: entries = [(str(r.get('id', '')), r) for r in raw_items if r.get('id')] if entries: cache.store_entities_bulk('discogs', 'artist', entries) cache.store_search_results('discogs', 'artist', query, limit, [str(r.get('id', '')) for r in raw_items if r.get('id')]) return artists def search_albums(self, query: str, limit: int = 10) -> List[Album]: """Search for releases/albums on Discogs.""" cache = get_metadata_cache() cached_results = cache.get_search_results('discogs', 'album', query, limit) if cached_results is not None: albums = [] for raw in cached_results: try: albums.append(Album.from_discogs_release(raw)) except Exception as e: logger.debug("Album.from_discogs_release cache parse: %s", e) if albums: return albums data = self._api_get('/database/search', { 'q': query, 'type': 'release', 'per_page': min(limit, 50), }) if not data or not data.get('results'): return [] albums = [] raw_items = [] seen_titles = set() for item in data['results'][:limit * 2]: try: album = Album.from_discogs_release(item) dedup_key = f"{album.name.lower()}|{album.artists[0].lower() if album.artists else ''}" if dedup_key in seen_titles: continue seen_titles.add(dedup_key) albums.append(album) raw_items.append(item) if len(albums) >= limit: break except Exception as e: logger.debug(f"Error parsing Discogs release: {e}") if raw_items: entries = [(str(r.get('id', '')), r) for r in raw_items if r.get('id')] if entries: cache.store_entities_bulk('discogs', 'album', entries, skip_if_exists=True) cache.store_search_results('discogs', 'album', query, limit, [str(r.get('id', '')) for r in raw_items if r.get('id')]) return albums def search_tracks(self, query: str, limit: int = 10) -> List[Track]: """Search for tracks on Discogs. Discogs doesn't have a track-level search API — returns empty list. Track data is available via get_album() tracklists instead.""" # Discogs has no track search endpoint. Artists and albums are the # searchable entities. Individual tracks come from release tracklists. return [] # --- Lookup Methods --- def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: """Get artist details by Discogs ID.""" cache = get_metadata_cache() cached = cache.get_entity('discogs', 'artist', artist_id) if cached and cached.get('name'): # Rebuild normalized result from cached raw data data = cached else: data = self._api_get(f'/artists/{artist_id}') if not data: return None cache.store_entity('discogs', 'artist', artist_id, data) artist = Artist.from_discogs_artist(data) # Get profile/bio profile = data.get('profile', '') result = { 'id': artist.id, 'name': artist.name, 'image_url': artist.image_url, 'genres': [], 'popularity': 0, 'followers': 0, 'bio': profile, 'external_urls': artist.external_urls, 'images': [{'url': artist.image_url}] if artist.image_url else [], } return result def get_album(self, release_id: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]: """Get release/album details by Discogs ID. Tries master first, falls back to release.""" cache = get_metadata_cache() cached = cache.get_entity('discogs', 'album', release_id) if cached and cached.get('title'): data = cached else: # Try as master first (artist discography returns master IDs) data = self._api_get(f'/masters/{release_id}') if not data or not data.get('title'): data = self._api_get(f'/releases/{release_id}') if not data: return None cache.store_entity('discogs', 'album', release_id, data) album = Album.from_discogs_release(data) result = { 'id': album.id, 'name': album.name, 'artist': album.artists[0] if album.artists else '', 'artists': album.artists, 'release_date': album.release_date, 'total_tracks': album.total_tracks, 'album_type': album.album_type, 'image_url': album.image_url, 'images': [{'url': album.image_url}] if album.image_url else [], 'external_urls': album.external_urls, 'genres': data.get('genres', []), 'styles': data.get('styles', []), 'label': data.get('labels', [{}])[0].get('name', '') if data.get('labels') else '', 'catalog_number': data.get('labels', [{}])[0].get('catno', '') if data.get('labels') else '', } if include_tracks and data.get('tracklist'): result['tracks'] = { 'items': [self._tracklist_to_spotify_format(t, data) for t in data['tracklist'] if t.get('type_', '') == 'track' or not t.get('type_')] } return result def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]: """Get releases by an artist. Prefers master releases, filters features.""" # First get the artist name for feature filtering artist_data = self._api_get(f'/artists/{artist_id}') artist_name = artist_data.get('name', '').lower() if artist_data else '' data = self._api_get(f'/artists/{artist_id}/releases', { 'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200), }) if not data or not data.get('releases'): return [] # Separate masters from individual releases — prefer masters (canonical versions) masters = [] releases_no_master = [] master_titles = set() for item in data['releases']: # Skip non-main roles role = item.get('role', 'Main').lower() if role not in ('main', ''): continue # Filter out features — only include releases where this artist is the PRIMARY artist # "Beyoncé Feat. Kendrick Lamar" → primary is Beyoncé, skip # "Kendrick Lamar Feat. Rihanna" → primary is Kendrick, keep release_artist = item.get('artist', '') if artist_name and release_artist: # Get the primary artist (before any Feat./Ft./&) primary = re.split(r'\s+(?:feat\.?|ft\.?|featuring)\s+', release_artist, flags=re.IGNORECASE)[0] primary = re.split(r'\s*[&,]\s*', primary)[0].strip() # Check if our artist is the primary if self._normalize_name(primary) != self._normalize_name(artist_name): continue if item.get('type') == 'master': masters.append(item) master_titles.add(item.get('title', '').lower()) else: releases_no_master.append(item) # Use masters first, then add releases that don't have a master ordered = masters + [r for r in releases_no_master if r.get('title', '').lower() not in master_titles] albums = [] seen_titles = set() allowed_types = set(album_type.split(',')) for item in ordered: try: album = Album.from_discogs_release(item) # Use thumb from release list as image thumb = item.get('thumb') or item.get('cover_image') or '' if thumb and 'spacer.gif' not in thumb and not album.image_url: album = Album(id=album.id, name=album.name, artists=album.artists, release_date=album.release_date, total_tracks=album.total_tracks, album_type=album.album_type, image_url=thumb, external_urls=album.external_urls) # Deduplicate by normalized title (but keep deluxe/special editions as separate) dedup_key = album.name.lower().strip() if dedup_key in seen_titles: continue seen_titles.add(dedup_key) # Filter by requested type if album.album_type in allowed_types: albums.append(album) if len(albums) >= limit: break except Exception as e: logger.debug(f"Error parsing Discogs artist release: {e}") return albums def get_album_tracks(self, release_id: str) -> Optional[Dict[str, Any]]: """Get album tracks by Discogs release or master ID. Returns Spotify-compatible format.""" cache = get_metadata_cache() cache_key = f"{release_id}_tracks" cached = cache.get_entity('discogs', 'album', cache_key) if cached: return cached # Try as master first (master IDs are used in artist discography) data = self._api_get(f'/masters/{release_id}') if not data or not data.get('tracklist'): data = self._api_get(f'/releases/{release_id}') if not data or not data.get('tracklist'): return None # Get album image image_url = None images = data.get('images', []) if images: primary = next((img for img in images if img.get('type') == 'primary'), None) image_url = (primary or images[0]).get('uri') album_info = { 'id': str(data.get('id', release_id)), 'name': data.get('title', ''), 'images': [{'url': image_url, 'height': 600, 'width': 600}] if image_url else [], 'release_date': str(data.get('year', '')) if data.get('year') else '', } # Get artists artists_list = [] if data.get('artists'): artists_list = [{'name': a.get('name', '')} for a in data['artists'] if a.get('name')] if not artists_list: artists_list = [{'name': 'Unknown Artist'}] tracks = [] track_num = 0 disc_num = 1 for t in data['tracklist']: if t.get('type_') == 'heading': disc_num += 1 continue if t.get('type_', '') not in ('track', ''): continue track_num += 1 # Parse duration duration_ms = 0 dur_str = t.get('duration', '') if dur_str and ':' in dur_str: parts = dur_str.split(':') try: duration_ms = (int(parts[0]) * 60 + int(parts[1])) * 1000 except (ValueError, IndexError): pass # Per-track artists or fall back to release artists track_artists = artists_list if t.get('artists'): track_artists = [{'name': a.get('name', '')} for a in t['artists'] if a.get('name')] tracks.append({ 'id': f"{release_id}_t{track_num}", 'name': t.get('title', ''), 'artists': track_artists, 'album': album_info, 'duration_ms': duration_ms, 'track_number': track_num, 'disc_number': disc_num if disc_num > 1 else 1, 'explicit': False, 'uri': f"discogs:track:{release_id}_{track_num}", 'external_urls': {}, '_source': 'discogs', }) result = { 'items': tracks, 'total': len(tracks), 'limit': len(tracks), 'next': None, } cache.store_entity('discogs', 'album', cache_key, result) return result def _fetch_and_cache_artist(self, artist_id: str) -> Optional[Dict]: """Fetch raw artist data with cache. Used by enrichment worker.""" cache = get_metadata_cache() cached = cache.get_entity('discogs', 'artist', str(artist_id)) if cached and cached.get('name'): return cached data = self._api_get(f'/artists/{artist_id}') if data: cache.store_entity('discogs', 'artist', str(artist_id), data) return data def _fetch_and_cache_album(self, release_id: str) -> Optional[Dict]: """Fetch raw album/release data with cache. Used by enrichment worker.""" cache = get_metadata_cache() cached = cache.get_entity('discogs', 'album', str(release_id)) if cached and cached.get('title'): return cached data = self._api_get(f'/masters/{release_id}') if not data or not data.get('title'): data = self._api_get(f'/releases/{release_id}') if data: cache.store_entity('discogs', 'album', str(release_id), data) return data def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]: """Get artist image by fetching their first album's cover art. Used as fallback when artist has no direct image.""" data = self._api_get(f'/artists/{artist_id}/releases', { 'sort': 'year', 'sort_order': 'desc', 'per_page': 5, }) if not data or not data.get('releases'): return None for release in data['releases']: thumb = release.get('thumb') if thumb and 'spacer.gif' not in thumb: return thumb return None # --- Helpers --- def _tracklist_to_spotify_format(self, track_data: Dict, release_data: Dict) -> Dict: """Convert a Discogs tracklist entry to Spotify-compatible track dict.""" t = Track.from_discogs_track(track_data, release_data) return { 'id': t.id, 'name': t.name, 'artists': [{'name': a} for a in t.artists], 'track_number': t.track_number, 'disc_number': t.disc_number, 'duration_ms': t.duration_ms, }