package org.atlasapi.media.channel;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.StreamSupport;
import javax.annotation.Nullable;
import org.atlasapi.persistence.ids.MongoSequentialIdGenerator;
import com.metabroadcast.common.base.Maybe;
import com.metabroadcast.common.ids.SubstitutionTableNumberCodec;
import com.metabroadcast.common.persistence.mongo.DatabasedMongo;
import com.metabroadcast.common.persistence.mongo.MongoConstants;
import com.metabroadcast.common.persistence.mongo.MongoQueryBuilder;
import com.metabroadcast.common.persistence.mongo.MongoSortBuilder;
import com.metabroadcast.common.stream.MoreCollectors;
import com.google.common.base.Equivalence;
import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicates;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;
import com.mongodb.BasicDBObject;
import com.mongodb.DBCollection;
import com.mongodb.DBCursor;
import com.mongodb.DBObject;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.metabroadcast.common.persistence.mongo.MongoBuilders.where;
import static com.metabroadcast.common.persistence.mongo.MongoConstants.SINGLE;
import static com.metabroadcast.common.persistence.mongo.MongoConstants.UPSERT;
import static org.atlasapi.media.channel.ChannelTranslator.ADVERTISE_FROM;
import static org.atlasapi.media.channel.ChannelTranslator.AVAILABLE_ON;
import static org.atlasapi.media.channel.ChannelTranslator.BROADCASTER;
import static org.atlasapi.media.channel.ChannelTranslator.CHANNEL_TYPE;
import static org.atlasapi.media.channel.ChannelTranslator.KEY;
import static org.atlasapi.media.channel.ChannelTranslator.MEDIA_TYPE;
import static org.atlasapi.media.channel.ChannelTranslator.PUBLISHER;
import static org.atlasapi.media.channel.ChannelTranslator.URI;
import static org.atlasapi.persistence.media.entity.IdentifiedTranslator.CANONICAL_URL;
import static org.atlasapi.persistence.media.entity.IdentifiedTranslator.IDS_NAMESPACE;
import static org.atlasapi.persistence.media.entity.IdentifiedTranslator.IDS_VALUE;
public class MongoChannelStore implements ServiceChannelStore {
public static final String COLLECTION = "channels";
private static final Logger log = LoggerFactory.getLogger(MongoChannelStore.class);
private static final ChannelTranslator translator = new ChannelTranslator();
private static final String NUMBERING_CHANNEL_GROUP_ID = Joiner.on('.')
.join(
ChannelTranslator.NUMBERINGS,
ChannelNumberingTranslator.CHANNEL_GROUP_KEY,
MongoConstants.ID
);
private static final Function<DBObject, Channel> DB_TO_CHANNEL_TRANSLATOR = input -> translator.fromDBObject(
input,
null
);
private final DBCollection collection;
private final ChannelGroupResolver channelGroupResolver;
private final ChannelGroupWriter channelGroupWriter;
private final Equivalence<Channel> channelEquivalence;
private MongoSequentialIdGenerator idGenerator;
private SubstitutionTableNumberCodec codec;
public MongoChannelStore(
DatabasedMongo mongo,
ChannelGroupResolver channelGroupResolver,
ChannelGroupWriter channelGroupWriter
) {
this(mongo, channelGroupResolver, channelGroupWriter, new DefaultEquivalence());
}
public MongoChannelStore(
DatabasedMongo mongo,
ChannelGroupResolver channelGroupResolver,
ChannelGroupWriter channelGroupWriter,
Equivalence<Channel> channelEquivalence
) {
this.channelGroupResolver = channelGroupResolver;
this.channelGroupWriter = channelGroupWriter;
this.collection = mongo.collection(COLLECTION);
this.idGenerator = new MongoSequentialIdGenerator(mongo, COLLECTION);
this.codec = new SubstitutionTableNumberCodec();
this.channelEquivalence = channelEquivalence;
}
@Override
public Maybe<Channel> fromKey(final String key) {
return Maybe.fromPossibleNullValue(
translator.fromDBObject(
collection.findOne(where().fieldEquals(KEY, key).build()),
null
)
);
}
@Override
public Maybe<Channel> fromId(long id) {
return Maybe.fromPossibleNullValue(
translator.fromDBObject(
collection.findOne(where().idEquals(id).build()),
null
)
);
}
@Override
public Maybe<Channel> fromUri(final String uri) {
return Maybe.fromPossibleNullValue(
translator.fromDBObject(
collection.findOne(where().fieldEquals(CANONICAL_URL, uri).build()),
null
)
);
}
@Override
public Iterable<Channel> forIds(Iterable<Long> ids) {
return Iterables.transform(
getOrderedCursor(where().longIdIn(ids).build()),
DB_TO_CHANNEL_TRANSLATOR::apply
);
}
@Override
public Iterable<Channel> all() {
return Iterables.transform(
getOrderedCursor(new BasicDBObject()),
DB_TO_CHANNEL_TRANSLATOR::apply
);
}
@Override
public Iterable<Channel> allChannels(ChannelQuery query) {
MongoQueryBuilder mongoQuery = new MongoQueryBuilder();
if (query.getBroadcaster().isPresent()) {
mongoQuery.fieldEquals(BROADCASTER, query.getBroadcaster().get().key());
}
if (query.getMediaType().isPresent()) {
mongoQuery.fieldEquals(MEDIA_TYPE, query.getMediaType().get().name());
}
if (query.getAvailableFrom().isPresent()) {
mongoQuery.fieldEquals(AVAILABLE_ON, query.getAvailableFrom().get().key());
}
if (query.getChannelGroups().isPresent()) {
mongoQuery.longFieldIn(NUMBERING_CHANNEL_GROUP_ID, query.getChannelGroups().get());
}
if (query.getGenres().isPresent()) {
mongoQuery.fieldIn(ChannelTranslator.GENRES_KEY, query.getGenres().get());
}
if (query.getAdvertisedOn().isPresent()) {
mongoQuery.fieldBeforeOrAt(ADVERTISE_FROM, query.getAdvertisedOn().get());
}
if (query.getPublisher().isPresent()) {
mongoQuery.fieldEquals(PUBLISHER, query.getPublisher().get().key());
}
if (query.getUri().isPresent()) {
mongoQuery.fieldEquals(URI, query.getUri().get());
}
if (query.getAliasNamespace().isPresent()) {
mongoQuery.fieldEquals(IDS_NAMESPACE, query.getAliasNamespace().get());
}
if (query.getAliasValue().isPresent()) {
mongoQuery.fieldEquals(IDS_VALUE, query.getAliasValue().get());
}
if (query.getChannelType().isPresent()) {
mongoQuery.fieldEquals(CHANNEL_TYPE, query.getChannelType().get().name());
}
return Iterables.transform(
getOrderedCursor(mongoQuery.build()),
DB_TO_CHANNEL_TRANSLATOR::apply
);
}
@Override
public Maybe<Channel> forAlias(String alias) {
MongoQueryBuilder query = new MongoQueryBuilder()
.fieldEquals("aliases", alias);
DBCursor cursor = getOrderedCursor(query.build());
if (Iterables.isEmpty(cursor)) {
return Maybe.nothing();
}
return Maybe.just(translator.fromDBObject(Iterables.getOnlyElement(cursor), null));
}
@Override
public Map<String, Channel> forAliases(String aliasPrefix) {
final Pattern prefixPattern = Pattern.compile(String.format(
"^%s",
Pattern.quote(aliasPrefix)
));
Iterable<Channel> channels = all();
Map<String, Channel> channelMap = Maps.newHashMap();
for (Channel channel : channels) {
for (String alias : Iterables.filter(
channel.getAliasUrls(),
Predicates.contains(prefixPattern)
)) {
if (channelMap.get(alias) == null) {
channelMap.put(alias, channel);
} else {
log.error("duplicate alias " + alias + " on channels " + channelMap.get(alias)
.getId() + " & " + channel.getId());
}
}
}
return ImmutableMap.copyOf(channelMap);
}
// this method fetches channels by its aliases that are stored as ids in Mongo
@Override
public Iterable<Channel> forKeyPairAlias(ChannelQuery channelQuery) {
MongoQueryBuilder queryBuilder = new MongoQueryBuilder();
queryBuilder.fieldEquals(IDS_NAMESPACE, channelQuery.getAliasNamespace().get());
queryBuilder.fieldEquals(IDS_VALUE, channelQuery.getAliasValue().get());
return StreamSupport.stream(getOrderedCursor(queryBuilder.build()).spliterator(), false)
.map(DB_TO_CHANNEL_TRANSLATOR)
.collect(MoreCollectors.toImmutableList());
}
private DBCursor getOrderedCursor(DBObject query) {
return collection.find(query)
.sort(new MongoSortBuilder().ascending(MongoConstants.ID).build());
}
@Override
public Channel createOrUpdate(Channel channel) {
checkNotNull(channel);
checkNotNull(channel.getUri());
Maybe<Channel> existing = fromUri(channel.getUri());
if (existing.hasValue()) {
maintainParentLinks(channel, existing.requireValue());
} else {
channel.setId(codec.decode(idGenerator.generate()).longValue());
}
updateNumberingsOnChannelGroups(channel, existing);
ensureParentReference(channel);
setLastUpdated(channel, existing.valueOrNull(), DateTime.now(DateTimeZone.UTC));
collection.update(
new BasicDBObject(URI, channel.getUri()),
translator.toDBObject(null, channel),
UPSERT,
SINGLE
);
return channel;
}
private void maintainParentLinks(Channel newChannel, Channel existingChannel) {
if (existingChannel.getParent() != null) {
if (newChannel.getParent() == null || !existingChannel.getParent()
.equals(newChannel.getParent())) {
Maybe<Channel> maybeOldParent = fromId(existingChannel.getParent());
Preconditions.checkState(
maybeOldParent.hasValue(),
String.format(
"Parent channel with id %s not found for channel with id %s",
newChannel.getParent(),
newChannel.getId()
)
);
Channel oldParent = maybeOldParent.requireValue();
Set<Long> variations = Sets.newHashSet(oldParent.getVariations());
variations.remove(existingChannel.getId());
oldParent.setVariationIds(variations);
collection.update(
new BasicDBObject(MongoConstants.ID, oldParent.getId()),
translator.toDBObject(null, oldParent),
UPSERT,
SINGLE
);
}
}
newChannel.setVariationIds(existingChannel.getVariations());
SetView<ChannelNumbering> difference = Sets.difference(
existingChannel.getChannelNumbers(),
newChannel.getChannelNumbers()
);
if (!difference.isEmpty()) {
for (ChannelNumbering oldNumbering : difference) {
Optional<ChannelGroup> maybeGroup = channelGroupResolver.channelGroupFor(
oldNumbering.getChannelGroup());
Preconditions.checkState(
maybeGroup.isPresent(),
String.format(
"ChannelGroup with id %s not found for channel with id %s",
oldNumbering.getChannelGroup(),
newChannel.getId()
)
);
ChannelGroup group = maybeGroup.get();
Set<ChannelNumbering> numberings = Sets.newHashSet(group.getChannelNumberings());
numberings.remove(oldNumbering);
group.setChannelNumberings(numberings);
channelGroupWriter.createOrUpdate(group);
}
}
}
private void updateNumberingsOnChannelGroups(Channel channel, Maybe<Channel> existingRecord) {
if (existingRecord.hasValue()
&& channel.getChannelNumbers()
.equals(existingRecord.requireValue().getChannelNumbers())) {
return;
}
if (existingRecord.hasValue()) {
removeLinks(
channel.getChannelNumbers(),
existingRecord.requireValue().getChannelNumbers()
);
}
Multimap<Long, ChannelNumbering> newChannelGroupMapping = ArrayListMultimap.create();
for (ChannelNumbering numbering : channel.getChannelNumbers()) {
numbering.setChannel(channel.getId());
newChannelGroupMapping.put(numbering.getChannelGroup(), numbering);
}
for (Long channelGroupId : newChannelGroupMapping.keySet()) {
Optional<ChannelGroup> maybeGroup = channelGroupResolver.channelGroupFor(channelGroupId);
Preconditions.checkState(
maybeGroup.isPresent(),
String.format(
"ChannelGroup with id %s not found for channel with id %s",
channelGroupId,
channel.getId()
)
);
ChannelGroup group = maybeGroup.get();
for (ChannelNumbering numbering : newChannelGroupMapping.get(channelGroupId)) {
group.addChannelNumbering(numbering);
}
channelGroupWriter.createOrUpdate(group);
}
}
private void ensureParentReference(Channel channel) {
if (channel.getParent() != null) {
Maybe<Channel> maybeParent = fromId(channel.getParent());
Preconditions.checkState(
maybeParent.hasValue(),
String.format(
"Parent channel with id %s not found for channel with id %s",
channel.getParent(),
channel.getId()
)
);
Channel parent = maybeParent.requireValue();
parent.addVariation(channel.getId());
collection.update(
new BasicDBObject(MongoConstants.ID, parent.getId()),
translator.toDBObject(null, parent),
UPSERT,
SINGLE
);
}
}
private void setLastUpdated(Channel current, @Nullable Channel previous, DateTime now) {
if (previous == null
|| current.getLastUpdated() == null
|| !channelEquivalence.equivalent(current, previous)) {
current.setLastUpdated(now);
}
}
/**
* Given a set of the new and existing ChannelNumberings, determines those numberings not in the
* new set, and removes them from their channel group.
*
* @param newNumbers
* @param existingNumbers
*/
private void removeLinks(final Set<ChannelNumbering> newNumbers,
Set<ChannelNumbering> existingNumbers) {
SetView<ChannelNumbering> expiredNumberings = Sets.difference(existingNumbers, newNumbers);
for (ChannelNumbering expired : expiredNumberings) {
removeNumbering(expired);
}
}
/**
* Resolves the ChannelGroup for a given numbering, removes all numberings on that group with a
* channel matching that of the provided numbering, then writes the group.
*
* @param expired
*/
private void removeNumbering(final ChannelNumbering expired) {
Optional<ChannelGroup> resolved = channelGroupResolver.channelGroupFor(expired.getChannelGroup());
if (!resolved.isPresent()) {
log.error(
"Tried to remove numbering {} from non-existent channel group {}",
expired,
expired.getChannelGroup()
);
}
Iterable<ChannelNumbering> nonExpired = Iterables.filter(
resolved.get().getChannelNumberings(),
input -> !input.getChannel().equals(expired.getChannel())
);
resolved.get().setChannelNumberings(nonExpired);
channelGroupWriter.createOrUpdate(resolved.get());
}
@Override
public void start() {
/* no-op */
}
@Override
public void shutdown() {
/* no-op */
}
private static class DefaultEquivalence extends Equivalence<Channel> {
@Override
protected boolean doEquivalent(@Nullable Channel a, @Nullable Channel b) {
return a == b
|| a != null
&& b != null
// Identified
&& Objects.equals(a.getId(), b.getId())
&& Objects.equals(a.getCanonicalUri(), b.getCanonicalUri())
&& Objects.equals(a.getCurie(), b.getCurie())
&& Objects.equals(a.getAliasUrls(), b.getAliasUrls())
&& Objects.equals(a.getAliases(), b.getAliases())
&& Objects.equals(a.getEquivalentTo(), b.getEquivalentTo())
// Channel
&& a.getSource() == b.getSource() && Objects.equals(a.getTitle(), b.getTitle())
&& Objects.equals(a.getImages(), b.getImages())
&& Objects.equals(a.getRelatedLinks(), b.getRelatedLinks())
&& a.getMediaType() == b.getMediaType()
&& Objects.equals(a.getKey(), b.getKey())
&& Objects.equals(a.getHighDefinition(), b.getHighDefinition())
&& Objects.equals(a.getRegional(), b.getRegional())
&& Objects.equals(a.getAdult(), b.getAdult())
&& Objects.equals(a.getTimeshift(), b.getTimeshift())
&& a.getBroadcaster() == b.getBroadcaster()
&& Objects.equals(a.getAdvertiseFrom(), b.getAdvertiseFrom())
&& Objects.equals(a.getAvailableFrom(), b.getAvailableFrom())
&& Objects.equals(a.getVariations(), b.getVariations())
&& Objects.equals(a.getParent(), b.getParent())
&& Objects.equals(a.getChannelNumbers(), b.getChannelNumbers())
&& Objects.equals(a.getStartDate(), b.getStartDate())
&& Objects.equals(a.getEndDate(), b.getEndDate())
&& Objects.equals(a.getGenres(), b.getGenres())
&& Objects.equals(a.getShortDescription(), b.getShortDescription())
&& Objects.equals(a.getMediumDescription(), b.getMediumDescription())
&& Objects.equals(a.getLongDescription(), b.getLongDescription())
&& Objects.equals(a.getRegion(), b.getRegion())
&& a.getChannelType() == b.getChannelType()
&& Objects.equals(a.getTargetRegions(), b.getTargetRegions())
&& Objects.equals(a.getInteractive(), b.getInteractive());
}
@Override
protected int doHash(Channel channel) {
return channel.hashCode();
}
}
}