1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.tv.dvr;
18 
19 import android.annotation.SuppressLint;
20 import android.annotation.TargetApi;
21 import android.content.Context;
22 import android.content.SharedPreferences;
23 import android.os.AsyncTask;
24 import android.os.Build;
25 import android.support.annotation.MainThread;
26 import android.support.annotation.VisibleForTesting;
27 import android.text.TextUtils;
28 import android.util.ArraySet;
29 import android.util.Log;
30 import android.util.LongSparseArray;
31 
32 import com.android.tv.ApplicationSingletons;
33 import com.android.tv.TvApplication;
34 import com.android.tv.common.CollectionUtils;
35 import com.android.tv.common.SharedPreferencesUtils;
36 import com.android.tv.common.SoftPreconditions;
37 import com.android.tv.data.Program;
38 import com.android.tv.data.epg.EpgFetcher;
39 import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
40 import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener;
41 import com.android.tv.dvr.EpisodicProgramLoadTask.ScheduledEpisode;
42 import com.android.tv.experiments.Experiments;
43 
44 import java.util.ArrayList;
45 import java.util.Arrays;
46 import java.util.Collection;
47 import java.util.Collections;
48 import java.util.Comparator;
49 import java.util.HashMap;
50 import java.util.HashSet;
51 import java.util.Iterator;
52 import java.util.List;
53 import java.util.Map;
54 import java.util.Map.Entry;
55 import java.util.concurrent.CopyOnWriteArraySet;
56 import java.util.Set;
57 
58 /**
59  * Creates the {@link ScheduledRecording}s for the {@link SeriesRecording}.
60  * <p>
61  * The current implementation assumes that the series recordings are scheduled only for one channel.
62  */
63 @TargetApi(Build.VERSION_CODES.N)
64 public class SeriesRecordingScheduler {
65     private static final String TAG = "SeriesRecordingSchd";
66     private static final boolean DEBUG = false;
67 
68     private static final String KEY_FETCHED_SERIES_IDS =
69             "SeriesRecordingScheduler.fetched_series_ids";
70 
71     @SuppressLint("StaticFieldLeak")
72     private static SeriesRecordingScheduler sInstance;
73 
74     /**
75      * Creates and returns the {@link SeriesRecordingScheduler}.
76      */
getInstance(Context context)77     public static synchronized SeriesRecordingScheduler getInstance(Context context) {
78         if (sInstance == null) {
79             sInstance = new SeriesRecordingScheduler(context);
80         }
81         return sInstance;
82     }
83 
84     private final Context mContext;
85     private final DvrManager mDvrManager;
86     private final WritableDvrDataManager mDataManager;
87     private final List<SeriesRecordingUpdateTask> mScheduleTasks = new ArrayList<>();
88     private final List<FetchSeriesInfoTask> mFetchSeriesInfoTasks = new ArrayList<>();
89     private final Set<String> mFetchedSeriesIds = new ArraySet<>();
90     private final SharedPreferences mSharedPreferences;
91     private boolean mStarted;
92     private boolean mPaused;
93     private final Set<Long> mPendingSeriesRecordings = new ArraySet<>();
94     private final Set<OnSeriesRecordingUpdatedListener> mOnSeriesRecordingUpdatedListeners =
95             new CopyOnWriteArraySet<>();
96 
97 
98     private final SeriesRecordingListener mSeriesRecordingListener = new SeriesRecordingListener() {
99         @Override
100         public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) {
101             for (SeriesRecording seriesRecording : seriesRecordings) {
102                 executeFetchSeriesInfoTask(seriesRecording);
103             }
104         }
105 
106         @Override
107         public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) {
108             // Cancel the update.
109             for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator();
110                  iter.hasNext(); ) {
111                 SeriesRecordingUpdateTask task = iter.next();
112                 if (CollectionUtils.subtract(task.getSeriesRecordings(), seriesRecordings,
113                         SeriesRecording.ID_COMPARATOR).isEmpty()) {
114                     task.cancel(true);
115                     iter.remove();
116                 }
117             }
118         }
119 
120         @Override
121         public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) {
122             List<SeriesRecording> stopped = new ArrayList<>();
123             List<SeriesRecording> normal = new ArrayList<>();
124             for (SeriesRecording r : seriesRecordings) {
125                 if (r.isStopped()) {
126                     stopped.add(r);
127                 } else {
128                     normal.add(r);
129                 }
130             }
131             if (!stopped.isEmpty()) {
132                 onSeriesRecordingRemoved(SeriesRecording.toArray(stopped));
133             }
134             if (!normal.isEmpty()) {
135                 updateSchedules(normal);
136             }
137         }
138     };
139 
140     private final ScheduledRecordingListener mScheduledRecordingListener =
141             new ScheduledRecordingListener() {
142                 @Override
143                 public void onScheduledRecordingAdded(ScheduledRecording... schedules) {
144                     // No need to update series recordings when the new schedule is added.
145                 }
146 
147                 @Override
148                 public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
149                     handleScheduledRecordingChange(Arrays.asList(schedules));
150                 }
151 
152                 @Override
153                 public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
154                     List<ScheduledRecording> schedulesForUpdate = new ArrayList<>();
155                     for (ScheduledRecording r : schedules) {
156                         if ((r.getState() == ScheduledRecording.STATE_RECORDING_FAILED
157                                 || r.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED)
158                                 && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET
159                                 && !TextUtils.isEmpty(r.getSeasonNumber())
160                                 && !TextUtils.isEmpty(r.getEpisodeNumber())) {
161                             schedulesForUpdate.add(r);
162                         }
163                     }
164                     if (!schedulesForUpdate.isEmpty()) {
165                         handleScheduledRecordingChange(schedulesForUpdate);
166                     }
167                 }
168 
169                 private void handleScheduledRecordingChange(List<ScheduledRecording> schedules) {
170                     if (schedules.isEmpty()) {
171                         return;
172                     }
173                     Set<Long> seriesRecordingIds = new HashSet<>();
174                     for (ScheduledRecording r : schedules) {
175                         if (r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) {
176                             seriesRecordingIds.add(r.getSeriesRecordingId());
177                         }
178                     }
179                     if (!seriesRecordingIds.isEmpty()) {
180                         List<SeriesRecording> seriesRecordings = new ArrayList<>();
181                         for (Long id : seriesRecordingIds) {
182                             SeriesRecording seriesRecording = mDataManager.getSeriesRecording(id);
183                             if (seriesRecording != null) {
184                                 seriesRecordings.add(seriesRecording);
185                             }
186                         }
187                         if (!seriesRecordings.isEmpty()) {
188                             updateSchedules(seriesRecordings);
189                         }
190                     }
191                 }
192             };
193 
SeriesRecordingScheduler(Context context)194     private SeriesRecordingScheduler(Context context) {
195         mContext = context.getApplicationContext();
196         ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
197         mDvrManager = appSingletons.getDvrManager();
198         mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager();
199         mSharedPreferences = context.getSharedPreferences(
200                 SharedPreferencesUtils.SHARED_PREF_SERIES_RECORDINGS, Context.MODE_PRIVATE);
201         mFetchedSeriesIds.addAll(mSharedPreferences.getStringSet(KEY_FETCHED_SERIES_IDS,
202                 Collections.emptySet()));
203     }
204 
205     /**
206      * Starts the scheduler.
207      */
208     @MainThread
start()209     public void start() {
210         SoftPreconditions.checkState(mDataManager.isInitialized());
211         if (mStarted) {
212             return;
213         }
214         if (DEBUG) Log.d(TAG, "start");
215         mStarted = true;
216         mDataManager.addSeriesRecordingListener(mSeriesRecordingListener);
217         mDataManager.addScheduledRecordingListener(mScheduledRecordingListener);
218         startFetchingSeriesInfo();
219         updateSchedules(mDataManager.getSeriesRecordings());
220     }
221 
222     @MainThread
stop()223     public void stop() {
224         if (!mStarted) {
225             return;
226         }
227         if (DEBUG) Log.d(TAG, "stop");
228         mStarted = false;
229         for (FetchSeriesInfoTask task : mFetchSeriesInfoTasks) {
230             task.cancel(true);
231         }
232         mFetchSeriesInfoTasks.clear();
233         for (SeriesRecordingUpdateTask task : mScheduleTasks) {
234             task.cancel(true);
235         }
236         mScheduleTasks.clear();
237         mDataManager.removeScheduledRecordingListener(mScheduledRecordingListener);
238         mDataManager.removeSeriesRecordingListener(mSeriesRecordingListener);
239     }
240 
startFetchingSeriesInfo()241     private void startFetchingSeriesInfo() {
242         for (SeriesRecording seriesRecording : mDataManager.getSeriesRecordings()) {
243             if (!mFetchedSeriesIds.contains(seriesRecording.getSeriesId())) {
244                 executeFetchSeriesInfoTask(seriesRecording);
245             }
246         }
247     }
248 
executeFetchSeriesInfoTask(SeriesRecording seriesRecording)249     private void executeFetchSeriesInfoTask(SeriesRecording seriesRecording) {
250         if (Experiments.CLOUD_EPG.get()) {
251             FetchSeriesInfoTask task = new FetchSeriesInfoTask(seriesRecording);
252             task.execute();
253             mFetchSeriesInfoTasks.add(task);
254         }
255     }
256 
257     /**
258      * Pauses the updates of the series recordings.
259      */
pauseUpdate()260     public void pauseUpdate() {
261         if (DEBUG) Log.d(TAG, "Schedule paused");
262         if (mPaused) {
263             return;
264         }
265         mPaused = true;
266         if (!mStarted) {
267             return;
268         }
269         for (SeriesRecordingUpdateTask task : mScheduleTasks) {
270             for (SeriesRecording r : task.getSeriesRecordings()) {
271                 mPendingSeriesRecordings.add(r.getId());
272             }
273             task.cancel(true);
274         }
275     }
276 
277     /**
278      * Resumes the updates of the series recordings.
279      */
resumeUpdate()280     public void resumeUpdate() {
281         if (DEBUG) Log.d(TAG, "Schedule resumed");
282         if (!mPaused) {
283             return;
284         }
285         mPaused = false;
286         if (!mStarted) {
287             return;
288         }
289         if (!mPendingSeriesRecordings.isEmpty()) {
290             List<SeriesRecording> seriesRecordings = new ArrayList<>();
291             for (long seriesRecordingId : mPendingSeriesRecordings) {
292                 SeriesRecording seriesRecording =
293                         mDataManager.getSeriesRecording(seriesRecordingId);
294                 if (seriesRecording != null) {
295                     seriesRecordings.add(seriesRecording);
296                 }
297             }
298             if (!seriesRecordings.isEmpty()) {
299                 updateSchedules(seriesRecordings);
300             }
301         }
302     }
303 
304     /**
305      * Update schedules for the given series recordings. If it's paused, the update will be done
306      * after it's resumed.
307      */
updateSchedules(Collection<SeriesRecording> seriesRecordings)308     public void updateSchedules(Collection<SeriesRecording> seriesRecordings) {
309         if (DEBUG) Log.d(TAG, "updateSchedules:" + seriesRecordings);
310         if (!mStarted) {
311             if (DEBUG) Log.d(TAG, "Not started yet.");
312             return;
313         }
314         if (mPaused) {
315             for (SeriesRecording r : seriesRecordings) {
316                 mPendingSeriesRecordings.add(r.getId());
317             }
318             if (DEBUG) {
319                 Log.d(TAG, "The scheduler has been paused. Adding to the pending list. size="
320                         + mPendingSeriesRecordings.size());
321             }
322             return;
323         }
324         Set<SeriesRecording> previousSeriesRecordings = new HashSet<>();
325         for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator();
326              iter.hasNext(); ) {
327             SeriesRecordingUpdateTask task = iter.next();
328             if (CollectionUtils.containsAny(task.getSeriesRecordings(), seriesRecordings,
329                     SeriesRecording.ID_COMPARATOR)) {
330                 // The task is affected by the seriesRecordings
331                 task.cancel(true);
332                 previousSeriesRecordings.addAll(task.getSeriesRecordings());
333                 iter.remove();
334             }
335         }
336         List<SeriesRecording> seriesRecordingsToUpdate = CollectionUtils.union(seriesRecordings,
337                 previousSeriesRecordings, SeriesRecording.ID_COMPARATOR);
338         for (Iterator<SeriesRecording> iter = seriesRecordingsToUpdate.iterator();
339                 iter.hasNext(); ) {
340             SeriesRecording seriesRecording = mDataManager.getSeriesRecording(iter.next().getId());
341             if (seriesRecording == null || seriesRecording.isStopped()) {
342                 // Series recording has been removed or stopped.
343                 iter.remove();
344             }
345         }
346         if (seriesRecordingsToUpdate.isEmpty()) {
347             return;
348         }
349         if (needToReadAllChannels(seriesRecordingsToUpdate)) {
350             SeriesRecordingUpdateTask task =
351                     new SeriesRecordingUpdateTask(seriesRecordingsToUpdate);
352             mScheduleTasks.add(task);
353             if (DEBUG) Log.d(TAG, "Added schedule task: " + task);
354             task.execute();
355         } else {
356             for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) {
357                 SeriesRecordingUpdateTask task = new SeriesRecordingUpdateTask(
358                         Collections.singletonList(seriesRecording));
359                 mScheduleTasks.add(task);
360                 if (DEBUG) Log.d(TAG, "Added schedule task: " + task);
361                 task.execute();
362             }
363         }
364     }
365 
366     /**
367      * Adds {@link OnSeriesRecordingUpdatedListener}.
368      */
addOnSeriesRecordingUpdatedListener(OnSeriesRecordingUpdatedListener listener)369     public void addOnSeriesRecordingUpdatedListener(OnSeriesRecordingUpdatedListener listener) {
370         mOnSeriesRecordingUpdatedListeners.add(listener);
371     }
372 
373     /**
374      * Removes {@link OnSeriesRecordingUpdatedListener}.
375      */
removeOnSeriesRecordingUpdatedListener(OnSeriesRecordingUpdatedListener listener)376     public void removeOnSeriesRecordingUpdatedListener(OnSeriesRecordingUpdatedListener listener) {
377         mOnSeriesRecordingUpdatedListeners.remove(listener);
378     }
379 
needToReadAllChannels(List<SeriesRecording> seriesRecordingsToUpdate)380     private boolean needToReadAllChannels(List<SeriesRecording> seriesRecordingsToUpdate) {
381         for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) {
382             if (seriesRecording.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ALL) {
383                 return true;
384             }
385         }
386         return false;
387     }
388 
389     /**
390      * Pick one program per an episode.
391      *
392      * <p>Note that the programs which has been already scheduled have the highest priority, and all
393      * of them are added even though they are the same episodes. That's because the schedules
394      * should be added to the series recording.
395      * <p>If there are no existing schedules for an episode, one program which starts earlier is
396      * picked.
397      */
pickOneProgramPerEpisode( List<SeriesRecording> seriesRecordings, List<Program> programs)398     private LongSparseArray<List<Program>> pickOneProgramPerEpisode(
399             List<SeriesRecording> seriesRecordings, List<Program> programs) {
400         return pickOneProgramPerEpisode(mDataManager, seriesRecordings, programs);
401     }
402 
403     /**
404      * @see #pickOneProgramPerEpisode(List, List)
405      */
406     @VisibleForTesting
pickOneProgramPerEpisode( DvrDataManager dataManager, List<SeriesRecording> seriesRecordings, List<Program> programs)407     static LongSparseArray<List<Program>> pickOneProgramPerEpisode(
408             DvrDataManager dataManager, List<SeriesRecording> seriesRecordings,
409             List<Program> programs) {
410         // Initialize.
411         LongSparseArray<List<Program>> result = new LongSparseArray<>();
412         Map<String, Long> seriesRecordingIds = new HashMap<>();
413         for (SeriesRecording seriesRecording : seriesRecordings) {
414             result.put(seriesRecording.getId(), new ArrayList<>());
415             seriesRecordingIds.put(seriesRecording.getSeriesId(), seriesRecording.getId());
416         }
417         // Group programs by the episode.
418         Map<ScheduledEpisode, List<Program>> programsForEpisodeMap = new HashMap<>();
419         for (Program program : programs) {
420             long seriesRecordingId = seriesRecordingIds.get(program.getSeriesId());
421             if (TextUtils.isEmpty(program.getSeasonNumber())
422                     || TextUtils.isEmpty(program.getEpisodeNumber())) {
423                 // Add all the programs if it doesn't have season number or episode number.
424                 result.get(seriesRecordingId).add(program);
425                 continue;
426             }
427             ScheduledEpisode episode = new ScheduledEpisode(seriesRecordingId,
428                     program.getSeasonNumber(), program.getEpisodeNumber());
429             List<Program> programsForEpisode = programsForEpisodeMap.get(episode);
430             if (programsForEpisode == null) {
431                 programsForEpisode = new ArrayList<>();
432                 programsForEpisodeMap.put(episode, programsForEpisode);
433             }
434             programsForEpisode.add(program);
435         }
436         // Pick one program.
437         for (Entry<ScheduledEpisode, List<Program>> entry : programsForEpisodeMap.entrySet()) {
438             List<Program> programsForEpisode = entry.getValue();
439             Collections.sort(programsForEpisode, new Comparator<Program>() {
440                 @Override
441                 public int compare(Program lhs, Program rhs) {
442                     // Place the existing schedule first.
443                     boolean lhsScheduled = isProgramScheduled(dataManager, lhs);
444                     boolean rhsScheduled = isProgramScheduled(dataManager, rhs);
445                     if (lhsScheduled && !rhsScheduled) {
446                         return -1;
447                     }
448                     if (!lhsScheduled && rhsScheduled) {
449                         return 1;
450                     }
451                     // Sort by the start time in ascending order.
452                     return lhs.compareTo(rhs);
453                 }
454             });
455             boolean added = false;
456             // Add all the scheduled programs
457             List<Program> programsForSeries = result.get(entry.getKey().seriesRecordingId);
458             for (Program program : programsForEpisode) {
459                 if (isProgramScheduled(dataManager, program)) {
460                     programsForSeries.add(program);
461                     added = true;
462                 } else if (!added) {
463                     programsForSeries.add(program);
464                     break;
465                 }
466             }
467         }
468         return result;
469     }
470 
isProgramScheduled(DvrDataManager dataManager, Program program)471     private static boolean isProgramScheduled(DvrDataManager dataManager, Program program) {
472         ScheduledRecording schedule =
473                 dataManager.getScheduledRecordingForProgramId(program.getId());
474         return schedule != null && schedule.getState()
475                 == ScheduledRecording.STATE_RECORDING_NOT_STARTED;
476     }
477 
updateFetchedSeries()478     private void updateFetchedSeries() {
479         mSharedPreferences.edit().putStringSet(KEY_FETCHED_SERIES_IDS, mFetchedSeriesIds).apply();
480     }
481 
482     /**
483      * This works only for the existing series recordings. Do not use this task for the
484      * "adding series recording" UI.
485      */
486     private class SeriesRecordingUpdateTask extends EpisodicProgramLoadTask {
SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings)487         SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings) {
488             super(mContext, seriesRecordings);
489         }
490 
491         @Override
onPostExecute(List<Program> programs)492         protected void onPostExecute(List<Program> programs) {
493             if (DEBUG) Log.d(TAG, "onPostExecute: updating schedules with programs:" + programs);
494             mScheduleTasks.remove(this);
495             if (programs == null) {
496                 Log.e(TAG, "Creating schedules for series recording failed: "
497                         + getSeriesRecordings());
498                 return;
499             }
500             LongSparseArray<List<Program>> seriesProgramMap = pickOneProgramPerEpisode(
501                     getSeriesRecordings(), programs);
502             for (SeriesRecording seriesRecording : getSeriesRecordings()) {
503                 // Check the series recording is still valid.
504                 SeriesRecording actualSeriesRecording = mDataManager.getSeriesRecording(
505                         seriesRecording.getId());
506                 if (actualSeriesRecording == null || actualSeriesRecording.isStopped()) {
507                     continue;
508                 }
509                 List<Program> programsToSchedule = seriesProgramMap.get(seriesRecording.getId());
510                 if (mDataManager.getSeriesRecording(seriesRecording.getId()) != null
511                         && !programsToSchedule.isEmpty()) {
512                     mDvrManager.addScheduleToSeriesRecording(seriesRecording, programsToSchedule);
513                 }
514             }
515             if (!mOnSeriesRecordingUpdatedListeners.isEmpty()) {
516                 for (OnSeriesRecordingUpdatedListener listener
517                         : mOnSeriesRecordingUpdatedListeners) {
518                     listener.onSeriesRecordingUpdated(
519                             SeriesRecording.toArray(getSeriesRecordings()));
520                 }
521             }
522         }
523 
524         @Override
onCancelled(List<Program> programs)525         protected void onCancelled(List<Program> programs) {
526             mScheduleTasks.remove(this);
527         }
528 
529         @Override
toString()530         public String toString() {
531             return "SeriesRecordingUpdateTask:{"
532                     + "series_recordings=" + getSeriesRecordings()
533                     + "}";
534         }
535     }
536 
537     private class FetchSeriesInfoTask extends AsyncTask<Void, Void, SeriesInfo> {
538         private SeriesRecording mSeriesRecording;
539 
FetchSeriesInfoTask(SeriesRecording seriesRecording)540         FetchSeriesInfoTask(SeriesRecording seriesRecording) {
541             mSeriesRecording = seriesRecording;
542         }
543 
544         @Override
doInBackground(Void... voids)545         protected SeriesInfo doInBackground(Void... voids) {
546             return EpgFetcher.createEpgReader(mContext)
547                     .getSeriesInfo(mSeriesRecording.getSeriesId());
548         }
549 
550         @Override
onPostExecute(SeriesInfo seriesInfo)551         protected void onPostExecute(SeriesInfo seriesInfo) {
552             if (seriesInfo != null) {
553                 mDataManager.updateSeriesRecording(SeriesRecording.buildFrom(mSeriesRecording)
554                         .setTitle(seriesInfo.getTitle())
555                         .setDescription(seriesInfo.getDescription())
556                         .setLongDescription(seriesInfo.getLongDescription())
557                         .setCanonicalGenreIds(seriesInfo.getCanonicalGenreIds())
558                         .setPosterUri(seriesInfo.getPosterUri())
559                         .setPhotoUri(seriesInfo.getPhotoUri())
560                         .build());
561                 mFetchedSeriesIds.add(seriesInfo.getId());
562                 updateFetchedSeries();
563             }
564             mFetchSeriesInfoTasks.remove(this);
565         }
566 
567         @Override
onCancelled(SeriesInfo seriesInfo)568         protected void onCancelled(SeriesInfo seriesInfo) {
569             mFetchSeriesInfoTasks.remove(this);
570         }
571     }
572 
573     /**
574      * A listener to notify when series recording are updated.
575      */
576     public interface OnSeriesRecordingUpdatedListener {
onSeriesRecordingUpdated(SeriesRecording... seriesRecordings)577         void onSeriesRecordingUpdated(SeriesRecording... seriesRecordings);
578     }
579 }
580