All Downloads are FREE. Search and download functionalities are using the official Maven repository.

nl.vpro.api.client.utils.NpoApiMediaUtil Maven / Gradle / Ivy

package nl.vpro.api.client.utils;

import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.core.Response;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.ConnectException;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;

import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.meeuw.functional.Consumers;

import com.google.common.cache.*;
import com.google.common.util.concurrent.UncheckedExecutionException;

import nl.vpro.api.client.frontend.NpoApiClients;
import nl.vpro.domain.api.*;
import nl.vpro.domain.api.media.*;
import nl.vpro.domain.media.*;
import nl.vpro.jackson2.JsonArrayIterator;
import nl.vpro.util.*;

import static nl.vpro.api.client.utils.ChangesFeedParameters.changesParameters;
import static nl.vpro.api.client.utils.MediaRestClientUtils.unwrapIO;
import static nl.vpro.domain.api.Result.Total.equalsTo;

/**
 * Wrapper around {@link NpoApiClients}, that provides things like:
 * 
  • rate limiting
  • caching
  • un paging of calls that require paging. if the api enforces a max of at most e.g. 240, calls in this utility will accept any max, and do paging implicitly
  • less arguments. Some of the Rest service interface want arguments like request and response object which should at the client side simply remain null (btw I think there are no much of that kind of methods left
  • exception handling
  • Parsing of input stream if that it the return value (huge results like {@link nl.vpro.api.rs.v3.media.MediaRestService#changes(String, String, Long, String, String, Integer, Boolean, Deletes, Tail, String)} and {@link nl.vpro.api.rs.v3.media.MediaRestService#iterate(MediaForm, String, String, Long, Integer)} have that.
* @author Michiel Meeuwissen * @see NpoApiPageUtil * @see NpoApiImageUtil */ @Named @Slf4j public class NpoApiMediaUtil implements MediaProvider { protected static ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1); final NpoApiClients clients; final NpoApiRateLimiter limiter; // TODO arrange caching via ehcache (ehcache4guice or something) private int cacheSize = 500; private Duration cacheTTL = Duration.ofMinutes(5); private boolean iterateLogProgress = true; LoadingCache> cache = buildCache(); private static long maxWindow = 10000; private Instant loggedAboutConnect = Instant.EPOCH; @Inject public NpoApiMediaUtil(@NotNull NpoApiClients clients, @NotNull NpoApiRateLimiter limiter) { this.clients = clients; this.limiter = limiter; } @lombok.Builder private NpoApiMediaUtil( @NotNull NpoApiClients clients, @Nullable NpoApiRateLimiter limiter, boolean iterateLogProgress, int cacheSize, Duration cacheTTL ) { this.clients = clients; this.limiter = limiter == null ? new NpoApiRateLimiter() : limiter; this.cacheTTL = cacheTTL; this.cacheSize = cacheSize; this.iterateLogProgress = iterateLogProgress; } public NpoApiMediaUtil(NpoApiClients clients) { this(clients, new NpoApiRateLimiter()); } public void clearCache() { cache.invalidateAll(); clients.clearBrowserCache(); } @Named("npo-api-mediautil.cachesize") public void setCacheSize(int size) { cacheSize = size; cache = buildCache(); } @Named("npo-api-mediautil.cacheExpiry") public void setCacheExpiry(String ttl) { this.cacheTTL = TimeUtils.parseDuration(ttl).orElse(Duration.ofMinutes(5)); cache = buildCache(); } private LoadingCache> buildCache() { return CacheBuilder.newBuilder() .concurrencyLevel(4) .maximumSize(cacheSize) .expireAfterWrite(cacheTTL.toMillis(), TimeUnit.MILLISECONDS) .build( new CacheLoader>() { @Override public @NonNull Optional load(@NonNull String mid) { limiter.acquire(); try { MediaObject object = MediaRestClientUtils.loadOrNull(clients.getMediaService(), mid); limiter.upRate(); //return Optional.ofNullable(object); return Optional.ofNullable(object); } catch (RuntimeException se) { limiter.downRate(); throw se; } } }); } @SuppressWarnings("unchecked") public T loadOrNull(String id) throws IOException{ try { return (T) cache.get(id).orElse(null); } catch (ExecutionException | UncheckedExecutionException e) { if (e.getCause() instanceof IOException ioException) { throw ioException; } if (e.getCause() instanceof RuntimeException runtimeException) { throw runtimeException; } throw new RuntimeException(e); } } public void invalidateCache() { cache.invalidateAll(); } public MediaResult listDescendants(String mid, Order order) { limiter.acquire(); try { MediaResult result = clients.getMediaService().listDescendants(mid, null, null, order.toString(), 0L, 200); limiter.upRate(); return result; } catch (Exception e) { limiter.downRate(); throw e; } } /** * Given an api with offset/max formalism, unpage this. The 'maximal' match of NPO API is * 240, this allows for large max sizes. */ public MediaResult unPage( BiFunction supplier, Predicate filter, int max) { limiter.acquire(); if (filter == null) { filter = mediaObject -> true; } try { List result = new ArrayList<>(); long offset = 0L; int batch = 50; long total; long found = 0; do { MediaResult list = supplier.apply(batch, offset); total = list.getTotal(); if (list.getSize() == 0) { break; } list.getItems().stream() .filter(filter).forEach(o -> { if (result.size() < max) { result.add(o); } }); offset += batch; found += list.getSize(); if (offset > maxWindow - batch) { log.info("Offset is getting to big. Breaking"); break; } } while (found < total && result.size() < max); limiter.upRate(); return new MediaResult(result, 0L, max, equalsTo(total)); } catch (Exception e) { limiter.downRate(); throw e; } } /** * The api has limits on max size, requiring you to use paging when you want more. * This method arranges that. */ public ProgramResult unPageProgramResult(BiFunction supplier, Predicate filter, int max) { limiter.acquire(); if (filter == null) { filter = mediaObject -> true; } try { List result = new ArrayList<>(); long offset = 0L; int batch = 50; long total; long found = 0; do { ProgramResult list = supplier.apply(batch, offset); total = list.getTotal(); if (list.getSize() == 0) { break; } list.getItems().stream().filter(filter).forEach(o -> { if (result.size() < max) { result.add(o); } }); offset += batch; found += list.getSize(); if (offset > maxWindow - batch) { log.info("Offset is getting to big. Breaking"); break; } } while (found < total && result.size() < max); limiter.upRate(); return new ProgramResult(result, 0L, max, equalsTo(total)); } catch (Exception e) { limiter.downRate(); throw e; } } /** * Wraps {@link nl.vpro.api.rs.v3.media.MediaRestService#listDescendants(String, String, String, String, Long, Integer)}, but with less arguments. * @param max The max number of results you want. If this is bigger than the maximum accepted by the API, implicit paging will happen. */ public MediaResult listDescendants(String mid, Order order, Predicate filter, int max) { BiFunction descendants = (batch, offset) -> clients.getMediaService().listDescendants(mid, null, null, order.toString(), offset, batch); return unPage(descendants, filter, max); } public MediaResult listMembers(String mid, Order order, Predicate filter, int max) { BiFunction members = (batch, offset) -> clients.getMediaService().listMembers(mid, null, null, order.toString(), offset, batch); return unPage(members, filter, max); } public ProgramResult listEpisodes(String mid, Order order, Predicate filter, int max) { BiFunction members = (batch, offset) -> clients.getMediaService().listEpisodes(mid, null,null, order.toString(), offset, batch); return unPageProgramResult(members, filter, max); } @SuppressWarnings({"unchecked", "OptionalAssignedToNull"}) public MediaObject[] load(String... id) throws IOException { Optional[] result = new Optional[id.length]; Set toRequest = new LinkedHashSet<>(); for (int i = 0; i < id.length; i++) { result[i] = cache.getIfPresent(id[i]); if (result[i] == null) { toRequest.add(id[i]); } else { log.debug("Using {} from cache", id[i]); } } if (!toRequest.isEmpty()) { limiter.acquire(); try { String[] array = toRequest.toArray(new String[0]); MediaObject[] requested = MediaRestClientUtils.load(clients.getMediaService(), array); for (int j = 0 ; j < array.length; j++) { Optional optional = Optional.ofNullable(requested[j]); cache.put(array[j], optional); for (int i = 0; i < id.length; i++) { if (id[i].equals(array[j])) { result[i] = optional; } } } limiter.upRate(); } catch (ProcessingException pe) { limiter.downRate(); unwrapIO(pe); throw pe; } catch (RuntimeException rte) { limiter.downRate(); throw rte; } } MediaObject[] resultArray = new MediaObject[id.length]; for (int i = 0; i < id.length; i++) { resultArray[i] = result[i].orElse(null); } return resultArray; } public CountedIterator changes(String profile, Instant since, Order order, Integer max) { return changes(profile, since, null, order, max); } public CountedIterator changes(String profile, Instant since, String mid, Order order, Integer max) { return changes(changesParameters() .profile(profile) .since(since) .mid(mid) .order(order) .max(max) .deletes(Deletes.ID_ONLY) .tail(Tail.IF_EMPTY) .build()); } public CountedIterator changes(String profile, Instant since, String mid, Order order, Integer max, Deletes deletes) { return changes(profile, since, mid, order, max, deletes, Tail.IF_EMPTY); } public CountedIterator changes(String profile, Instant since) { return changes(changesParameters().profile(profile).since(since).build()); } public CountedIterator changes(Instant since) { return changes(MediaSince.of(since)); } public CountedIterator changes(MediaSince since) { return changes(changesParameters().mediaSince(since)); } public CountedIterator changes(String profile, Instant since, String mid, Order order, Integer max, Deletes deletes, Tail tail) { return changes(changesParameters() .profile(profile) .since(since) .mid(mid) .order(order) .max(max) .deletes(deletes) .tail(tail) ); } @Getter @Setter private Duration waitBetweenChangeListening = Duration.ofSeconds(2); public Future subscribeToChanges(String profile, Instant since, BooleanSupplier doWhile, final Consumer listener) { return subscribeToChanges(profile, since, Deletes.ID_ONLY, doWhile, listener); } public Future subscribeToChanges(Instant since, BooleanSupplier doWhile, final Consumer listener) { return subscribeToChanges(null, since, doWhile, listener); } public Future subscribeToChanges( @Nullable String profile, final Instant initialSince, Deletes deletes, BooleanSupplier doWhile, final Consumer listener) { return subscribeToChanges( ChangesFeedParameters.changesParameters() .profile(profile) .since(initialSince) .deletes(deletes) .build(), doWhile, Consumers.ignoreArg1(listener) ).thenApply(MediaSince::getInstant); } public CompletableFuture subscribeToChanges( final ChangesFeedParameters parameters, final BooleanSupplier doWhile, final BiConsumer listener) { if (doWhile.getAsBoolean()) { return CompletableFuture.supplyAsync(() -> { ChangesFeedParameters effectiveParameters = parameters; MediaSince currentSince = parameters.getMediaSince(); while (doWhile.getAsBoolean() && !Thread.currentThread().isInterrupted()) { try (CountedIterator changes = changes(effectiveParameters)) { while (changes.hasNext()) { if (Thread.currentThread().isInterrupted()) { log.info("Breaking with remaining changes because thread {} is marked interrupted", Thread.currentThread()); break; } MediaChange change = changes.next(); currentSince = change.asSince(); listener.accept(currentSince, change); effectiveParameters = parameters.withMediaSince(currentSince); } } catch (NullPointerException npe) { log.error(npe.getClass().getSimpleName(), npe); } catch (ConnectException ce) { if (loggedAboutConnect.isBefore(Instant.now().minus(Duration.ofMinutes(5)))) { log.info(ce.getClass() + ":" + ce.getMessage()); loggedAboutConnect = Instant.now(); } else { log.debug(ce.getClass() + ":" + ce.getMessage()); } } catch (Exception e) { log.info(e.getClass() + ":" + e.getMessage()); } try { synchronized (listener) { listener.wait(waitBetweenChangeListening.toMillis()); } } catch (InterruptedException iae) { log.info("Interrupted"); Thread.currentThread().interrupt(); } } log.info("Ready listening for changes until: {}, interrupted: {}", doWhile.getAsBoolean(), Thread.currentThread().isInterrupted()); synchronized (listener) { listener.notifyAll(); } return currentSince; }, EXECUTOR_SERVICE); } else { log.info("No started changes listening, because doWhile condition is already false"); return CompletableFuture.completedFuture(parameters.getMediaSince()); } } public RedirectList redirects() { try (Response response = clients.getMediaService().redirects(null)) { return response.readEntity(RedirectList.class); } } public JsonArrayIterator changes(ChangesFeedParameters.Builder parameters) { return changes(parameters.build()); } public JsonArrayIterator changes(ChangesFeedParameters parameters) { limiter.acquire(); try { try { JsonArrayIterator result = MediaRestClientUtils.changes(clients.getMediaServiceNoTimeout(), parameters); limiter.upRate(); return result; } catch (ConnectException ce) { throw ce; } } catch (IOException e) { limiter.downRate(); throw new RuntimeException(clients + ":" + e.getMessage(), e); } } @Deprecated public JsonArrayIterator changes(String profile, Long since, Order order, Integer max) { limiter.acquire(); try { JsonArrayIterator result = MediaRestClientUtils.changes(clients.getMediaServiceNoTimeout(), profile, since, order, max); limiter.upRate(); return result; } catch (IOException e) { limiter.downRate(); throw new RuntimeException(clients + ":" + e.getMessage(), e); } } /** * Calls {@link nl.vpro.api.rs.v3.media.MediaRestService#iterate(MediaForm, String, String, Long, Integer)}, and wraps the resulting {@link java.io.InputStream} in an {@link Iterator} of {@link MediaObject}} */ public CloseableIterator iterate(MediaForm form, String profile) { limiter.acquire(); try { CloseableIterator result = MediaRestClientUtils .iterate(clients.getMediaServiceNoTimeout(), form, profile, iterateLogProgress); limiter.upRate(); return result; } catch (Throwable e) { limiter.downRate(); throw new RuntimeException(clients + ":" + e.getMessage(), e); } } /** * Essentially like {@link #load(String...)}, but *
    *
  1. without the checked exception *
  2. Implementing {@link MediaProvider#findByMid(boolean, String)} *
  3. Loading only one *
* @see {@link MediaProvider#findByMid(String)} */ @Override public T findByMid(boolean loadDeleted, String mid) { try { if (loadDeleted) { throw new UnsupportedOperationException(); } return (T) load(mid)[0]; } catch (IOException ioe) { return null; } } /** * It is (currently?) not possible to load deleted objects from the frontend api, so * this defaults to findByMid(false, mid)} */ @Override public T findByMid(String mid) { return findByMid(false, mid); } public MediaType getType(String mid) throws IOException { MediaObject object = load(mid)[0]; return object == null ? MediaType.MEDIA : object.getMediaType(); } @Override public String toString() { return String.valueOf(clients); } public NpoApiClients getClients() { return clients; } public boolean isAvailable() { return clients.isAvailable(); } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy