chromium/ash/system/focus_mode/sounds/focus_mode_youtube_music_delegate.cc

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "ash/system/focus_mode/sounds/focus_mode_youtube_music_delegate.h"

#include <cstddef>
#include <optional>
#include <string>
#include <utility>
#include <vector>

#include "ash/system/focus_mode/focus_mode_controller.h"
#include "ash/system/focus_mode/focus_mode_retry_util.h"
#include "ash/system/focus_mode/sounds/youtube_music/youtube_music_types.h"
#include "base/check.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/functional/callback_helpers.h"
#include "base/strings/stringprintf.h"
#include "base/task/sequenced_task_runner.h"
#include "base/time/time.h"
#include "google_apis/common/api_error_codes.h"
#include "url/gurl.h"

namespace ash {

namespace {

constexpr size_t kPlaylistNum = 4;
constexpr char kFocusSupermixPlaylistId[] =
    "playlists/RDTMAK5uy_l3TXw3uC_sIHl4m6RMGqCyKKd2D2_pv28";
constexpr char kYouTubeMusicSourceFormat[] = "YouTube Music ᐧ %s";
constexpr char kYouTubeMusicTrackNotExplicit[] = "EXPLICIT_TYPE_NOT_EXPLICIT";

}  // namespace

FocusModeYouTubeMusicDelegate::FocusModeYouTubeMusicDelegate() {
  youtube_music_controller_ =
      std::make_unique<youtube_music::YouTubeMusicController>();
}

FocusModeYouTubeMusicDelegate::~FocusModeYouTubeMusicDelegate() = default;

void FocusModeYouTubeMusicDelegate::GetNextTrack(
    const std::string& playlist_id,
    FocusModeSoundsDelegate::TrackCallback callback) {
  CHECK(callback);
  next_track_state_.retry_state.Reset();
  next_track_state_.ResetDoneCallback();
  next_track_state_.done_callback = std::move(callback);

  GetNextTrackInternal(playlist_id);
}

void FocusModeYouTubeMusicDelegate::GetPlaylists(
    FocusModeSoundsDelegate::PlaylistsCallback callback) {
  CHECK(callback);
  get_playlists_state_.Reset();

  // Cache the done callback, add focus supermix/reserved playlist to the to-do
  // list, and update the total number of API request to run.
  get_playlists_state_.done_callback = std::move(callback);
  if (get_playlists_state_.reserved_playlist_id) {
    get_playlists_state_
        .playlists_to_query[get_playlists_state_.reserved_playlist_id.value()] =
        1;
  }
  get_playlists_state_.playlists_to_query[kFocusSupermixPlaylistId] = 0;
  get_playlists_state_.target_count =
      get_playlists_state_.playlists_to_query.size() + 1;

  // Invoke the API requests.
  for (const auto& [playlist_id, playlist_bucket] :
       get_playlists_state_.playlists_to_query) {
    youtube_music_controller_->GetPlaylist(
        playlist_id,
        base::BindOnce(&FocusModeYouTubeMusicDelegate::OnGetPlaylistDone,
                       weak_factory_.GetWeakPtr(), playlist_bucket));
  }
  youtube_music_controller_->GetMusicSection(
      base::BindOnce(&FocusModeYouTubeMusicDelegate::OnGetMusicSectionDone,
                     weak_factory_.GetWeakPtr(), /*bucket=*/2));
}

bool FocusModeYouTubeMusicDelegate::ReportPlayback(
    const youtube_music::PlaybackData& playback_data) {
  // Check for token and see if it has sufficient data for the reporting
  // request.
  if (report_playback_state_.url_to_token.find(playback_data.url) ==
      report_playback_state_.url_to_token.end()) {
    return false;
  }

  report_playback_state_.url_to_playback_state.insert(
      {playback_data.url, playback_data.state});
  const std::string& playback_reporting_token =
      report_playback_state_.url_to_token[playback_data.url];

  return youtube_music_controller_->ReportPlayback(
      playback_reporting_token, playback_data,
      base::BindOnce(&FocusModeYouTubeMusicDelegate::OnReportPlaybackDone,
                     weak_factory_.GetWeakPtr(), playback_data.url));
}

void FocusModeYouTubeMusicDelegate::SetNoPremiumCallback(
    base::RepeatingClosure callback) {
  CHECK(callback);
  no_premium_callback_ = std::move(callback);
}

void FocusModeYouTubeMusicDelegate::ReservePlaylistForGetPlaylists(
    const std::string& playlist_id) {
  get_playlists_state_.reserved_playlist_id = playlist_id;
}

FocusModeYouTubeMusicDelegate::GetPlaylistsRequestState::
    GetPlaylistsRequestState() = default;
FocusModeYouTubeMusicDelegate::GetPlaylistsRequestState::
    ~GetPlaylistsRequestState() = default;

void FocusModeYouTubeMusicDelegate::GetPlaylistsRequestState::Reset() {
  for (auto& playlist_bucket : playlist_buckets) {
    playlist_bucket.clear();
  }
  playlists_to_query.clear();
  target_count = 0;
  count = 0;
  ResetDoneCallback();
}

void FocusModeYouTubeMusicDelegate::GetPlaylistsRequestState::
    ResetDoneCallback() {
  if (done_callback) {
    std::move(done_callback).Run({});
  }
  done_callback = base::NullCallback();
}

std::vector<FocusModeSoundsDelegate::Playlist>
FocusModeYouTubeMusicDelegate::GetPlaylistsRequestState::GetTopPlaylists() {
  std::vector<Playlist> results;
  results.reserve(kPlaylistNum);
  for (auto& playlist_bucket : playlist_buckets) {
    for (size_t i = 0;
         i < playlist_bucket.size() && results.size() < kPlaylistNum; i++) {
      // Skip the duplicate.
      if (base::ranges::find(results, playlist_bucket[i].id, &Playlist::id) !=
          results.end()) {
        continue;
      }
      results.emplace_back(playlist_bucket[i]);
    }
  }
  CHECK_EQ(results.size(), kPlaylistNum);
  return results;
}

FocusModeYouTubeMusicDelegate::GetNextTrackRequestState::
    GetNextTrackRequestState() = default;
FocusModeYouTubeMusicDelegate::GetNextTrackRequestState::
    ~GetNextTrackRequestState() = default;

void FocusModeYouTubeMusicDelegate::GetNextTrackRequestState::Reset() {
  last_playlist_id = std::string();
  last_queue_id = std::string();
  ResetDoneCallback();
  retry_state.Reset();
}

void FocusModeYouTubeMusicDelegate::GetNextTrackRequestState::
    ResetDoneCallback() {
  if (done_callback) {
    std::move(done_callback).Run(std::nullopt);
  }
  done_callback = base::NullCallback();
}

FocusModeYouTubeMusicDelegate::ReportPlaybackRequestState::
    ReportPlaybackRequestState() = default;
FocusModeYouTubeMusicDelegate::ReportPlaybackRequestState::
    ~ReportPlaybackRequestState() = default;

bool FocusModeYouTubeMusicDelegate::ReportPlaybackRequestState::
    CanReportPlaybackForUrl(const GURL& url) {
  return url_to_playback_state.find(url) != url_to_playback_state.end() &&
         url_to_token.find(url) != url_to_token.end();
}

void FocusModeYouTubeMusicDelegate::OnGetPlaylistDone(
    size_t bucket,
    google_apis::ApiErrorCode http_error_code,
    std::optional<youtube_music::Playlist> playlist) {
  if (http_error_code != google_apis::ApiErrorCode::HTTP_SUCCESS) {
    get_playlists_state_.Reset();
    if (http_error_code == google_apis::ApiErrorCode::HTTP_FORBIDDEN &&
        no_premium_callback_) {
      no_premium_callback_.Run();
    }
    // TODO(b/354240276): Add more error handling and retries.
    return;
  }

  if (!get_playlists_state_.done_callback) {
    return;
  }

  CHECK_LT(bucket, kYouTubeMusicPlaylistBucketCount);

  if (playlist.has_value()) {
    get_playlists_state_.playlist_buckets[bucket].emplace_back(
        playlist.value().name, playlist.value().title,
        playlist.value().image.url);
  }

  get_playlists_state_.count++;
  if (get_playlists_state_.count == get_playlists_state_.target_count) {
    const std::vector<Playlist>& results =
        get_playlists_state_.GetTopPlaylists();
    CHECK_GE(results.size(), kPlaylistNum);
    std::move(get_playlists_state_.done_callback).Run(results);
    get_playlists_state_.done_callback = base::NullCallback();
  }
}

void FocusModeYouTubeMusicDelegate::OnGetMusicSectionDone(
    size_t bucket,
    google_apis::ApiErrorCode http_error_code,
    std::optional<const std::vector<youtube_music::Playlist>> playlists) {
  if (http_error_code != google_apis::ApiErrorCode::HTTP_SUCCESS) {
    get_playlists_state_.Reset();
    if (http_error_code == google_apis::ApiErrorCode::HTTP_FORBIDDEN &&
        no_premium_callback_) {
      no_premium_callback_.Run();
    }
    // TODO(b/354240276): Add more error handling and retries.
    return;
  }

  if (!get_playlists_state_.done_callback) {
    return;
  }

  CHECK_LT(bucket, kYouTubeMusicPlaylistBucketCount);

  if (playlists.has_value()) {
    for (const auto& playlist : playlists.value()) {
      get_playlists_state_.playlist_buckets[bucket].emplace_back(
          playlist.name, playlist.title, playlist.image.url);
    }
  }

  get_playlists_state_.count++;
  if (get_playlists_state_.count == get_playlists_state_.target_count) {
    const std::vector<Playlist>& results =
        get_playlists_state_.GetTopPlaylists();
    CHECK_GE(results.size(), kPlaylistNum);
    std::move(get_playlists_state_.done_callback).Run(results);
    get_playlists_state_.done_callback = base::NullCallback();
  }
}

void FocusModeYouTubeMusicDelegate::GetNextTrackInternal(
    const std::string& playlist_id) {
  if (next_track_state_.last_playlist_id != playlist_id) {
    youtube_music_controller_->PlaybackQueuePrepare(
        playlist_id,
        base::BindOnce(&FocusModeYouTubeMusicDelegate::OnNextTrackDone,
                       weak_factory_.GetWeakPtr(), playlist_id));
  } else {
    youtube_music_controller_->PlaybackQueueNext(
        next_track_state_.last_queue_id,
        base::BindOnce(&FocusModeYouTubeMusicDelegate::OnNextTrackDone,
                       weak_factory_.GetWeakPtr(), playlist_id));
  }
}

void FocusModeYouTubeMusicDelegate::OnNextTrackDone(
    const std::string& playlist_id,
    google_apis::ApiErrorCode http_error_code,
    std::optional<const youtube_music::PlaybackContext> playback_context) {
  if (!next_track_state_.done_callback) {
    return;
  }

  if (http_error_code != google_apis::ApiErrorCode::HTTP_SUCCESS) {
    // Handle forbidden error. No need to retry.
    if (http_error_code == google_apis::ApiErrorCode::HTTP_FORBIDDEN) {
      // Notify UI about no premium subscription.
      if (no_premium_callback_) {
        no_premium_callback_.Run();
      }

      // Bail gracefully.
      std::move(next_track_state_.done_callback).Run(std::nullopt);
      next_track_state_.Reset();
      return;
    }

    // Handle too many request error.
    if (http_error_code == 429) {
      // Retry if needed.
      if (next_track_state_.retry_state.retry_index <
          kMaxRetryTooManyRequests) {
        next_track_state_.retry_state.retry_index++;
        next_track_state_.retry_state.timer.Start(
            FROM_HERE, kWaitTimeTooManyRequests,
            base::BindOnce(&FocusModeYouTubeMusicDelegate::GetNextTrackInternal,
                           weak_factory_.GetWeakPtr(), playlist_id));
        return;
      }

      // Max number of retries reached. Bail gracefully.
      std::move(next_track_state_.done_callback).Run(std::nullopt);
      next_track_state_.Reset();
      return;
    }

    // Handle general HTTP errors.
    if (ShouldRetryHttpError(http_error_code)) {
      // Retry if needed.
      if (next_track_state_.retry_state.retry_index < kMaxRetryOverall) {
        next_track_state_.retry_state.retry_index++;
        next_track_state_.retry_state.timer.Start(
            FROM_HERE,
            GetExponentialBackoffRetryWaitTime(
                next_track_state_.retry_state.retry_index),
            base::BindOnce(&FocusModeYouTubeMusicDelegate::GetNextTrackInternal,
                           weak_factory_.GetWeakPtr(), playlist_id));
        return;
      }

      // Max number of retries reached. Bail gracefully.
      std::move(next_track_state_.done_callback).Run(std::nullopt);
      next_track_state_.Reset();
      return;
    }

    // Other unhandled HTTP errors. Bail gracefully.
    std::move(next_track_state_.done_callback).Run(std::nullopt);
    next_track_state_.Reset();
    return;
  }

  next_track_state_.last_playlist_id = playlist_id;
  next_track_state_.last_queue_id = playback_context->queue_name;

  std::optional<Track> result = std::nullopt;
  if (playback_context.has_value()) {
    // Handle explicit track.
    if (playback_context->track_explicit_type_ !=
        kYouTubeMusicTrackNotExplicit) {
      // Retry if needed.
      if (next_track_state_.retry_state.retry_index < kMaxRetryExplicitTrack) {
        next_track_state_.retry_state.retry_index++;
        next_track_state_.retry_state.timer.Start(
            FROM_HERE, kWaitTimeExplicitTrack,
            base::BindOnce(&FocusModeYouTubeMusicDelegate::GetNextTrackInternal,
                           weak_factory_.GetWeakPtr(), playlist_id));
        return;
      }

      // Max number of retries reached. Bail gracefully.
      std::move(next_track_state_.done_callback).Run(std::nullopt);
      next_track_state_.Reset();
      return;
    }

    result = Track(
        /*title=*/playback_context->track_title,
        /*artist=*/playback_context->track_artists,
        /*source=*/
        base::StringPrintf(kYouTubeMusicSourceFormat, playlist_id.c_str()),
        /*thumbnail_url=*/playback_context->track_image.url,
        /*source_url=*/playback_context->stream_url,
        // YouTube Music requires playback reporting.
        /*enable_playback_reporting=*/true);
    report_playback_state_.url_to_token[playback_context->stream_url] =
        playback_context->playback_reporting_token;
  }

  std::move(next_track_state_.done_callback).Run(result);
  next_track_state_.done_callback = base::NullCallback();

  // For a successful request, reset the retry state so that it could handle
  // failure correctly going forward.
  next_track_state_.retry_state.Reset();
}

void FocusModeYouTubeMusicDelegate::OnReportPlaybackDone(
    const GURL& url,
    google_apis::ApiErrorCode http_error_code,
    std::optional<const std::string> new_playback_reporting_token) {
  if (http_error_code != google_apis::ApiErrorCode::HTTP_SUCCESS) {
    if (http_error_code == google_apis::ApiErrorCode::HTTP_FORBIDDEN &&
        no_premium_callback_) {
      no_premium_callback_.Run();
    }
    // TODO(b/354240276): Add more error handling and retries.
    return;
  }

  if (!report_playback_state_.CanReportPlaybackForUrl(url)) {
    return;
  }

  // Refresh the reports.playback token since we have a new one. Please note,
  // the API server may return empty tokens when a track is completed.
  if (new_playback_reporting_token.has_value() &&
      !new_playback_reporting_token.value().empty()) {
    report_playback_state_.url_to_token[url] =
        new_playback_reporting_token.value();
  }

  // When a track is completed, clear the local data.
  if (report_playback_state_.url_to_playback_state.at(url) ==
          youtube_music::PlaybackState::kEnded ||
      report_playback_state_.url_to_playback_state.at(url) ==
          youtube_music::PlaybackState::kSwitchedToNext) {
    report_playback_state_.url_to_playback_state.erase(url);
    report_playback_state_.url_to_token.erase(url);
  }
}

}  // namespace ash