diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b153415..b6ae93d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -26,4 +26,4 @@ jobs:
- run: poetry env use python
- run: poetry install
# Offline tests only, the remaining tests require a TIDAL account
- - run: poetry run pytest tests/test_backoff.py tests/test_parsing.py tests/test_token_refresh.py
+ - run: poetry run pytest tests/test_backoff.py tests/test_parsing.py tests/test_token_refresh.py tests/test_playlog.py
diff --git a/HISTORY.rst b/HISTORY.rst
index 70399b7..8fe0146 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -5,6 +5,7 @@ History
v0.9.0
------
+* Added `Session.report_playback` to report plays to TIDAL, so they show up in Recently played on all devices and count for recommendations (Fixes #401) - Petru101_
* (BREAKING!) Drop support for python3.9 and older, python 3.10 or newer is now required - tehkillerbee_
* Added support for python 3.14 - tehkillerbee_
* Removed unused runtime dependency `pyaes` - tehkillerbee_
@@ -323,4 +324,5 @@ v0.6.2
.. _nilathedragon: https://github.com/nilathedragon
.. _semohr: https://github.com/semohr
.. _rafrombrc: https://github.com/rafrombrc
+.. _Petru101: https://github.com/Petru101
diff --git a/docs/api.rst b/docs/api.rst
index 317b439..01d404c 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -167,3 +167,22 @@ API Reference
.. autoclass:: Mix
:members:
:undoc-members:
+
+`tidalapi.playlog`
+------------------
+
+.. automodule:: tidalapi.playlog
+
+.. autoclass:: PlaybackSession
+ :members:
+
+.. autoclass:: PlaybackAction
+ :members:
+
+.. autoclass:: SourceType
+ :members:
+ :undoc-members:
+
+.. autoclass:: ActionType
+ :members:
+ :undoc-members:
diff --git a/tests/test_playlog.py b/tests/test_playlog.py
new file mode 100644
index 0000000..cb05a30
--- /dev/null
+++ b/tests/test_playlog.py
@@ -0,0 +1,236 @@
+# Copyright (C) 2023- The Tidalapi Developers
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program. If not, see .
+"""Offline tests for reporting plays (no TIDAL account needed, nothing is sent)."""
+
+import base64
+import json
+import platform
+from unittest import mock
+
+import pytest
+import requests
+
+import tidalapi
+from tidalapi.exceptions import TidalAPIError
+from tidalapi.playlog import (
+ ActionType,
+ PlaybackAction,
+ PlaybackSession,
+ SourceType,
+ get_token_claims,
+)
+
+ACCEPTED = (
+ ''
+ "x"
+ ""
+)
+REJECTED = (
+ ""
+ "true"
+ ""
+)
+
+
+def make_token(claims):
+ payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode()
+ return f"header.{payload.rstrip('=')}.signature"
+
+
+def fake_response(status_code=200, text=ACCEPTED):
+ response = requests.Response()
+ response.status_code = status_code
+ response._content = text.encode()
+ response.request = requests.Request(
+ "POST", "https://ec.tidal.com/api/event-batch"
+ ).prepare()
+ return response
+
+
+def make_session():
+ session = tidalapi.Session(tidalapi.Config())
+ session.token_type = "Bearer"
+ session.access_token = make_token({"uid": 123, "cid": 8017, "sid": "s-1"})
+ session.refresh_token = "refresh"
+ session.is_pkce = True
+ session.request_session = mock.Mock()
+ session.request_session.request.return_value = fake_response()
+ return session
+
+
+def make_play(track_id=1, **kwargs):
+ return PlaybackSession(
+ track_id=track_id,
+ start_timestamp=1_000,
+ end_timestamp=201_000,
+ end_position=200.0,
+ **kwargs,
+ )
+
+
+def sent_forms(session):
+ return [c.kwargs["data"] for c in session.request_session.request.call_args_list]
+
+
+def entries(form):
+ """The (body, headers, name) of each event in a SendMessageBatch form."""
+ events = []
+ index = 1
+ while f"SendMessageBatchRequestEntry.{index}.Id" in form:
+ entry = f"SendMessageBatchRequestEntry.{index}."
+ events.append(
+ (
+ json.loads(form[entry + "MessageBody"]),
+ json.loads(form[entry + "MessageAttribute.2.Value.StringValue"]),
+ form[entry + "MessageAttribute.1.Value.StringValue"],
+ )
+ )
+ index += 1
+ return events
+
+
+def test_payload():
+ play = make_play(
+ source_type=SourceType.album,
+ source_id=55,
+ actions=[
+ PlaybackAction(50_000, 49.5, ActionType.playback_stop),
+ PlaybackAction(60_000, 49.5, ActionType.playback_start),
+ ],
+ quality=tidalapi.Quality.hi_res_lossless,
+ )
+ payload = play.to_json()
+
+ assert payload["requestedProductId"] == "1"
+ assert payload["actualProductId"] == "1"
+ assert payload["productType"] == "TRACK"
+ assert payload["sourceType"] == "ALBUM"
+ assert payload["sourceId"] == "55"
+ assert payload["actualQuality"] == "HI_RES_LOSSLESS"
+ assert payload["actualAudioMode"] == "STEREO"
+ assert payload["startTimestamp"] == 1_000
+ assert payload["endTimestamp"] == 201_000
+ assert payload["startAssetPosition"] == 0.0
+ assert payload["endAssetPosition"] == 200.0
+ assert payload["actions"] == [
+ {"timestamp": 50_000, "assetPosition": 49.5, "actionType": "PLAYBACK_STOP"},
+ {"timestamp": 60_000, "assetPosition": 49.5, "actionType": "PLAYBACK_START"},
+ ]
+ assert payload["playbackSessionId"]
+
+
+def test_track_played_on_its_own_uses_its_own_id_as_source():
+ payload = make_play(track_id=7).to_json()
+ assert payload["sourceType"] == "ITEM"
+ assert payload["sourceId"] == "7"
+
+
+def test_token_claims():
+ assert get_token_claims(make_token({"uid": 1, "cid": 2})) == {"uid": 1, "cid": 2}
+ assert get_token_claims("not a token") == {}
+ assert get_token_claims("a.%%%.b") == {}
+
+
+def test_report_sends_one_playback_session_event_per_play():
+ session = make_session()
+
+ assert session.report_playback([make_play(1), make_play(2)], app_version="App 1.0")
+
+ call = session.request_session.request.call_args
+ assert call.args == ("POST", "https://ec.tidal.com/api/event-batch")
+ assert call.kwargs["headers"]["authorization"] == "Bearer " + session.access_token
+
+ events = entries(call.kwargs["data"])
+ assert [body["payload"]["requestedProductId"] for body, _, _ in events] == [
+ "1",
+ "2",
+ ]
+ body, headers, name = events[0]
+ assert name == "playback_session"
+ assert body["group"] == "play_log"
+ assert body["version"] == 2
+ assert body["user"] == {"id": 123, "clientId": 8017, "sessionId": "s-1"}
+ assert body["client"] == {
+ "token": "8017",
+ "deviceType": "desktop",
+ "version": "App 1.0",
+ "platform": platform.system().lower(),
+ }
+ assert headers["app-version"] == "App 1.0"
+ assert headers["os-name"] == platform.system()
+ assert headers["client-id"] == session.config.client_id_pkce
+ assert headers["authorization"] == session.access_token
+ assert headers["consent-category"] == "NECESSARY"
+
+
+def test_events_describe_the_real_client():
+ session = make_session()
+ session.report_playback([make_play()])
+
+ body, headers, _ = entries(sent_forms(session)[0])[0]
+ assert "device-model" not in headers
+ assert "device-vendor" not in headers
+ assert body["client"]["platform"] != "android"
+ assert headers["app-version"] == f"tidalapi {tidalapi.__version__}"
+
+
+def test_report_sends_at_most_ten_events_per_request():
+ session = make_session()
+
+ assert session.report_playback([make_play(i) for i in range(23)])
+
+ assert [len(entries(form)) for form in sent_forms(session)] == [10, 10, 3]
+
+
+def test_rejected_plays_return_false():
+ session = make_session()
+ session.request_session.request.return_value = fake_response(text=REJECTED)
+ assert not session.report_playback([make_play()])
+
+ session.request_session.request.return_value = fake_response(400, "bad request")
+ assert not session.report_playback([make_play()])
+
+
+def test_server_errors_raise_so_plays_can_be_sent_later():
+ session = make_session()
+ session.request_session.request.return_value = fake_response(503, "unavailable")
+
+ with pytest.raises((requests.HTTPError, TidalAPIError)):
+ session.report_playback([make_play()])
+
+
+def test_expired_token_is_refreshed_and_the_request_retried_once():
+ session = make_session()
+ old_token = session.access_token
+ new_token = make_token({"uid": 123, "cid": 8017, "sid": "s-2"})
+ session.request_session.request.side_effect = [
+ fake_response(401, '{"status": 401}'),
+ fake_response(),
+ ]
+
+ def token_refresh(refresh_token):
+ session.access_token = new_token
+ return True
+
+ with mock.patch.object(session, "token_refresh", side_effect=token_refresh):
+ assert session.report_playback([make_play()])
+
+ calls = session.request_session.request.call_args_list
+ assert len(calls) == 2
+ assert calls[0].kwargs["headers"]["authorization"] == "Bearer " + old_token
+ assert calls[1].kwargs["headers"]["authorization"] == "Bearer " + new_token
+ body, headers, _ = entries(calls[1].kwargs["data"])[0]
+ assert headers["authorization"] == new_token
+ assert body["user"]["sessionId"] == "s-2"
diff --git a/tidalapi/__init__.py b/tidalapi/__init__.py
index 6603e01..6d45691 100644
--- a/tidalapi/__init__.py
+++ b/tidalapi/__init__.py
@@ -7,6 +7,12 @@
from .mix import Mix, MixV2 # noqa: F401
from .page import Page # noqa: F401
from .playlist import Playlist, UserPlaylist # noqa: F401
+from .playlog import ( # noqa: F401
+ ActionType,
+ PlaybackAction,
+ PlaybackSession,
+ SourceType,
+)
from .request import Requests # noqa: F401
from .session import Config, Session # noqa: F401
from .user import ( # noqa: F401
diff --git a/tidalapi/playlog.py b/tidalapi/playlog.py
new file mode 100644
index 0000000..58667cf
--- /dev/null
+++ b/tidalapi/playlog.py
@@ -0,0 +1,301 @@
+# Copyright (C) 2023- The Tidalapi Developers
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program. If not, see .
+"""Reporting plays to TIDAL (the play log).
+
+Fetching and playing a stream doesn't count as a play: the official TIDAL players report
+every play as a ``playback_session`` event to TIDAL's event producer. Reported plays
+show up in Recently played on all devices and in the listening history TIDAL uses for
+recommendations.
+
+The event layout follows TIDAL's open source SDK (
+
+https://github.com/tidal-music/tidal-sdk-android,
+modules ``eventproducer`` and
+``player/events``). The events describe the real client (the operating system and
+the application version), they don't pretend to come from an official TIDAL app.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import platform
+import time
+import uuid
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import TYPE_CHECKING, Iterable, List, Optional, Union
+
+import requests
+
+from tidalapi import media
+from tidalapi.exceptions import AuthenticationError, http_error_to_tidal_error
+from tidalapi.types import JsonObj
+
+if TYPE_CHECKING:
+ from tidalapi.session import Session
+
+log = logging.getLogger(__name__)
+
+#: The most events TIDAL's event producer accepts in one request
+MAX_BATCH_SIZE = 10
+
+
+class SourceType(str, Enum):
+ """What a track was played from.
+
+ A track played on its own (from search, a list of tracks, the play queue) is an
+ ``ITEM`` with the track's own id as source id.
+ """
+
+ album = "ALBUM"
+ playlist = "PLAYLIST"
+ mix = "MIX"
+ artist = "ARTIST"
+ item = "ITEM"
+
+ def __str__(self) -> str:
+ return self.value
+
+
+class ActionType(str, Enum):
+ """A change during a play: playback stopped (paused) or started (resumed)."""
+
+ playback_start = "PLAYBACK_START"
+ playback_stop = "PLAYBACK_STOP"
+
+ def __str__(self) -> str:
+ return self.value
+
+
+@dataclass
+class PlaybackAction:
+ """A pause, resume or seek during a play.
+
+ A pause is a ``PLAYBACK_STOP``, resuming is a ``PLAYBACK_START``. A seek is a
+ ``PLAYBACK_STOP`` at the old position followed by a ``PLAYBACK_START`` at the new
+ one.
+
+ :param timestamp: When it happened, in milliseconds since the epoch
+ :param position: The position in the track, in seconds
+ :param action_type: :class:`ActionType`
+ """
+
+ timestamp: int
+ position: float
+ action_type: ActionType
+
+ def to_json(self) -> JsonObj:
+ return {
+ "timestamp": self.timestamp,
+ "assetPosition": self.position,
+ "actionType": str(self.action_type),
+ }
+
+
+@dataclass
+class PlaybackSession:
+ """One play of a track, from the moment it started to the moment it stopped.
+
+ TIDAL counts a play after 30 seconds of listening, so shorter plays don't need to be
+ reported.
+
+ :param track_id: The id of the played track
+ :param start_timestamp: When the play started, in milliseconds since the epoch
+ :param end_timestamp: When the play ended, in milliseconds since the epoch
+ :param end_position: The position in the track when the play ended, in seconds
+ :param start_position: The position in the track when the play started, in seconds
+ :param source_type: What the track was played from, see :class:`SourceType`
+ :param source_id: The id of the album, playlist, mix or artist the track was played
+ from. Defaults to the track id, for a track played on its own
+ :param actions: Pauses, resumes and seeks during the play, see
+ :class:`PlaybackAction`
+ :param quality: The audio quality that was played, see :class:`.media.Quality`
+ :param audio_mode: The audio mode that was played, see :class:`.media.AudioMode`
+ :param playback_session_id: A unique id for this play. Generated if not given
+ """
+
+ track_id: Union[int, str]
+ start_timestamp: int
+ end_timestamp: int
+ end_position: float
+ start_position: float = 0.0
+ source_type: SourceType = SourceType.item
+ source_id: Optional[Union[int, str]] = None
+ actions: List[PlaybackAction] = field(default_factory=list)
+ quality: str = media.Quality.high_lossless
+ audio_mode: str = media.AudioMode.stereo
+ playback_session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
+
+ def to_json(self) -> JsonObj:
+ """The event payload, as TIDAL's players send it."""
+ source_id = self.source_id if self.source_id is not None else self.track_id
+ return {
+ "playbackSessionId": self.playback_session_id,
+ "startTimestamp": self.start_timestamp,
+ "startAssetPosition": self.start_position,
+ "isPostPaywall": True,
+ "productType": "TRACK",
+ "requestedProductId": str(self.track_id),
+ "actualProductId": str(self.track_id),
+ "actualAssetPresentation": "FULL",
+ "actualAudioMode": str(self.audio_mode),
+ "actualQuality": str(self.quality),
+ "sourceType": str(self.source_type),
+ "sourceId": str(source_id),
+ "actions": [action.to_json() for action in self.actions],
+ "endTimestamp": self.end_timestamp,
+ "endAssetPosition": self.end_position,
+ }
+
+
+def get_token_claims(access_token: str) -> JsonObj:
+ """The claims of an access token, without verifying it.
+
+ :param access_token: A TIDAL access token (a JWT)
+ :return: The claims, or an empty dict if the token can't be decoded
+ """
+ try:
+ payload = access_token.split(".")[1]
+ payload += "=" * (-len(payload) % 4)
+ return json.loads(base64.urlsafe_b64decode(payload))
+ except (IndexError, ValueError):
+ return {}
+
+
+class PlayLog:
+ """Reports plays to TIDAL, see :meth:`.Session.report_playback`."""
+
+ def __init__(self, session: Session):
+ self.session = session
+
+ def report(
+ self, playback_sessions: Iterable[PlaybackSession], app_version: str
+ ) -> bool:
+ """Report plays to TIDAL.
+
+ :param playback_sessions: The plays to report
+ :param app_version: The version of the application that played them
+ :return: True if TIDAL accepted the plays, False if it rejected them. Rejected
+ plays would be rejected again, so they shouldn't be sent again.
+ :raises: :class:`requests.RequestException` or a :class:`.TidalAPIError` if the
+ plays couldn't be sent (e.g. offline, server errors). They can be sent again
+ later.
+ """
+ playback_sessions = list(playback_sessions)
+ accepted = True
+ for start in range(0, len(playback_sessions), MAX_BATCH_SIZE):
+ batch = playback_sessions[start : start + MAX_BATCH_SIZE]
+ accepted = self._post(batch, app_version) and accepted
+ return accepted
+
+ def _post(self, playback_sessions: List[PlaybackSession], app_version: str) -> bool:
+ requests_ = self.session.request
+ if requests_._access_token_expired():
+ try:
+ requests_._refresh_access_token(self.session.access_token)
+ except AuthenticationError as e:
+ log.warning("Access token refresh failed: %s", e)
+
+ sent_access_token = self.session.access_token
+ response = self._send(playback_sessions, app_version)
+ if response.status_code == 401 and requests_._refresh_access_token(
+ sent_access_token
+ ):
+ response = self._send(playback_sessions, app_version)
+
+ if response.status_code in (401, 403) or response.status_code >= 500:
+ try:
+ response.raise_for_status()
+ except requests.HTTPError as e:
+ if err := http_error_to_tidal_error(e):
+ raise err from e
+ raise
+
+ if not response.ok or "BatchResultErrorEntry" in response.text:
+ log.warning("TIDAL rejected the reported plays: %s", response.text[:500])
+ return False
+ return True
+
+ def _send(
+ self, playback_sessions: List[PlaybackSession], app_version: str
+ ) -> requests.Response:
+ form = self.get_form(playback_sessions, app_version)
+ return self.session.request._send(
+ "POST", self.session.config.event_producer_location, {}, form, {}
+ )
+
+ def get_form(
+ self, playback_sessions: List[PlaybackSession], app_version: str
+ ) -> dict:
+ """The request body for a batch of plays: an SQS ``SendMessageBatch`` form, with
+ one ``playback_session`` event per play.
+
+ :param playback_sessions: At most :data:`MAX_BATCH_SIZE` plays
+ :param app_version: The version of the application that played them
+ :return: The form fields
+ """
+ access_token = self.session.access_token or ""
+ claims = get_token_claims(access_token)
+ os_name = platform.system()
+ client_id = (
+ self.session.config.client_id_pkce
+ if self.session.is_pkce
+ else self.session.config.client_id
+ )
+
+ sent_timestamp = str(int(time.time() * 1000))
+
+ form = {}
+ for index, playback_session in enumerate(playback_sessions, 1):
+ event_id = str(uuid.uuid4())
+ body = {
+ "group": "play_log",
+ "version": 2,
+ "ts": playback_session.end_timestamp,
+ "uuid": event_id,
+ "user": {
+ "id": claims.get("uid"),
+ "clientId": claims.get("cid"),
+ "sessionId": claims.get("sid"),
+ },
+ "client": {
+ "token": str(claims.get("cid", "")),
+ "deviceType": "desktop",
+ "version": app_version,
+ "platform": os_name.lower(),
+ },
+ "payload": playback_session.to_json(),
+ }
+ headers = {
+ "app-version": app_version,
+ "os-name": os_name,
+ "os-version": platform.release(),
+ "consent-category": "NECESSARY",
+ "requested-sent-timestamp": sent_timestamp,
+ "authorization": access_token,
+ "client-id": client_id,
+ }
+ entry = f"SendMessageBatchRequestEntry.{index}."
+ form[entry + "Id"] = event_id
+ form[entry + "MessageBody"] = json.dumps(body)
+ form[entry + "MessageAttribute.1.Name"] = "Name"
+ form[entry + "MessageAttribute.1.Value.StringValue"] = "playback_session"
+ form[entry + "MessageAttribute.1.Value.DataType"] = "String"
+ form[entry + "MessageAttribute.2.Name"] = "Headers"
+ form[entry + "MessageAttribute.2.Value.StringValue"] = json.dumps(headers)
+ form[entry + "MessageAttribute.2.Value.DataType"] = "String"
+ return form
diff --git a/tidalapi/session.py b/tidalapi/session.py
index 2b364c0..749b70f 100644
--- a/tidalapi/session.py
+++ b/tidalapi/session.py
@@ -34,6 +34,7 @@
TYPE_CHECKING,
Any,
Callable,
+ Iterable,
List,
Literal,
Optional,
@@ -56,7 +57,7 @@
)
from tidalapi.types import JsonObj
-from . import album, artist, genre, media, mix, page, playlist, request, user
+from . import album, artist, genre, media, mix, page, playlist, playlog, request, user
if TYPE_CHECKING:
from tidalapi.user import FetchedUser, LoggedInUser, PlaylistCreator
@@ -116,6 +117,8 @@ class Config:
api_v1_location: str = "https://api.tidal.com/v1/"
api_v2_location: str = "https://api.tidal.com/v2/"
openapi_v2_location: str = "https://openapi.tidal.com/v2/"
+ #: Where plays are reported, see :meth:`Session.report_playback`
+ event_producer_location: str = "https://ec.tidal.com/api/event-batch"
client_id: str
client_secret: str
image_url: str = "https://resources.tidal.com/images/%s/%ix%i.jpg"
@@ -247,6 +250,7 @@ def __init__(self, config: Optional[Config] = None):
# Objects for keeping the session across all modules.
self.request = request.Requests(session=self)
+ self.playlog = playlog.PlayLog(session=self)
self.genre = genre.Genre(session=self)
self.parse_user = user.User(self, None).parse
@@ -886,6 +890,49 @@ def search(
return result
+ def report_playback(
+ self,
+ playback_sessions: Iterable[playlog.PlaybackSession],
+ app_version: Optional[str] = None,
+ ) -> bool:
+ """Report plays to TIDAL, so they show up in Recently played on all devices and
+ count for recommendations.
+
+ Fetching and playing a stream doesn't count as a play on its own. Report each
+ play of a track once it ends (e.g. when the next track starts), if it lasted at
+ least 30 seconds, as TIDAL counts plays from then on.
+
+ Example::
+
+ session.report_playback(
+ [
+ PlaybackSession(
+ track_id=track.id,
+ start_timestamp=started_at_ms,
+ end_timestamp=ended_at_ms,
+ end_position=position_seconds,
+ source_type=SourceType.album,
+ source_id=track.album.id,
+ )
+ ],
+ app_version="My Player 1.0",
+ )
+
+ :param playback_sessions: The plays to report, see
+ :class:`~tidalapi.playlog.PlaybackSession`
+ :param app_version: The name and version of the application that played them.
+ Defaults to the tidalapi version
+ :return: True if TIDAL accepted the plays, False if it rejected them (they
+ would be rejected again, so they shouldn't be sent again)
+ :raises: :class:`requests.RequestException` or a :class:`.TidalAPIError` if
+ the plays couldn't be sent (e.g. offline). They can be sent again later.
+ """
+ if app_version is None:
+ from tidalapi import __version__
+
+ app_version = f"tidalapi {__version__}"
+ return self.playlog.report(playback_sessions, app_version)
+
def check_login(self) -> bool:
"""Returns true if current session is valid, false otherwise."""
if self.user is None or not self.user.id or not self.session_id: