aboutsummaryrefslogtreecommitdiff
path: root/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java')
-rw-r--r--src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java563
1 files changed, 563 insertions, 0 deletions
diff --git a/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java b/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java
new file mode 100644
index 00000000..15508c24
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java
@@ -0,0 +1,563 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.recorder;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.support.annotation.MainThread;
+import android.text.TextUtils;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.LongSparseArray;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.TvApplication;
+import com.android.tv.common.CollectionUtils;
+import com.android.tv.common.SharedPreferencesUtils;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Program;
+import com.android.tv.data.epg.EpgFetcher;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.WritableDvrDataManager;
+import com.android.tv.dvr.data.SeasonEpisodeNumber;
+import com.android.tv.dvr.data.ScheduledRecording;
+import com.android.tv.dvr.data.SeriesInfo;
+import com.android.tv.dvr.data.SeriesRecording;
+import com.android.tv.dvr.provider.EpisodicProgramLoadTask;
+import com.android.tv.experiments.Experiments;
+
+import com.android.tv.util.LocationUtils;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/**
+ * Creates the {@link com.android.tv.dvr.data.ScheduledRecording}s for
+ * the {@link com.android.tv.dvr.data.SeriesRecording}.
+ * <p>
+ * The current implementation assumes that the series recordings are scheduled only for one channel.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class SeriesRecordingScheduler {
+ private static final String TAG = "SeriesRecordingSchd";
+ private static final boolean DEBUG = false;
+
+ private static final String KEY_FETCHED_SERIES_IDS =
+ "SeriesRecordingScheduler.fetched_series_ids";
+
+ @SuppressLint("StaticFieldLeak")
+ private static SeriesRecordingScheduler sInstance;
+
+ /**
+ * Creates and returns the {@link SeriesRecordingScheduler}.
+ */
+ public static synchronized SeriesRecordingScheduler getInstance(Context context) {
+ if (sInstance == null) {
+ sInstance = new SeriesRecordingScheduler(context);
+ }
+ return sInstance;
+ }
+
+ private final Context mContext;
+ private final DvrManager mDvrManager;
+ private final WritableDvrDataManager mDataManager;
+ private final List<SeriesRecordingUpdateTask> mScheduleTasks = new ArrayList<>();
+ private final LongSparseArray<FetchSeriesInfoTask> mFetchSeriesInfoTasks =
+ new LongSparseArray<>();
+ private final Set<String> mFetchedSeriesIds = new ArraySet<>();
+ private final SharedPreferences mSharedPreferences;
+ private boolean mStarted;
+ private boolean mPaused;
+ private final Set<Long> mPendingSeriesRecordings = new ArraySet<>();
+
+ private final SeriesRecordingListener mSeriesRecordingListener = new SeriesRecordingListener() {
+ @Override
+ public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ executeFetchSeriesInfoTask(seriesRecording);
+ }
+ }
+
+ @Override
+ public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) {
+ // Cancel the update.
+ for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator();
+ iter.hasNext(); ) {
+ SeriesRecordingUpdateTask task = iter.next();
+ if (CollectionUtils.subtract(task.getSeriesRecordings(), seriesRecordings,
+ SeriesRecording.ID_COMPARATOR).isEmpty()) {
+ task.cancel(true);
+ iter.remove();
+ }
+ }
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ FetchSeriesInfoTask task = mFetchSeriesInfoTasks.get(seriesRecording.getId());
+ if (task != null) {
+ task.cancel(true);
+ mFetchSeriesInfoTasks.remove(seriesRecording.getId());
+ }
+ }
+ }
+
+ @Override
+ public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) {
+ List<SeriesRecording> stopped = new ArrayList<>();
+ List<SeriesRecording> normal = new ArrayList<>();
+ for (SeriesRecording r : seriesRecordings) {
+ if (r.isStopped()) {
+ stopped.add(r);
+ } else {
+ normal.add(r);
+ }
+ }
+ if (!stopped.isEmpty()) {
+ onSeriesRecordingRemoved(SeriesRecording.toArray(stopped));
+ }
+ if (!normal.isEmpty()) {
+ updateSchedules(normal);
+ }
+ }
+ };
+
+ private final ScheduledRecordingListener mScheduledRecordingListener =
+ new ScheduledRecordingListener() {
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... schedules) {
+ // No need to update series recordings when the new schedule is added.
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
+ handleScheduledRecordingChange(Arrays.asList(schedules));
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
+ List<ScheduledRecording> schedulesForUpdate = new ArrayList<>();
+ for (ScheduledRecording r : schedules) {
+ if ((r.getState() == ScheduledRecording.STATE_RECORDING_FAILED
+ || r.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED)
+ && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET
+ && !TextUtils.isEmpty(r.getSeasonNumber())
+ && !TextUtils.isEmpty(r.getEpisodeNumber())) {
+ schedulesForUpdate.add(r);
+ }
+ }
+ if (!schedulesForUpdate.isEmpty()) {
+ handleScheduledRecordingChange(schedulesForUpdate);
+ }
+ }
+
+ private void handleScheduledRecordingChange(List<ScheduledRecording> schedules) {
+ if (schedules.isEmpty()) {
+ return;
+ }
+ Set<Long> seriesRecordingIds = new HashSet<>();
+ for (ScheduledRecording r : schedules) {
+ if (r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) {
+ seriesRecordingIds.add(r.getSeriesRecordingId());
+ }
+ }
+ if (!seriesRecordingIds.isEmpty()) {
+ List<SeriesRecording> seriesRecordings = new ArrayList<>();
+ for (Long id : seriesRecordingIds) {
+ SeriesRecording seriesRecording = mDataManager.getSeriesRecording(id);
+ if (seriesRecording != null) {
+ seriesRecordings.add(seriesRecording);
+ }
+ }
+ if (!seriesRecordings.isEmpty()) {
+ updateSchedules(seriesRecordings);
+ }
+ }
+ }
+ };
+
+ private SeriesRecordingScheduler(Context context) {
+ mContext = context.getApplicationContext();
+ ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
+ mDvrManager = appSingletons.getDvrManager();
+ mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager();
+ mSharedPreferences = context.getSharedPreferences(
+ SharedPreferencesUtils.SHARED_PREF_SERIES_RECORDINGS, Context.MODE_PRIVATE);
+ mFetchedSeriesIds.addAll(mSharedPreferences.getStringSet(KEY_FETCHED_SERIES_IDS,
+ Collections.emptySet()));
+ }
+
+ /**
+ * Starts the scheduler.
+ */
+ @MainThread
+ public void start() {
+ SoftPreconditions.checkState(mDataManager.isInitialized());
+ if (mStarted) {
+ return;
+ }
+ if (DEBUG) Log.d(TAG, "start");
+ mStarted = true;
+ mDataManager.addSeriesRecordingListener(mSeriesRecordingListener);
+ mDataManager.addScheduledRecordingListener(mScheduledRecordingListener);
+ startFetchingSeriesInfo();
+ updateSchedules(mDataManager.getSeriesRecordings());
+ }
+
+ @MainThread
+ public void stop() {
+ if (!mStarted) {
+ return;
+ }
+ if (DEBUG) Log.d(TAG, "stop");
+ mStarted = false;
+ for (int i = 0; i < mFetchSeriesInfoTasks.size(); i++) {
+ FetchSeriesInfoTask task = mFetchSeriesInfoTasks.get(mFetchSeriesInfoTasks.keyAt(i));
+ task.cancel(true);
+ }
+ mFetchSeriesInfoTasks.clear();
+ for (SeriesRecordingUpdateTask task : mScheduleTasks) {
+ task.cancel(true);
+ }
+ mScheduleTasks.clear();
+ mDataManager.removeScheduledRecordingListener(mScheduledRecordingListener);
+ mDataManager.removeSeriesRecordingListener(mSeriesRecordingListener);
+ }
+
+ private void startFetchingSeriesInfo() {
+ for (SeriesRecording seriesRecording : mDataManager.getSeriesRecordings()) {
+ if (!mFetchedSeriesIds.contains(seriesRecording.getSeriesId())) {
+ executeFetchSeriesInfoTask(seriesRecording);
+ }
+ }
+ }
+
+ private void executeFetchSeriesInfoTask(SeriesRecording seriesRecording) {
+ if (Experiments.CLOUD_EPG.get()) {
+ FetchSeriesInfoTask task = new FetchSeriesInfoTask(seriesRecording);
+ task.execute();
+ mFetchSeriesInfoTasks.put(seriesRecording.getId(), task);
+ }
+ }
+
+ /**
+ * Pauses the updates of the series recordings.
+ */
+ public void pauseUpdate() {
+ if (DEBUG) Log.d(TAG, "Schedule paused");
+ if (mPaused) {
+ return;
+ }
+ mPaused = true;
+ if (!mStarted) {
+ return;
+ }
+ for (SeriesRecordingUpdateTask task : mScheduleTasks) {
+ for (SeriesRecording r : task.getSeriesRecordings()) {
+ mPendingSeriesRecordings.add(r.getId());
+ }
+ task.cancel(true);
+ }
+ }
+
+ /**
+ * Resumes the updates of the series recordings.
+ */
+ public void resumeUpdate() {
+ if (DEBUG) Log.d(TAG, "Schedule resumed");
+ if (!mPaused) {
+ return;
+ }
+ mPaused = false;
+ if (!mStarted) {
+ return;
+ }
+ if (!mPendingSeriesRecordings.isEmpty()) {
+ List<SeriesRecording> seriesRecordings = new ArrayList<>();
+ for (long seriesRecordingId : mPendingSeriesRecordings) {
+ SeriesRecording seriesRecording =
+ mDataManager.getSeriesRecording(seriesRecordingId);
+ if (seriesRecording != null) {
+ seriesRecordings.add(seriesRecording);
+ }
+ }
+ if (!seriesRecordings.isEmpty()) {
+ updateSchedules(seriesRecordings);
+ }
+ }
+ }
+
+ /**
+ * Update schedules for the given series recordings. If it's paused, the update will be done
+ * after it's resumed.
+ */
+ public void updateSchedules(Collection<SeriesRecording> seriesRecordings) {
+ if (DEBUG) Log.d(TAG, "updateSchedules:" + seriesRecordings);
+ if (!mStarted) {
+ if (DEBUG) Log.d(TAG, "Not started yet.");
+ return;
+ }
+ if (mPaused) {
+ for (SeriesRecording r : seriesRecordings) {
+ mPendingSeriesRecordings.add(r.getId());
+ }
+ if (DEBUG) {
+ Log.d(TAG, "The scheduler has been paused. Adding to the pending list. size="
+ + mPendingSeriesRecordings.size());
+ }
+ return;
+ }
+ Set<SeriesRecording> previousSeriesRecordings = new HashSet<>();
+ for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator();
+ iter.hasNext(); ) {
+ SeriesRecordingUpdateTask task = iter.next();
+ if (CollectionUtils.containsAny(task.getSeriesRecordings(), seriesRecordings,
+ SeriesRecording.ID_COMPARATOR)) {
+ // The task is affected by the seriesRecordings
+ task.cancel(true);
+ previousSeriesRecordings.addAll(task.getSeriesRecordings());
+ iter.remove();
+ }
+ }
+ List<SeriesRecording> seriesRecordingsToUpdate = CollectionUtils.union(seriesRecordings,
+ previousSeriesRecordings, SeriesRecording.ID_COMPARATOR);
+ for (Iterator<SeriesRecording> iter = seriesRecordingsToUpdate.iterator();
+ iter.hasNext(); ) {
+ SeriesRecording seriesRecording = mDataManager.getSeriesRecording(iter.next().getId());
+ if (seriesRecording == null || seriesRecording.isStopped()) {
+ // Series recording has been removed or stopped.
+ iter.remove();
+ }
+ }
+ if (seriesRecordingsToUpdate.isEmpty()) {
+ return;
+ }
+ if (needToReadAllChannels(seriesRecordingsToUpdate)) {
+ SeriesRecordingUpdateTask task =
+ new SeriesRecordingUpdateTask(seriesRecordingsToUpdate);
+ mScheduleTasks.add(task);
+ if (DEBUG) Log.d(TAG, "Added schedule task: " + task);
+ task.execute();
+ } else {
+ for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) {
+ SeriesRecordingUpdateTask task = new SeriesRecordingUpdateTask(
+ Collections.singletonList(seriesRecording));
+ mScheduleTasks.add(task);
+ if (DEBUG) Log.d(TAG, "Added schedule task: " + task);
+ task.execute();
+ }
+ }
+ }
+
+ private boolean needToReadAllChannels(List<SeriesRecording> seriesRecordingsToUpdate) {
+ for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) {
+ if (seriesRecording.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ALL) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Pick one program per an episode.
+ *
+ * <p>Note that the programs which has been already scheduled have the highest priority, and all
+ * of them are added even though they are the same episodes. That's because the schedules
+ * should be added to the series recording.
+ * <p>If there are no existing schedules for an episode, one program which starts earlier is
+ * picked.
+ */
+ private LongSparseArray<List<Program>> pickOneProgramPerEpisode(
+ List<SeriesRecording> seriesRecordings, List<Program> programs) {
+ return pickOneProgramPerEpisode(mDataManager, seriesRecordings, programs);
+ }
+
+ /**
+ * @see #pickOneProgramPerEpisode(List, List)
+ */
+ public static LongSparseArray<List<Program>> pickOneProgramPerEpisode(
+ DvrDataManager dataManager, List<SeriesRecording> seriesRecordings,
+ List<Program> programs) {
+ // Initialize.
+ LongSparseArray<List<Program>> result = new LongSparseArray<>();
+ Map<String, Long> seriesRecordingIds = new HashMap<>();
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ result.put(seriesRecording.getId(), new ArrayList<>());
+ seriesRecordingIds.put(seriesRecording.getSeriesId(), seriesRecording.getId());
+ }
+ // Group programs by the episode.
+ Map<SeasonEpisodeNumber, List<Program>> programsForEpisodeMap = new HashMap<>();
+ for (Program program : programs) {
+ long seriesRecordingId = seriesRecordingIds.get(program.getSeriesId());
+ if (TextUtils.isEmpty(program.getSeasonNumber())
+ || TextUtils.isEmpty(program.getEpisodeNumber())) {
+ // Add all the programs if it doesn't have season number or episode number.
+ result.get(seriesRecordingId).add(program);
+ continue;
+ }
+ SeasonEpisodeNumber seasonEpisodeNumber = new SeasonEpisodeNumber(seriesRecordingId,
+ program.getSeasonNumber(), program.getEpisodeNumber());
+ List<Program> programsForEpisode = programsForEpisodeMap.get(seasonEpisodeNumber);
+ if (programsForEpisode == null) {
+ programsForEpisode = new ArrayList<>();
+ programsForEpisodeMap.put(seasonEpisodeNumber, programsForEpisode);
+ }
+ programsForEpisode.add(program);
+ }
+ // Pick one program.
+ for (Entry<SeasonEpisodeNumber, List<Program>> entry : programsForEpisodeMap.entrySet()) {
+ List<Program> programsForEpisode = entry.getValue();
+ Collections.sort(programsForEpisode, new Comparator<Program>() {
+ @Override
+ public int compare(Program lhs, Program rhs) {
+ // Place the existing schedule first.
+ boolean lhsScheduled = isProgramScheduled(dataManager, lhs);
+ boolean rhsScheduled = isProgramScheduled(dataManager, rhs);
+ if (lhsScheduled && !rhsScheduled) {
+ return -1;
+ }
+ if (!lhsScheduled && rhsScheduled) {
+ return 1;
+ }
+ // Sort by the start time in ascending order.
+ return lhs.compareTo(rhs);
+ }
+ });
+ boolean added = false;
+ // Add all the scheduled programs
+ List<Program> programsForSeries = result.get(entry.getKey().seriesRecordingId);
+ for (Program program : programsForEpisode) {
+ if (isProgramScheduled(dataManager, program)) {
+ programsForSeries.add(program);
+ added = true;
+ } else if (!added) {
+ programsForSeries.add(program);
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private static boolean isProgramScheduled(DvrDataManager dataManager, Program program) {
+ ScheduledRecording schedule =
+ dataManager.getScheduledRecordingForProgramId(program.getId());
+ return schedule != null && schedule.getState()
+ == ScheduledRecording.STATE_RECORDING_NOT_STARTED;
+ }
+
+ private void updateFetchedSeries() {
+ mSharedPreferences.edit().putStringSet(KEY_FETCHED_SERIES_IDS, mFetchedSeriesIds).apply();
+ }
+
+ /**
+ * This works only for the existing series recordings. Do not use this task for the
+ * "adding series recording" UI.
+ */
+ private class SeriesRecordingUpdateTask extends EpisodicProgramLoadTask {
+ SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings) {
+ super(mContext, seriesRecordings);
+ }
+
+ @Override
+ protected void onPostExecute(List<Program> programs) {
+ if (DEBUG) Log.d(TAG, "onPostExecute: updating schedules with programs:" + programs);
+ mScheduleTasks.remove(this);
+ if (programs == null) {
+ Log.e(TAG, "Creating schedules for series recording failed: "
+ + getSeriesRecordings());
+ return;
+ }
+ LongSparseArray<List<Program>> seriesProgramMap = pickOneProgramPerEpisode(
+ getSeriesRecordings(), programs);
+ for (SeriesRecording seriesRecording : getSeriesRecordings()) {
+ // Check the series recording is still valid.
+ SeriesRecording actualSeriesRecording = mDataManager.getSeriesRecording(
+ seriesRecording.getId());
+ if (actualSeriesRecording == null || actualSeriesRecording.isStopped()) {
+ continue;
+ }
+ List<Program> programsToSchedule = seriesProgramMap.get(seriesRecording.getId());
+ if (mDataManager.getSeriesRecording(seriesRecording.getId()) != null
+ && !programsToSchedule.isEmpty()) {
+ mDvrManager.addScheduleToSeriesRecording(seriesRecording, programsToSchedule);
+ }
+ }
+ }
+
+ @Override
+ protected void onCancelled(List<Program> programs) {
+ mScheduleTasks.remove(this);
+ }
+
+ @Override
+ public String toString() {
+ return "SeriesRecordingUpdateTask:{"
+ + "series_recordings=" + getSeriesRecordings()
+ + "}";
+ }
+ }
+
+ private class FetchSeriesInfoTask extends AsyncTask<Void, Void, SeriesInfo> {
+ private SeriesRecording mSeriesRecording;
+
+ FetchSeriesInfoTask(SeriesRecording seriesRecording) {
+ mSeriesRecording = seriesRecording;
+ }
+
+ @Override
+ protected SeriesInfo doInBackground(Void... voids) {
+ return EpgFetcher.createEpgReader(mContext, LocationUtils.getCurrentCountry(mContext))
+ .getSeriesInfo(mSeriesRecording.getSeriesId());
+ }
+
+ @Override
+ protected void onPostExecute(SeriesInfo seriesInfo) {
+ if (seriesInfo != null) {
+ mDataManager.updateSeriesRecording(SeriesRecording.buildFrom(mSeriesRecording)
+ .setTitle(seriesInfo.getTitle())
+ .setDescription(seriesInfo.getDescription())
+ .setLongDescription(seriesInfo.getLongDescription())
+ .setCanonicalGenreIds(seriesInfo.getCanonicalGenreIds())
+ .setPosterUri(seriesInfo.getPosterUri())
+ .setPhotoUri(seriesInfo.getPhotoUri())
+ .build());
+ mFetchedSeriesIds.add(seriesInfo.getId());
+ updateFetchedSeries();
+ }
+ mFetchSeriesInfoTasks.remove(mSeriesRecording.getId());
+ }
+
+ @Override
+ protected void onCancelled(SeriesInfo seriesInfo) {
+ mFetchSeriesInfoTasks.remove(mSeriesRecording.getId());
+ }
+ }
+}