com.battlelancer.seriesguide.util.TraktTools.java Source code

Java tutorial

Introduction

Here is the source code for com.battlelancer.seriesguide.util.TraktTools.java

Source

/*
 * Copyright 2014 Uwe Trottmann
 *
 * 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.battlelancer.seriesguide.util;

import android.content.ContentProviderOperation;
import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.net.Uri;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.text.format.DateUtils;
import com.battlelancer.seriesguide.R;
import com.battlelancer.seriesguide.enums.EpisodeFlags;
import com.battlelancer.seriesguide.provider.SeriesGuideContract;
import com.battlelancer.seriesguide.settings.TraktCredentials;
import com.battlelancer.seriesguide.settings.TraktSettings;
import com.uwetrottmann.trakt.v2.TraktLink;
import com.uwetrottmann.trakt.v2.TraktV2;
import com.uwetrottmann.trakt.v2.entities.BaseEpisode;
import com.uwetrottmann.trakt.v2.entities.BaseMovie;
import com.uwetrottmann.trakt.v2.entities.BaseSeason;
import com.uwetrottmann.trakt.v2.entities.BaseShow;
import com.uwetrottmann.trakt.v2.entities.LastActivity;
import com.uwetrottmann.trakt.v2.entities.LastActivityMore;
import com.uwetrottmann.trakt.v2.entities.RatedEpisode;
import com.uwetrottmann.trakt.v2.entities.RatedMovie;
import com.uwetrottmann.trakt.v2.entities.RatedShow;
import com.uwetrottmann.trakt.v2.entities.SearchResult;
import com.uwetrottmann.trakt.v2.entities.Show;
import com.uwetrottmann.trakt.v2.entities.ShowIds;
import com.uwetrottmann.trakt.v2.entities.SyncEpisode;
import com.uwetrottmann.trakt.v2.entities.SyncItems;
import com.uwetrottmann.trakt.v2.entities.SyncSeason;
import com.uwetrottmann.trakt.v2.entities.SyncShow;
import com.uwetrottmann.trakt.v2.enums.Extended;
import com.uwetrottmann.trakt.v2.enums.IdType;
import com.uwetrottmann.trakt.v2.enums.RatingsFilter;
import com.uwetrottmann.trakt.v2.exceptions.OAuthUnauthorizedException;
import com.uwetrottmann.trakt.v2.services.Search;
import com.uwetrottmann.trakt.v2.services.Sync;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.joda.time.DateTime;
import retrofit.RetrofitError;
import timber.log.Timber;

import static com.battlelancer.seriesguide.sync.SgSyncAdapter.UpdateResult;

public class TraktTools {

    // Sync status codes
    public static final int SUCCESS = 1;
    public static final int FAILED_API = -1;
    public static final int FAILED = -2;
    public static final int FAILED_CREDENTIALS = -3;

    public enum Flag {
        COLLECTED(SeriesGuideContract.Episodes.COLLECTED,
                // only remove flags for already collected episodes
                SeriesGuideContract.Episodes.COLLECTED + "=1", 1, 0), WATCHED(SeriesGuideContract.Episodes.WATCHED,
                        // do not remove flags of skipped episodes, only of watched ones
                        SeriesGuideContract.Episodes.WATCHED + "=" + EpisodeFlags.WATCHED, EpisodeFlags.WATCHED,
                        EpisodeFlags.UNWATCHED);

        final String databaseColumn;
        final String clearFlagSelection;
        final int flaggedValue;
        final int nonFlaggedValue;

        Flag(String databaseColumn, String clearFlagSelection, int flaggedValue, int nonFlaggedValue) {
            this.databaseColumn = databaseColumn;
            this.clearFlagSelection = clearFlagSelection;
            this.flaggedValue = flaggedValue;
            this.nonFlaggedValue = nonFlaggedValue;
        }
    }

    /**
     * Downloads trakt movie watched flags and mirrors them in the local database. Does NOT upload
     * any flags (e.g. trakt is considered the truth).
     */
    public static UpdateResult downloadWatchedMovies(Context context, DateTime watchedAt) {
        if (watchedAt == null) {
            Timber.e("downloadWatchedMovies: null watched_at");
            return UpdateResult.INCOMPLETE;
        }

        long lastWatchedAt = TraktSettings.getLastMoviesWatchedAt(context);
        if (!watchedAt.isAfter(lastWatchedAt)) {
            // not initial sync, no watched flags have changed
            Timber.d("downloadWatchedMovies: no changes since " + lastWatchedAt);
            return UpdateResult.SUCCESS;
        }

        TraktV2 trakt = ServiceUtils.getTraktV2WithAuth(context);
        if (trakt == null) {
            return UpdateResult.INCOMPLETE;
        }

        // download watched movies
        List<BaseMovie> watchedMovies;
        try {
            watchedMovies = trakt.sync().watchedMovies(Extended.DEFAULT_MIN);
        } catch (RetrofitError e) {
            Timber.e(e, "downloadWatchedMovies: download failed");
            return UpdateResult.INCOMPLETE;
        } catch (OAuthUnauthorizedException e) {
            TraktCredentials.get(context).setCredentialsInvalid();
            return UpdateResult.INCOMPLETE;
        }
        if (watchedMovies == null) {
            Timber.e("downloadWatchedMovies: null response");
            return UpdateResult.INCOMPLETE;
        }
        if (watchedMovies.isEmpty()) {
            Timber.d("downloadWatchedMovies: no watched movies on trakt");
            return UpdateResult.SUCCESS;
        }

        // apply watched flags for all watched trakt movies that are in the local database
        ArrayList<ContentProviderOperation> batch = new ArrayList<>();
        Set<Integer> localMovies = MovieTools.getMovieTmdbIdsAsSet(context);
        if (localMovies == null) {
            return UpdateResult.INCOMPLETE;
        }
        Set<Integer> unwatchedMovies = new HashSet<>(localMovies);
        for (BaseMovie movie : watchedMovies) {
            if (movie.movie == null || movie.movie.ids == null || movie.movie.ids.tmdb == null) {
                // required values are missing
                continue;
            }
            if (!localMovies.contains(movie.movie.ids.tmdb)) {
                // movie NOT in local database
                // add a shell entry for storing watched state
                batch.add(ContentProviderOperation.newInsert(SeriesGuideContract.Movies.CONTENT_URI)
                        .withValue(SeriesGuideContract.Movies.TMDB_ID, movie.movie.ids.tmdb)
                        .withValue(SeriesGuideContract.Movies.WATCHED, true)
                        .withValue(SeriesGuideContract.Movies.IN_COLLECTION, false)
                        .withValue(SeriesGuideContract.Movies.IN_WATCHLIST, false).build());
            } else {
                // movie IN local database
                // set movie watched
                batch.add(ContentProviderOperation
                        .newUpdate(SeriesGuideContract.Movies.buildMovieUri(movie.movie.ids.tmdb))
                        .withValue(SeriesGuideContract.Movies.WATCHED, true).build());
                unwatchedMovies.remove(movie.movie.ids.tmdb);
            }
        }

        // remove watched flags from all remaining local movies
        for (Integer tmdbId : unwatchedMovies) {
            batch.add(ContentProviderOperation.newUpdate(SeriesGuideContract.Movies.buildMovieUri(tmdbId))
                    .withValue(SeriesGuideContract.Movies.WATCHED, false).build());
        }

        // apply database updates
        try {
            DBUtils.applyInSmallBatches(context, batch);
        } catch (OperationApplicationException e) {
            Timber.e(e, "downloadWatchedMovies: updating watched flags failed");
            return UpdateResult.INCOMPLETE;
        }

        // save last watched instant
        PreferenceManager.getDefaultSharedPreferences(context).edit()
                .putLong(TraktSettings.KEY_LAST_MOVIES_WATCHED_AT, watchedAt.getMillis()).commit();

        Timber.d("downloadWatchedMovies: success, last watched_at " + watchedAt.getMillis());
        return UpdateResult.SUCCESS;
    }

    /**
     * Downloads trakt movie ratings and applies the latest ones to the database.
     *
     * <p> To apply all ratings, set {@link TraktSettings#KEY_LAST_MOVIES_RATED_AT} to 0.
     */
    public static UpdateResult downloadMovieRatings(Context context, DateTime ratedAt) {
        if (ratedAt == null) {
            Timber.e("downloadMovieRatings: null rated_at");
            return UpdateResult.INCOMPLETE;
        }

        long lastRatedAt = TraktSettings.getLastMoviesRatedAt(context);
        if (!ratedAt.isAfter(lastRatedAt)) {
            // not initial sync, no ratings have changed
            Timber.d("downloadMovieRatings: no changes since " + lastRatedAt);
            return UpdateResult.SUCCESS;
        }

        TraktV2 trakt = ServiceUtils.getTraktV2WithAuth(context);
        if (trakt == null) {
            return UpdateResult.INCOMPLETE;
        }

        // download rated shows
        List<RatedMovie> ratedMovies;
        try {
            ratedMovies = trakt.sync().ratingsMovies(RatingsFilter.ALL, Extended.DEFAULT_MIN);
        } catch (RetrofitError e) {
            Timber.e(e, "downloadMovieRatings: download failed");
            return UpdateResult.INCOMPLETE;
        } catch (OAuthUnauthorizedException e) {
            TraktCredentials.get(context).setCredentialsInvalid();
            return UpdateResult.INCOMPLETE;
        }
        if (ratedMovies == null) {
            Timber.e("downloadMovieRatings: null response");
            return UpdateResult.INCOMPLETE;
        }
        if (ratedMovies.isEmpty()) {
            Timber.d("downloadMovieRatings: no ratings on trakt");
            return UpdateResult.SUCCESS;
        }

        // trakt last activity rated_at timestamp is set after the rating timestamp
        // so include ratings that are a little older
        long ratedAtThreshold = lastRatedAt - 5 * DateUtils.MINUTE_IN_MILLIS;

        // go through ratings, latest first (trakt sends in that order)
        ArrayList<ContentProviderOperation> batch = new ArrayList<>();
        for (RatedMovie movie : ratedMovies) {
            if (movie.rating == null || movie.movie == null || movie.movie.ids == null
                    || movie.movie.ids.tmdb == null) {
                // skip, can't handle
                continue;
            }
            if (movie.rated_at != null && movie.rated_at.isBefore(ratedAtThreshold)) {
                // no need to apply older ratings again
                break;
            }

            // if a movie does not exist, this update will do nothing
            ContentProviderOperation op = ContentProviderOperation
                    .newUpdate(SeriesGuideContract.Movies.buildMovieUri(movie.movie.ids.tmdb))
                    .withValue(SeriesGuideContract.Movies.RATING_USER, movie.rating.value).build();
            batch.add(op);
        }

        // apply database updates
        try {
            DBUtils.applyInSmallBatches(context, batch);
        } catch (OperationApplicationException e) {
            Timber.e(e, "downloadMovieRatings: database update failed");
            return UpdateResult.INCOMPLETE;
        }

        // save last rated instant
        PreferenceManager.getDefaultSharedPreferences(context).edit()
                .putLong(TraktSettings.KEY_LAST_MOVIES_RATED_AT, ratedAt.getMillis()).commit();

        Timber.d("downloadMovieRatings: success, last rated_at " + ratedAt.getMillis());
        return UpdateResult.SUCCESS;
    }

    /**
     * Downloads trakt show ratings and applies the latest ones to the database.
     *
     * <p> To apply all ratings, set {@link TraktSettings#KEY_LAST_SHOWS_RATED_AT} to 0.
     */
    public static UpdateResult downloadShowRatings(Context context, LastActivity activity) {
        if (activity.rated_at == null) {
            Timber.e("downloadShowRatings: null rated_at");
            return UpdateResult.INCOMPLETE;
        }

        long lastRatedAt = TraktSettings.getLastShowsRatedAt(context);
        if (!activity.rated_at.isAfter(lastRatedAt)) {
            // not initial sync, no ratings have changed
            Timber.d("downloadShowRatings: no changes since " + lastRatedAt);
            return UpdateResult.SUCCESS;
        }

        TraktV2 trakt = ServiceUtils.getTraktV2WithAuth(context);
        if (trakt == null) {
            return UpdateResult.INCOMPLETE;
        }

        // download rated shows
        List<RatedShow> ratedShows;
        try {
            ratedShows = trakt.sync().ratingsShows(RatingsFilter.ALL, Extended.DEFAULT_MIN);
        } catch (RetrofitError e) {
            Timber.e(e, "downloadShowRatings: download failed");
            return UpdateResult.INCOMPLETE;
        } catch (OAuthUnauthorizedException e) {
            TraktCredentials.get(context).setCredentialsInvalid();
            return UpdateResult.INCOMPLETE;
        }
        if (ratedShows == null) {
            Timber.e("downloadShowRatings: null response");
            return UpdateResult.INCOMPLETE;
        }
        if (ratedShows.isEmpty()) {
            Timber.d("downloadShowRatings: no ratings on trakt");
            return UpdateResult.SUCCESS;
        }

        // trakt last activity rated_at timestamp is set after the rating timestamp
        // so include ratings that are a little older
        long ratedAtThreshold = lastRatedAt - 5 * DateUtils.MINUTE_IN_MILLIS;

        // go through ratings, latest first (trakt sends in that order)
        ArrayList<ContentProviderOperation> batch = new ArrayList<>();
        for (RatedShow show : ratedShows) {
            if (show.rating == null || show.show == null || show.show.ids == null || show.show.ids.tvdb == null) {
                // skip, can't handle
                continue;
            }
            if (show.rated_at != null && show.rated_at.isBefore(ratedAtThreshold)) {
                // no need to apply older ratings again
                break;
            }

            // if a show does not exist, this update will do nothing
            ContentProviderOperation op = ContentProviderOperation
                    .newUpdate(SeriesGuideContract.Shows.buildShowUri(show.show.ids.tvdb))
                    .withValue(SeriesGuideContract.Shows.RATING_USER, show.rating.value).build();
            batch.add(op);
        }

        // apply database updates
        try {
            DBUtils.applyInSmallBatches(context, batch);
        } catch (OperationApplicationException e) {
            Timber.e(e, "downloadShowRatings: database update failed");
            return UpdateResult.INCOMPLETE;
        }

        // save last rated instant
        PreferenceManager.getDefaultSharedPreferences(context).edit()
                .putLong(TraktSettings.KEY_LAST_SHOWS_RATED_AT, activity.rated_at.getMillis()).commit();

        Timber.d("downloadShowRatings: success, last rated_at " + activity.rated_at.getMillis());
        return UpdateResult.SUCCESS;
    }

    /**
     * Downloads trakt episode ratings and applies the latest ones to the database.
     *
     * <p> To apply all ratings, set {@link TraktSettings#KEY_LAST_EPISODES_RATED_AT} to 0.
     */
    public static UpdateResult downloadEpisodeRatings(Context context, LastActivityMore activity) {
        if (activity.rated_at == null) {
            Timber.e("downloadEpisodeRatings: null rated_at");
            return UpdateResult.INCOMPLETE;
        }

        long lastRatedAt = TraktSettings.getLastEpisodesRatedAt(context);
        if (!activity.rated_at.isAfter(lastRatedAt)) {
            // not initial sync, no ratings have changed
            Timber.d("downloadEpisodeRatings: no changes since " + lastRatedAt);
            return UpdateResult.SUCCESS;
        }

        TraktV2 trakt = ServiceUtils.getTraktV2WithAuth(context);
        if (trakt == null) {
            return UpdateResult.INCOMPLETE;
        }

        // download rated episodes
        List<RatedEpisode> ratedEpisodes;
        try {
            ratedEpisodes = trakt.sync().ratingsEpisodes(RatingsFilter.ALL, Extended.DEFAULT_MIN);
        } catch (RetrofitError e) {
            Timber.e(e, "downloadEpisodeRatings: download failed");
            return UpdateResult.INCOMPLETE;
        } catch (OAuthUnauthorizedException e) {
            TraktCredentials.get(context).setCredentialsInvalid();
            return UpdateResult.INCOMPLETE;
        }
        if (ratedEpisodes == null) {
            Timber.e("downloadEpisodeRatings: null response");
            return UpdateResult.INCOMPLETE;
        }
        if (ratedEpisodes.isEmpty()) {
            Timber.d("downloadEpisodeRatings: no ratings on trakt");
            return UpdateResult.SUCCESS;
        }

        // trakt last activity rated_at timestamp is set after the rating timestamp
        // so include ratings that are a little older
        long ratedAtThreshold = lastRatedAt - 5 * DateUtils.MINUTE_IN_MILLIS;

        ArrayList<ContentProviderOperation> batch = new ArrayList<>();
        for (RatedEpisode episode : ratedEpisodes) {
            if (episode.rating == null || episode.episode == null || episode.episode.ids == null
                    || episode.episode.ids.tvdb == null) {
                // skip, can't handle
                continue;
            }
            if (episode.rated_at != null && episode.rated_at.isBefore(ratedAtThreshold)) {
                // no need to apply older ratings again
                break;
            }

            // if an episode does not exist, this update will do nothing
            ContentProviderOperation op = ContentProviderOperation
                    .newUpdate(SeriesGuideContract.Episodes.buildEpisodeUri(episode.episode.ids.tvdb))
                    .withValue(SeriesGuideContract.Episodes.RATING_USER, episode.rating.value).build();
            batch.add(op);
        }

        // apply database updates
        try {
            DBUtils.applyInSmallBatches(context, batch);
        } catch (OperationApplicationException e) {
            Timber.e(e, "downloadEpisodeRatings: database update failed");
            return UpdateResult.INCOMPLETE;
        }

        // save last rated instant
        PreferenceManager.getDefaultSharedPreferences(context).edit()
                .putLong(TraktSettings.KEY_LAST_EPISODES_RATED_AT, activity.rated_at.getMillis()).commit();

        Timber.d("downloadEpisodeRatings: success, last rated_at " + activity.rated_at.getMillis());
        return UpdateResult.SUCCESS;
    }

    /**
     * Downloads, uploads and sets watched and collected flags for episodes if they have changed on
     * trakt (or {@code isInitialSync} is true).
     *
     * @param isInitialSync If set, will upload any episodes flagged locally, but not flagged on
     * trakt. If not set, all watched and collected (and only those, e.g. not skipped flag) flags
     * will be removed prior to getting the actual flags from trakt (season by season).
     * @return Any of the {@link TraktTools} result codes.
     */
    public static int syncEpisodeFlags(Context context, HashSet<Integer> localShows, LastActivityMore activity,
            boolean isInitialSync) {
        if (activity.collected_at == null) {
            Timber.e("downloadEpisodeFlags: null collected_at");
            return FAILED;
        }
        if (activity.watched_at == null) {
            Timber.e("downloadEpisodeFlags: null watched_at");
            return FAILED;
        }

        TraktV2 trakt = ServiceUtils.getTraktV2WithAuth(context);
        if (trakt == null) {
            return FAILED_CREDENTIALS;
        }
        Sync traktSync = trakt.sync();

        // watched episodes
        if (isInitialSync || activity.watched_at.isAfter(TraktSettings.getLastEpisodesWatchedAt(context))) {
            try {
                // get watched episodes from trakt
                List<BaseShow> remoteShows = traktSync.watchedShows(Extended.DEFAULT_MIN);
                if (remoteShows == null) {
                    Timber.e("downloadEpisodeFlags: null watched response");
                    return FAILED_API;
                }

                // apply database updates, if initial sync upload diff
                int resultCode = applyEpisodeFlagChanges(context, traktSync, remoteShows, localShows, Flag.WATCHED,
                        isInitialSync);
                if (resultCode < 0) {
                    // upload failed, abort
                    return resultCode;
                }
            } catch (RetrofitError e) {
                Timber.e(e, "downloadEpisodeFlags: watched download failed");
                return FAILED_API;
            } catch (OAuthUnauthorizedException e) {
                TraktCredentials.get(context).setCredentialsInvalid();
                return FAILED_CREDENTIALS;
            }

            // store new last activity time
            PreferenceManager.getDefaultSharedPreferences(context).edit()
                    .putLong(TraktSettings.KEY_LAST_EPISODES_WATCHED_AT, activity.watched_at.getMillis()).apply();

            Timber.d("downloadEpisodeFlags: success for watched");
        }

        // collected episodes
        if (isInitialSync || activity.collected_at.isAfter(TraktSettings.getLastEpisodesCollectedAt(context))) {
            try {
                // get collected episodes from trakt
                List<BaseShow> remoteShows = traktSync.collectionShows(Extended.DEFAULT_MIN);
                if (remoteShows == null) {
                    Timber.e("downloadEpisodeFlags: null collected response");
                    return FAILED_API;
                }

                // apply database updates,  if initial sync upload diff
                int resultCode = applyEpisodeFlagChanges(context, traktSync, remoteShows, localShows,
                        Flag.COLLECTED, isInitialSync);
                if (resultCode < 0) {
                    // upload failed, abort
                    return resultCode;
                }
            } catch (RetrofitError e) {
                Timber.e(e, "downloadEpisodeFlags: collected download failed");
                return FAILED_API;
            } catch (OAuthUnauthorizedException e) {
                TraktCredentials.get(context).setCredentialsInvalid();
                return FAILED_CREDENTIALS;
            }

            // store new last activity time
            PreferenceManager.getDefaultSharedPreferences(context).edit()
                    .putLong(TraktSettings.KEY_LAST_EPISODES_WATCHED_AT, activity.collected_at.getMillis()).apply();

            Timber.d("downloadEpisodeFlags: success for collected");
        }

        return SUCCESS;
    }

    private static int applyEpisodeFlagChanges(Context context, Sync traktSync, List<BaseShow> traktShows,
            HashSet<Integer> localShows, Flag flag, boolean isMerging) throws OAuthUnauthorizedException {
        HashSet<Integer> localShowsNotOnTrakt = new HashSet<>(localShows);

        // loop through shows on trakt, update the ones existing locally
        for (BaseShow traktShow : traktShows) {
            if (traktShow.show == null || traktShow.show.ids == null || traktShow.show.ids.tvdb == null) {
                // trakt show misses required data
                continue;
            }
            if (!localShows.contains(traktShow.show.ids.tvdb)) {
                // trakt show not in local database
                continue;
            }

            localShowsNotOnTrakt.remove(traktShow.show.ids.tvdb);

            if (traktShow.seasons == null || traktShow.seasons.isEmpty()) {
                // trakt show has invalid episode data
                // do not touch show
                continue;
            }

            int resultCode = applyEpisodeFlagChanges(context, traktShow, flag, isMerging, traktSync);
            if (resultCode < 0) {
                // upload failed, abort
                return resultCode;
            }
        }

        // try to upload flags of all shows NOT on trakt
        // will skip shows that do not have a trakt id (e.g. can not be tracked with trakt, yet)
        switch (flag) {
        case WATCHED:
            return uploadWatchedEpisodes(context, traktSync, localShowsNotOnTrakt);
        case COLLECTED:
            return uploadCollectedEpisodes(context, traktSync, localShowsNotOnTrakt);
        }

        return SUCCESS;
    }

    /**
     * Flags the given episodes in the database (if they exist) and removes flags from all others
     * for this show.
     *
     * @param isMerging If set, you need to supply a {@code traktSync} instance.
     * @param traktSync If {@code isMerging} is set, needs to be NOT {@code null}.
     */
    public static int applyEpisodeFlagChanges(Context context, BaseShow traktShow, Flag flag, boolean isMerging,
            @Nullable Sync traktSync) throws OAuthUnauthorizedException {
        // guarantees:
        // show tvdb exists
        // show has at least one season
        // show is in local database

        final int showTvdbId = traktShow.show.ids.tvdb;
        final ArrayList<ContentProviderOperation> batch = new ArrayList<>();
        final Uri ofShowUri = SeriesGuideContract.Episodes.buildEpisodesOfShowUri(showTvdbId);

        // if not merging, clear all flags for episodes of this show
        if (!isMerging) {
            // op-apply is ensured as loop below will run at least once (at least one season)
            batch.add(ContentProviderOperation.newUpdate(ofShowUri).withSelection(flag.clearFlagSelection, null)
                    .withValue(flag.databaseColumn, flag.nonFlaggedValue).build());
        }

        // loop through seasons and build update ops for flagged episodes
        Set<Integer> traktFlaggedSeasonSet = new HashSet<>();
        List<SyncSeason> syncSeasons = new LinkedList<>();
        for (BaseSeason traktSeason : traktShow.seasons) {
            if (traktSeason.number == null || traktSeason.episodes == null) {
                // trakt season has no number
                continue;
            }

            // exclude from complete season upload
            traktFlaggedSeasonSet.add(traktSeason.number);

            // loop through episodes and add flag update ops
            Set<Integer> traktFlaggedEpisodeSet = new HashSet<>();
            for (BaseEpisode traktEpisode : traktSeason.episodes) {
                if (traktEpisode.number == null) {
                    // trakt episode has no number
                    continue;
                }

                // list as flagged on trakt
                traktFlaggedEpisodeSet.add(traktEpisode.number);

                batch.add(ContentProviderOperation.newUpdate(ofShowUri)
                        .withSelection(SeriesGuideContract.Episodes.SEASON + "=" + traktSeason.number + " AND "
                                + SeriesGuideContract.Episodes.NUMBER + "=" + traktEpisode.number, null)
                        .withValue(flag.databaseColumn, flag.flaggedValue).build());
            }

            if (isMerging) {
                // get local flagged episodes that are not flagged on trakt
                SyncSeason syncSeason = buildSeasonToUpload(context, showTvdbId, traktSeason.number,
                        traktFlaggedEpisodeSet, flag);
                if (syncSeason != null) {
                    syncSeasons.add(syncSeason);
                }
            }

            // apply batch of this season
            try {
                DBUtils.applyInSmallBatches(context, batch);
            } catch (OperationApplicationException e) {
                Timber.e("Applying flag changes failed: " + showTvdbId + " season: " + traktSeason.number
                        + " column: " + flag, e);
                // do not abort, try other seasons
                // some episodes might be in incorrect state, but next update should fix that
                // this includes the clear flags op failing
            }
            batch.clear();
        }

        // if merging: upload all local flagged episodes NOT flagged on trakt
        if (isMerging && traktSync != null) {
            // append local flagged episodes FOR ALL seasons NOT on trakt (== not handled above)
            addRemainingSeasonsToUpload(context, showTvdbId, traktFlaggedSeasonSet, flag, syncSeasons);

            // upload, if any
            if (!syncSeasons.isEmpty()) {
                Timber.d("applyEpisodeFlagChanges: upload " + syncSeasons.size() + " seasons for " + showTvdbId);
                SyncItems syncItems = new SyncItems()
                        .shows(new SyncShow().id(ShowIds.tvdb(showTvdbId)).seasons(syncSeasons));
                try {
                    switch (flag) {
                    case WATCHED:
                        traktSync.addItemsToWatchedHistory(syncItems);
                        break;
                    case COLLECTED:
                        traktSync.addItemsToCollection(syncItems);
                        break;
                    }
                } catch (RetrofitError e) {
                    Timber.e(e, "applyEpisodeFlagChanges: upload failed");
                    return FAILED_API;
                }
            }
        }

        return SUCCESS;
    }

    private static void addRemainingSeasonsToUpload(Context context, int showTvdbId,
            Set<Integer> traktFlaggedSeasonSet, Flag flag, List<SyncSeason> syncSeasons) {
        // query local seasons
        Cursor localSeasons = context.getContentResolver().query(
                SeriesGuideContract.Seasons.buildSeasonsOfShowUri(showTvdbId),
                new String[] { SeriesGuideContract.Seasons.COMBINED }, null, null, null);
        if (localSeasons == null) {
            // query failed
            return;
        }

        // build a list of local season numbers
        Set<Integer> localSeasonNumbers = new HashSet<>();
        while (localSeasons.moveToNext()) {
            localSeasonNumbers.add(localSeasons.getInt(0));
        }
        localSeasons.close();

        // loop through local seasons and add flagged episodes
        for (Integer localSeasonNumber : localSeasonNumbers) {
            if (traktFlaggedSeasonSet.contains(localSeasonNumber)) {
                // season was already processed as it is on trakt
                continue;
            }

            SyncSeason syncSeason = buildSeasonToUpload(context, showTvdbId, localSeasonNumber, null, flag);
            if (syncSeason != null) {
                syncSeasons.add(syncSeason);
            }
        }
    }

    /**
     * Returns a list of flagged episodes of a season that are not included in the given set.
     * Packaged ready for upload to trakt in a {@link com.uwetrottmann.trakt.v2.entities.SyncSeason}.
     *
     * @param flaggedEpisodeNumbers If {@code null}, all flagged episodes of the season are
     * returned.
     */
    private static SyncSeason buildSeasonToUpload(Context context, int showTvdbId, int seasonNumber,
            Set<Integer> flaggedEpisodeNumbers, Flag flag) {
        // query for flagged episodes of the given season
        String flaggedSelection;
        switch (flag) {
        case WATCHED:
            flaggedSelection = SeriesGuideContract.Episodes.SELECTION_WATCHED;
            break;
        case COLLECTED:
            flaggedSelection = SeriesGuideContract.Episodes.SELECTION_COLLECTED;
            break;
        default:
            return null;
        }

        Cursor localSeason = context.getContentResolver().query(
                SeriesGuideContract.Episodes.buildEpisodesOfShowUri(showTvdbId),
                new String[] { SeriesGuideContract.Episodes.NUMBER },
                SeriesGuideContract.Episodes.SEASON + "=" + seasonNumber + " AND " + flaggedSelection, null,
                SeriesGuideContract.Episodes.SORT_NUMBER_ASC);
        if (localSeason == null) {
            // query failed
            return null;
        }

        List<SyncEpisode> syncEpisodes = new LinkedList<>();
        while (localSeason.moveToNext()) {
            int episodeNumber = localSeason.getInt(0);
            if (flaggedEpisodeNumbers == null || !flaggedEpisodeNumbers.contains(episodeNumber)) {
                // episode NOT flagged on trakt
                syncEpisodes.add(new SyncEpisode().number(episodeNumber));
            }
        }
        localSeason.close();

        if (syncEpisodes.size() == 0) {
            // no local flagged episodes OR all local flagged episodes already flagged on trakt
            return null;
        }

        return new SyncSeason().number(seasonNumber).episodes(syncEpisodes);
    }

    /**
     * Uploads all collected episodes for the given shows to trakt.
     *
     * @return Any of the {@link TraktTools} result codes.
     */
    private static int uploadCollectedEpisodes(Context context, Sync traktSync, HashSet<Integer> localShows)
            throws OAuthUnauthorizedException {
        // loop through given shows
        SyncShow syncShow = new SyncShow();
        SyncItems syncItems = new SyncItems().shows(syncShow);
        for (int showTvdbId : localShows) {
            // check if the show has a trakt id (e.g. is on trakt)
            Integer showTraktId = ShowTools.getShowTraktId(context, showTvdbId);
            if (showTraktId == null) {
                // has no valid trakt id, skip upload
                continue;
            }

            syncShow.id(ShowIds.trakt(showTraktId));

            // query for watched episodes
            Cursor localEpisodes = context.getContentResolver().query(
                    SeriesGuideContract.Episodes.buildEpisodesOfShowUri(showTvdbId), EpisodesQuery.PROJECTION,
                    SeriesGuideContract.Episodes.SELECTION_COLLECTED, null,
                    SeriesGuideContract.Episodes.SORT_SEASON_ASC);
            if (localEpisodes == null) {
                Timber.e("uploadCollectedEpisodes: query failed");
                return FAILED;
            }

            // build a list of watched episodes
            List<SyncSeason> episodesToUpload = new LinkedList<>();
            buildEpisodeList(localEpisodes, episodesToUpload);
            localEpisodes.close();

            if (episodesToUpload.size() == 0) {
                // nothing to upload for this show
                continue;
            }

            // upload
            try {
                syncShow.seasons = episodesToUpload;
                traktSync.addItemsToCollection(syncItems);
            } catch (RetrofitError e) {
                Timber.e(e, "uploadCollectedEpisodes: upload failed");
                return FAILED_API;
            }
        }

        Timber.d("uploadCollectedEpisodes: uploaded " + localShows.size() + " shows");
        return SUCCESS;
    }

    /**
     * Uploads all watched episodes for the given shows to trakt.
     *
     * @return Any of the {@link TraktTools} result codes.
     */
    private static int uploadWatchedEpisodes(Context context, Sync traktSync, HashSet<Integer> localShows)
            throws OAuthUnauthorizedException {
        // loop through given shows
        SyncShow syncShow = new SyncShow();
        SyncItems syncItems = new SyncItems().shows(syncShow);
        for (int showTvdbId : localShows) {
            // check if the show has a trakt id (e.g. is on trakt)
            Integer showTraktId = ShowTools.getShowTraktId(context, showTvdbId);
            if (showTraktId == null) {
                // has no valid trakt id, skip upload
                continue;
            }

            syncShow.id(ShowIds.trakt(showTraktId));

            // query for watched episodes
            Cursor localEpisodes = context.getContentResolver().query(
                    SeriesGuideContract.Episodes.buildEpisodesOfShowUri(showTvdbId), EpisodesQuery.PROJECTION,
                    SeriesGuideContract.Episodes.SELECTION_WATCHED, null,
                    SeriesGuideContract.Episodes.SORT_SEASON_ASC);
            if (localEpisodes == null) {
                Timber.e("uploadWatchedEpisodes: query failed");
                return FAILED;
            }

            // build a list of watched episodes
            List<SyncSeason> episodesToUpload = new LinkedList<>();
            buildEpisodeList(localEpisodes, episodesToUpload);
            localEpisodes.close();

            if (episodesToUpload.size() == 0) {
                // nothing to upload for this show
                continue;
            }

            // upload
            try {
                syncShow.seasons = episodesToUpload;
                traktSync.addItemsToWatchedHistory(syncItems);
            } catch (RetrofitError e) {
                Timber.e(e, "uploadWatchedEpisodes: upload failed");
                return FAILED_API;
            }
        }

        Timber.d("uploadWatchedEpisodes: uploaded " + localShows.size() + " shows");
        return SUCCESS;
    }

    /**
     * @param episodesCursor Cursor of episodes sorted by season (ascending).
     * @param seasons Empty list.
     */
    private static void buildEpisodeList(Cursor episodesCursor, List<SyncSeason> seasons) {
        SyncSeason currentSeason = null;
        while (episodesCursor.moveToNext()) {
            int season = episodesCursor.getInt(EpisodesQuery.SEASON);
            int episode = episodesCursor.getInt(EpisodesQuery.EPISODE);

            // create new season if none exists or number has changed
            if (currentSeason == null || currentSeason.number != season) {
                currentSeason = new SyncSeason().number(season);
                currentSeason.episodes = new LinkedList<>();
                seasons.add(currentSeason);
            }

            // add episode
            currentSeason.episodes.add(new SyncEpisode().number(episode));
        }
    }

    public static String buildShowUrl(int showTvdbId) {
        return TraktLink.tvdb(showTvdbId) + "?id_type=show";
    }

    public static String buildEpisodeUrl(int episodeTvdbId) {
        return TraktLink.tvdb(episodeTvdbId) + "?id_type=episode";
    }

    public static String buildMovieUrl(int movieTmdbId) {
        return TraktLink.tmdb(movieTmdbId) + "?id_type=movie";
    }

    /**
     * Returns the given double as number string with one decimal digit, like "1.5".
     */
    public static String buildRatingString(Double rating) {
        return rating == null || rating == 0 ? "--" : String.format(Locale.getDefault(), "%.1f", rating);
    }

    /**
     * Builds a localized string like "x votes".
     */
    public static String buildRatingVotesString(Context context, Integer votes) {
        if (votes == null || votes < 0) {
            votes = 0;
        }
        return context.getResources().getQuantityString(R.plurals.votes, votes, votes);
    }

    /**
     * Converts a rating index from 1 to 10 into a localized string representation. Any other value
     * will return the local variant of "n/a".
     */
    public static String buildUserRatingString(Context context, int rating) {
        int resId;
        switch (rating) {
        case 1:
            resId = R.string.hate;
            break;
        case 2:
            resId = R.string.rating2;
            break;
        case 3:
            resId = R.string.rating3;
            break;
        case 4:
            resId = R.string.rating4;
            break;
        case 5:
            resId = R.string.rating5;
            break;
        case 6:
            resId = R.string.rating6;
            break;
        case 7:
            resId = R.string.rating7;
            break;
        case 8:
            resId = R.string.rating8;
            break;
        case 9:
            resId = R.string.rating9;
            break;
        case 10:
            resId = R.string.love;
            break;
        default:
            resId = R.string.action_rate;
            break;
        }

        return context.getString(resId);
    }

    /**
     * Look up a show's trakt id, may return {@code null} if not found.
     *
     * <p> <b>Always</b> supply trakt services <b>without</b> auth, as retrofit will crash on auth
     * errors.
     */
    public static String lookupShowTraktId(Context context, int showTvdbId) {
        Search traktSearch = ServiceUtils.getTraktV2(context).search();

        // 3 results: may be a show, season or episode (TVDb ids are not unique)
        List<SearchResult> searchResults = traktSearch.idLookup(IdType.TVDB, String.valueOf(showTvdbId), 1, 3);
        if (searchResults == null) {
            return null;
        }

        for (SearchResult result : searchResults) {
            if (result.episode != null) {
                // not a show result
                continue;
            }
            Show show = result.show;
            if (show != null && show.ids != null && show.ids.trakt != null) {
                return String.valueOf(show.ids.trakt);
            }
        }

        return null;
    }

    public interface EpisodesQuery {

        String[] PROJECTION = new String[] { SeriesGuideContract.Episodes.SEASON,
                SeriesGuideContract.Episodes.NUMBER };

        int SEASON = 0;
        int EPISODE = 1;
    }
}