PlaylistsEndpoint.java

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community 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://opensource.org/licenses/ecl2.txt
 *
 * 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 org.opencastproject.adminui.endpoint;

import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static javax.servlet.http.HttpServletResponse.SC_CREATED;
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
import static javax.servlet.http.HttpServletResponse.SC_OK;
import static org.apache.commons.lang3.StringUtils.trimToNull;
import static org.opencastproject.index.service.util.JSONUtils.safeString;
import static org.opencastproject.index.service.util.RestUtils.okJsonList;
import static org.opencastproject.playlists.PlaylistRestService.SAMPLE_PLAYLIST_JSON;
import static org.opencastproject.util.DateTimeSupport.toUTC;
import static org.opencastproject.util.RestUtil.getEndpointUrl;
import static org.opencastproject.util.doc.rest.RestParameter.Type.INTEGER;
import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
import static org.opencastproject.util.doc.rest.RestParameter.Type.TEXT;

import org.opencastproject.elasticsearch.api.SearchIndexException;
import org.opencastproject.elasticsearch.api.SearchResult;
import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
import org.opencastproject.elasticsearch.index.objects.event.Event;
import org.opencastproject.elasticsearch.index.objects.event.EventSearchQuery;
import org.opencastproject.index.service.util.RestUtils;
import org.opencastproject.list.common.query.PlaylistsListQuery;
import org.opencastproject.playlists.Playlist;
import org.opencastproject.playlists.PlaylistAccessControlEntry;
import org.opencastproject.playlists.PlaylistEntry;
import org.opencastproject.playlists.PlaylistRestService;
import org.opencastproject.playlists.PlaylistService;
import org.opencastproject.rest.RestConstants;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.UnauthorizedException;
import org.opencastproject.systems.OpencastConstants;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.UrlSupport;
import org.opencastproject.util.data.Tuple;
import org.opencastproject.util.doc.rest.RestParameter;
import org.opencastproject.util.doc.rest.RestQuery;
import org.opencastproject.util.doc.rest.RestResponse;
import org.opencastproject.util.doc.rest.RestService;
import org.opencastproject.util.requests.SortCriterion;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;

import org.json.simple.parser.ParseException;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;

import javax.ws.rs.DELETE;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

@Path("/admin-ng/playlists")
@Produces(MediaType.APPLICATION_JSON)
@RestService(
    name = "playlistsproxyservice",
    title = "Admin UI - Playlists",
    abstractText = "This service provides the playlists data for the admin UI.",
    notes = {
        "This service offers playlist CRUD operations for the admin UI.",
        "<strong>Important:</strong> "
            + "<em>This service is for exclusive use by the module admin-ui. Its API might change "
            + "anytime without prior notice. Any dependencies other than the admin UI will be strictly ignored. "
            + "DO NOT use this for integration of third-party applications.</em>"
    }
)
@Component(
    immediate = true,
    service = PlaylistsEndpoint.class,
    property = {
        "service.description=Admin UI - Playlists Endpoint",
        "opencast.service.type=org.opencastproject.adminui.PlaylistsEndpoint",
        "opencast.service.path=/admin-ng/playlists"
    }
)
@JaxrsResource
public class PlaylistsEndpoint {

  /** The logging facility */
  private static final Logger logger = LoggerFactory.getLogger(PlaylistsEndpoint.class);

  private static final int DEFAULT_LIMIT = 100;

  /** Base URL of this endpoint */
  protected String endpointBaseUrl;

  /** The playlists service */
  private PlaylistService service;

  /** The playlists REST service for parsing utilities */
  private PlaylistRestService restService;

  /** The Elasticsearch index for looking up event metadata */
  private ElasticsearchIndex elasticsearchIndex;

  /** The security service for organization/user context */
  private SecurityService securityService;

  /** OSGi DI */
  @Reference
  public void setPlaylistService(PlaylistService playlistService) {
    this.service = playlistService;
  }

  @Reference
  public void setPlaylistRestService(PlaylistRestService playlistRestService) {
    this.restService = playlistRestService;
  }

  @Reference
  public void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
    this.elasticsearchIndex = elasticsearchIndex;
  }

  @Reference
  public void setSecurityService(SecurityService securityService) {
    this.securityService = securityService;
  }

  /** OSGi activation method */
  @Activate
  void activate(ComponentContext cc) {
    logger.info("Activating Admin UI - Playlists Endpoint");

    final Tuple<String, String> endpointUrl = getEndpointUrl(cc, OpencastConstants.SERVER_URL_PROPERTY,
        RestConstants.SERVICE_PATH_PROPERTY);
    endpointBaseUrl = UrlSupport.concat(endpointUrl.getA(), endpointUrl.getB());
  }

  @GET
  @Path("{id}")
  @RestQuery(
      name = "getPlaylist",
      description = "Get a playlist by ID.",
      returnDescription = "A playlist as JSON",
      pathParameters = {
          @RestParameter(name = "id", isRequired = true, description = "The playlist identifier", type = STRING)
      },
      responses = {
          @RestResponse(description = "Returns the playlist.", responseCode = SC_OK),
          @RestResponse(description = "The specified playlist does not exist.",
              responseCode = SC_NOT_FOUND),
          @RestResponse(description = "The user does not have permission to access this playlist.",
              responseCode = SC_FORBIDDEN),
          @RestResponse(description = "The request is invalid or inconsistent.",
              responseCode = SC_BAD_REQUEST)
      })
  public Response getPlaylist(
      @HeaderParam("Accept") String acceptHeader,
      @PathParam("id") String id) {
    try {
      Playlist playlist = service.getPlaylistById(id);
      return Response.ok(playlistToJson(playlist).toString(), MediaType.APPLICATION_JSON_TYPE).build();
    } catch (NotFoundException e) {
      logger.info("Playlist '{}' not found.", id);
      return Response.status(Response.Status.NOT_FOUND).build();
    } catch (UnauthorizedException e) {
      logger.warn("User doesn't have permission to access playlist '{}'.", id);
      return Response.status(Response.Status.FORBIDDEN).build();
    } catch (IllegalStateException e) {
      logger.warn("Invalid state when accessing playlist '{}': {}", id, e.getMessage());
      return Response.status(Response.Status.BAD_REQUEST).build();
    }
  }

  @GET
  @Path("")
  @RestQuery(
      name = "getPlaylists",
      description = "Get playlists. Playlists that you do not have read access to will not show up.",
      returnDescription = "A JSON object containing an array of playlists.",
      restParameters = {
          @RestParameter(name = "limit", isRequired = false, type = INTEGER,
              description = "The maximum number of results to return for a single request.", defaultValue = "100"),
          @RestParameter(name = "offset", isRequired = false, type = INTEGER,
              description = "The index of the first result to return.", defaultValue = "0"),
          @RestParameter(name = "sort", isRequired = false, type = STRING,
              description = "Sort the results based upon a sorting criteria. Must be in the form '<Sort "
                  + "Name>:ASC' or '<Sort Name>:DESC'. Sort Name is case sensitive."
                  + " Supported Sort Names are 'updated', 'title', and 'creator'.",
              defaultValue = "updated:ASC"),
          @RestParameter(name = "filter", isRequired = false, type = STRING,
              description = "Filter the results by the given criteria. The format is 'key:value', "
                  + "and multiple filters can be combined with commas. Supported filter keys: "
                  + "'textFilter' (case-insensitive search across title, description, and creator), "
                  + "'creator' (case-insensitive match on the creator field), "
                  + "'Updated' (date range in the form 'start/end' using ISO 8601 date-time values).",
              defaultValue = "textFilter:opencast,creator:john,Updated:2025-01-01/2026-12-31")
      },
      responses = {
          @RestResponse(description = "Returns the playlists.", responseCode = SC_OK),
          @RestResponse(description = "The request is invalid or inconsistent.",
              responseCode = SC_BAD_REQUEST)
      })
  public Response getPlaylists(
      @HeaderParam("Accept") String acceptHeader,
      @QueryParam("limit") Integer limit,
      @QueryParam("offset") Integer offset,
      @QueryParam("sort") String sort,
      @QueryParam("filter") String filter) {

    Optional<Integer> optLimit = Optional.ofNullable(limit);
    Optional<Integer> optOffset = Optional.ofNullable(offset);
    Optional<String> optSort = Optional.ofNullable(trimToNull(sort));

    // If the limit is set to 0, this is not taken into account.
    if (optLimit.isPresent() && limit == 0) {
      optLimit = Optional.empty();
    }

    List<Predicate<Playlist>> filterPredicates = new ArrayList<>();

    // Add filters
    Map<String, String> filters = RestUtils.parseFilter(filter);
    for (String name : filters.keySet()) {
      String value = filters.get(name);
      if (PlaylistsListQuery.FILTER_TEXT_NAME.equals(name)) {
        filterPredicates.add(containsIgnoreCase(Playlist::getTitle, value)
            .or(containsIgnoreCase(Playlist::getDescription, value))
            .or(containsIgnoreCase(Playlist::getCreator, value)));
      } else if (PlaylistsListQuery.FILTER_CREATOR_NAME.equals(name)) {
        filterPredicates.add(containsIgnoreCase(Playlist::getCreator, value));
      } else if (PlaylistsListQuery.FILTER_UPDATED_NAME.equals(name)) {
        try {
          Tuple<Date, Date> range = RestUtils.getFromAndToDateRange(value);
          filterPredicates.add(p -> p.getUpdated() != null
              && !p.getUpdated().before(range.getA())
              && !p.getUpdated().after(range.getB()));
        } catch (IllegalArgumentException e) {
          logger.warn("Could not parse Updated filter dates: {}", value);
        }
      } else {
        logger.debug("Unknown filter: {}", name);
      }
    }

    SortCriterion sortCriterion
        = new SortCriterion("updated", SortCriterion.Order.Ascending);
    if (optSort.isPresent()) {
      ArrayList<SortCriterion> sortCriteria
          = RestUtils.parseSortQueryParameter(optSort.get());
      for (SortCriterion criterion : sortCriteria) {
        switch (criterion.getFieldName()) {
          case "updated":
          case "creator":
          case "title":
            sortCriterion = criterion;
            break;
          default:
            logger.warn("Unknown sort criteria: {}",
                criterion.getFieldName());
            return Response.status(Response.Status.BAD_REQUEST).build();
        }
      }
    }

    // Fetch all playlists (filtering is done in memory since
    // playlists are not in the search index). Todo: Add to index?
    List<Playlist> allPlaylists
        = service.getPlaylists(Integer.MAX_VALUE, 0, sortCriterion);

    // Apply filters
    List<Playlist> filteredPlaylists = allPlaylists;
    for (Predicate<Playlist> predicate : filterPredicates) {
      filteredPlaylists = filteredPlaylists.stream()
          .filter(predicate).toList();
    }

    // Apply pagination
    int effectiveOffset = optOffset.orElse(0);
    int effectiveLimit = optLimit.orElse(DEFAULT_LIMIT);
    int actualOffset = Math.min(effectiveOffset, filteredPlaylists.size());
    int endIndex = Math.min(
        actualOffset + effectiveLimit, filteredPlaylists.size());
    List<Playlist> paginatedPlaylists
        = filteredPlaylists.subList(actualOffset, endIndex);

    List<JsonObject> playlistsList = new ArrayList<>();
    for (Playlist p : paginatedPlaylists) {
      playlistsList.add(playlistToJson(p));
    }

    return okJsonList(playlistsList, effectiveOffset, effectiveLimit,
        filteredPlaylists.size());
  }

  @POST
  @Path("")
  @RestQuery(
      name = "createPlaylist",
      description = "Creates a new playlist.",
      returnDescription = "The created playlist.",
      restParameters = {
          @RestParameter(name = "playlist", isRequired = true, description = "Playlist in JSON format", type = TEXT,
              defaultValue = SAMPLE_PLAYLIST_JSON)
      },
      responses = {
          @RestResponse(description = "Playlist created.", responseCode = SC_CREATED),
          @RestResponse(description = "The user does not have permission to create a playlist.",
              responseCode = SC_FORBIDDEN),
          @RestResponse(description = "The request is invalid or inconsistent.",
              responseCode = SC_BAD_REQUEST)
      })
  public Response createPlaylist(
      @HeaderParam("Accept") String acceptHeader,
      @FormParam("playlist") String playlistText) {
    try {
      // Parse JSON to playlist
      Playlist playlist = restService.parseJsonToPlaylist(playlistText);

      // Persist
      playlist = service.update(playlist);

      return Response.created(URI.create(getPlaylistUrl(playlist.getId())))
          .entity(playlistToJson(playlist).toString())
          .type(MediaType.APPLICATION_JSON_TYPE)
          .build();
    } catch (UnauthorizedException e) {
      logger.warn("User doesn't have permission to create playlist.");
      return Response.status(Response.Status.FORBIDDEN).build();
    } catch (ParseException | IOException | IllegalArgumentException e) {
      logger.warn("Invalid playlist JSON: {}", e.getMessage());
      return Response.status(Response.Status.BAD_REQUEST).build();
    }
  }

  @PUT
  @Path("{id}")
  @RestQuery(
      name = "updatePlaylist",
      description = "Updates an existing playlist.",
      returnDescription = "The updated playlist.",
      pathParameters = {
          @RestParameter(name = "id", isRequired = true, description = "Playlist identifier", type = STRING)
      },
      restParameters = {
          @RestParameter(name = "playlist", isRequired = true, description = "Playlist in JSON format", type = TEXT,
              defaultValue = SAMPLE_PLAYLIST_JSON)
      },
      responses = {
          @RestResponse(description = "Playlist updated.", responseCode = SC_OK),
          @RestResponse(description = "The user does not have permission to update this playlist.",
              responseCode = SC_FORBIDDEN),
          @RestResponse(description = "The request is invalid or inconsistent.",
              responseCode = SC_BAD_REQUEST)
      })
  public Response updatePlaylist(
      @HeaderParam("Accept") String acceptHeader,
      @PathParam("id") String id,
      @FormParam("playlist") String playlistText) {
    try {
      Playlist playlist = service.updateWithJson(id, playlistText);
      return Response.ok(playlistToJson(playlist).toString(), MediaType.APPLICATION_JSON_TYPE).build();
    } catch (UnauthorizedException e) {
      logger.warn("User doesn't have permission to update playlist '{}'.", id);
      return Response.status(Response.Status.FORBIDDEN).build();
    } catch (IOException | IllegalArgumentException e) {
      logger.warn("Invalid playlist JSON: {}", e.getMessage());
      return Response.status(Response.Status.BAD_REQUEST).build();
    }
  }

  @DELETE
  @Path("{id}")
  @RestQuery(
      name = "removePlaylist",
      description = "Removes a playlist.",
      returnDescription = "The removed playlist.",
      pathParameters = {
          @RestParameter(name = "id", isRequired = true, description = "Playlist identifier", type = STRING)
      },
      responses = {
          @RestResponse(description = "Playlist removed.", responseCode = SC_OK),
          @RestResponse(description = "No playlist with that identifier exists.",
              responseCode = SC_NOT_FOUND),
          @RestResponse(description = "The user does not have permission to delete this playlist.",
              responseCode = SC_FORBIDDEN)
      })
  public Response removePlaylist(
      @HeaderParam("Accept") String acceptHeader,
      @PathParam("id") String id) {
    try {
      Playlist playlist = service.remove(id);
      return Response.ok(playlistToJson(playlist).toString(), MediaType.APPLICATION_JSON_TYPE).build();
    } catch (NotFoundException e) {
      logger.info("Playlist '{}' not found.", id);
      return Response.status(Response.Status.NOT_FOUND).build();
    } catch (UnauthorizedException e) {
      logger.warn("User doesn't have permission to delete playlist '{}'.", id);
      return Response.status(Response.Status.FORBIDDEN).build();
    }
  }

  private JsonObject playlistToJson(Playlist playlist) {
    JsonObject json = new JsonObject();

    json.addProperty("id", playlist.getId());

    // Look up event metadata for all entries so the frontend doesn't have to
    Map<String, Event> eventMap = lookupEntryEvents(playlist.getEntries());

    JsonArray entriesArray = new JsonArray();
    for (PlaylistEntry entry : playlist.getEntries()) {
      entriesArray.add(playlistEntryToJson(entry, eventMap.get(entry.getContentId())));
    }
    json.add("entries", entriesArray);

    json.addProperty("title", safeString(playlist.getTitle()));
    json.addProperty("description", safeString(playlist.getDescription()));
    json.addProperty("creator", safeString(playlist.getCreator()));
    json.addProperty("updated", playlist.getUpdated() != null ? toUTC(playlist.getUpdated().getTime()) : "");

    JsonArray aceArray = new JsonArray();
    for (PlaylistAccessControlEntry ace : playlist.getAccessControlEntries()) {
      aceArray.add(playlistAccessControlEntryToJson(ace));
    }
    json.add("accessControlEntries", aceArray);

    return json;
  }

  private JsonObject playlistEntryToJson(PlaylistEntry playlistEntry, Event event) {
    JsonObject json = new JsonObject();

    json.addProperty("id", playlistEntry.getId());
    if (playlistEntry.getContentId() != null) {
      json.addProperty("contentId", playlistEntry.getContentId());
    } else {
      json.add("contentId", null);
    }

    json.add("type", enumToJSON(playlistEntry.getType()));

    // Include event metadata if available
    if (event != null) {
      json.addProperty("title", safeString(event.getTitle()));
      json.addProperty("start_date", safeString(event.getRecordingStartDate()));

      if (event.getSeriesName() != null) {
        JsonObject series = new JsonObject();
        series.addProperty("id", safeString(event.getSeriesId()));
        series.addProperty("title", safeString(event.getSeriesName()));
        json.add("series", series);
      }

      if (event.getPresenters() != null && !event.getPresenters().isEmpty()) {
        JsonArray presenters = new JsonArray();
        for (String presenter : event.getPresenters()) {
          presenters.add(presenter);
        }
        json.add("presenters", presenters);
      }
    }

    return json;
  }

  /**
   * Look up event metadata from search index for all playlist entries.
   */
  private Map<String, Event> lookupEntryEvents(List<PlaylistEntry> entries) {
    Map<String, Event> eventMap = new HashMap<>();
    String org = securityService.getOrganization().getId();
    var user = securityService.getUser();

    for (PlaylistEntry entry : entries) {
      String contentId = entry.getContentId();
      if (contentId == null || contentId.isEmpty()) {
        continue;
      }

      try {
        SearchResult<Event> result = elasticsearchIndex.getByQuery(
            new EventSearchQuery(org, user).withIdentifier(contentId));
        if (result.getPageSize() != 0) {
          eventMap.put(contentId, result.getItems()[0].getSource());
        }
      } catch (SearchIndexException e) {
        logger.warn("Could not look up event '{}': {}", contentId, e.getMessage());
      }
    }

    return eventMap;
  }

  private JsonObject playlistAccessControlEntryToJson(PlaylistAccessControlEntry ace) {
    JsonObject json = new JsonObject();

    json.addProperty("id", ace.getId());
    json.addProperty("allow", ace.isAllow());
    json.addProperty("role", ace.getRole());
    json.addProperty("action", ace.getAction());

    return json;
  }

  private JsonElement enumToJSON(Enum<?> e) {
    return e == null ? null : new JsonPrimitive(e.toString());
  }

  private String getPlaylistUrl(String playlistId) {
    return UrlSupport.concat(endpointBaseUrl, playlistId);
  }

  /** Build a case-insensitive contains predicate for a string field of a playlist. */
  private static Predicate<Playlist> containsIgnoreCase(Function<Playlist, String> getter, String value) {
    String lower = value.toLowerCase();
    return p -> {
      String field = getter.apply(p);
      return field != null && field.toLowerCase().contains(lower);
    };
  }
}