View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.workflow.handler.themes;
23  
24  import static java.lang.String.format;
25  import static org.opencastproject.composer.layout.Offset.offset;
26  
27  import org.opencastproject.composer.layout.AbsolutePositionLayoutSpec;
28  import org.opencastproject.composer.layout.AnchorOffset;
29  import org.opencastproject.composer.layout.Anchors;
30  import org.opencastproject.composer.layout.Serializer;
31  import org.opencastproject.job.api.JobContext;
32  import org.opencastproject.mediapackage.MediaPackage;
33  import org.opencastproject.mediapackage.MediaPackageElement;
34  import org.opencastproject.mediapackage.MediaPackageElement.Type;
35  import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
36  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
37  import org.opencastproject.security.api.UnauthorizedException;
38  import org.opencastproject.series.api.SeriesException;
39  import org.opencastproject.series.api.SeriesService;
40  import org.opencastproject.serviceregistry.api.ServiceRegistry;
41  import org.opencastproject.staticfiles.api.StaticFileService;
42  import org.opencastproject.themes.Theme;
43  import org.opencastproject.themes.ThemesServiceDatabase;
44  import org.opencastproject.themes.persistence.ThemesServiceDatabaseException;
45  import org.opencastproject.util.MimeType;
46  import org.opencastproject.util.MimeTypes;
47  import org.opencastproject.util.NotFoundException;
48  import org.opencastproject.util.UnknownFileTypeException;
49  import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler;
50  import org.opencastproject.workflow.api.WorkflowInstance;
51  import org.opencastproject.workflow.api.WorkflowOperationException;
52  import org.opencastproject.workflow.api.WorkflowOperationHandler;
53  import org.opencastproject.workflow.api.WorkflowOperationResult;
54  import org.opencastproject.workflow.api.WorkflowOperationResult.Action;
55  import org.opencastproject.workspace.api.Workspace;
56  
57  import org.apache.commons.lang3.StringUtils;
58  import org.osgi.service.component.annotations.Component;
59  import org.osgi.service.component.annotations.Reference;
60  import org.slf4j.Logger;
61  import org.slf4j.LoggerFactory;
62  
63  import java.io.IOException;
64  import java.io.InputStream;
65  import java.net.URI;
66  import java.util.Arrays;
67  import java.util.Collections;
68  import java.util.List;
69  import java.util.Optional;
70  import java.util.UUID;
71  
72  /**
73   * The workflow definition for handling "theme" operations
74   */
75  @Component(
76      immediate = true,
77      service = WorkflowOperationHandler.class,
78      property = {
79          "service.description=Theme Workflow Operation Handler",
80          "workflow.operation=theme"
81      }
82  )
83  public class ThemeWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
84  
85    private static final String BUMPER_FLAVOR = "bumper-flavor";
86    private static final String BUMPER_TAGS = "bumper-tags";
87  
88    private static final String TRAILER_FLAVOR = "trailer-flavor";
89    private static final String TRAILER_TAGS = "trailer-tags";
90  
91    private static final String TITLE_SLIDE_FLAVOR = "title-slide-flavor";
92    private static final String TITLE_SLIDE_TAGS = "title-slide-tags";
93  
94    private static final String LICENSE_SLIDE_FLAVOR = "license-slide-flavor";
95    private static final String LICENSE_SLIDE_TAGS = "license-slide-tags";
96  
97    private static final String WATERMARK_FLAVOR = "watermark-flavor";
98    private static final String WATERMARK_TAGS = "watermark-tags";
99    private static final String WATERMARK_LAYOUT = "watermark-layout";
100   private static final String WATERMARK_LAYOUT_VARIABLE = "watermark-layout-variable";
101 
102   /** Workflow property names */
103   private static final String THEME_ACTIVE = "theme_active";
104   private static final String THEME_BUMPER_ACTIVE = "theme_bumper_active";
105   private static final String THEME_TRAILER_ACTIVE = "theme_trailer_active";
106   private static final String THEME_TITLE_SLIDE_ACTIVE = "theme_title_slide_active";
107   private static final String THEME_TITLE_SLIDE_UPLOADED = "theme_title_slide_uploaded";
108   private static final String THEME_WATERMARK_ACTIVE = "theme_watermark_active";
109 
110   /** The series theme property name */
111   private static final String THEME_PROPERTY_NAME = "theme";
112 
113   /** The logging facility */
114   private static final Logger logger = LoggerFactory.getLogger(ThemeWorkflowOperationHandler.class);
115 
116   private static final MediaPackageElementBuilderFactory elementBuilderFactory = MediaPackageElementBuilderFactory
117           .newInstance();
118 
119   /** The series service */
120   private SeriesService seriesService;
121 
122   /** The themes database service */
123   private ThemesServiceDatabase themesServiceDatabase;
124 
125   /** The static file service */
126   private StaticFileService staticFileService;
127 
128   /** The workspace */
129   private Workspace workspace;
130 
131   /** OSGi callback for the series service. */
132   @Reference
133   public void setSeriesService(SeriesService seriesService) {
134     this.seriesService = seriesService;
135   }
136 
137   /** OSGi callback for the themes database service. */
138   @Reference
139   public void setThemesServiceDatabase(ThemesServiceDatabase themesServiceDatabase) {
140     this.themesServiceDatabase = themesServiceDatabase;
141   }
142 
143   /** OSGi callback for the static file service. */
144   @Reference
145   public void setStaticFileService(StaticFileService staticFileService) {
146     this.staticFileService = staticFileService;
147   }
148 
149   /** OSGi callback for the workspace. */
150   @Reference
151   public void setWorkspace(Workspace workspace) {
152     this.workspace = workspace;
153   }
154 
155   @Reference
156   @Override
157   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
158     super.setServiceRegistry(serviceRegistry);
159   }
160 
161   @Override
162   public WorkflowOperationResult start(final WorkflowInstance workflowInstance, JobContext context)
163           throws WorkflowOperationException {
164     logger.debug("Running theme workflow operation on workflow {}", workflowInstance.getId());
165 
166     final MediaPackageElementFlavor bumperFlavor = Optional.ofNullable(
167         getOptConfig(workflowInstance, BUMPER_FLAVOR).orNull())
168         .map(MediaPackageElementFlavor::parseFlavor)
169         .orElse(new MediaPackageElementFlavor("branding", "bumper"));
170     final MediaPackageElementFlavor trailerFlavor = Optional.ofNullable(
171         getOptConfig(workflowInstance, TRAILER_FLAVOR).orNull())
172         .map(MediaPackageElementFlavor::parseFlavor)
173         .orElse(new MediaPackageElementFlavor("branding", "trailer"));
174     final MediaPackageElementFlavor titleSlideFlavor = Optional.ofNullable(
175         getOptConfig(workflowInstance, TITLE_SLIDE_FLAVOR).orNull())
176         .map(MediaPackageElementFlavor::parseFlavor)
177         .orElse(new MediaPackageElementFlavor("branding", "title-slide"));
178     final MediaPackageElementFlavor licenseSlideFlavor = Optional.ofNullable(
179         getOptConfig(workflowInstance, LICENSE_SLIDE_FLAVOR).orNull())
180         .map(MediaPackageElementFlavor::parseFlavor)
181         .orElse(new MediaPackageElementFlavor("branding", "license-slide"));
182     final MediaPackageElementFlavor watermarkFlavor = Optional.ofNullable(
183         getOptConfig(workflowInstance, WATERMARK_FLAVOR).orNull())
184         .map(MediaPackageElementFlavor::parseFlavor)
185         .orElse(new MediaPackageElementFlavor("branding", "watermark"));
186     final List<String> bumperTags = asList(workflowInstance.getConfiguration(BUMPER_TAGS));
187     final List<String> trailerTags = asList(workflowInstance.getConfiguration(TRAILER_TAGS));
188     final List<String> titleSlideTags = asList(workflowInstance.getConfiguration(TITLE_SLIDE_TAGS));
189     final List<String> licenseSlideTags = asList(workflowInstance.getConfiguration(LICENSE_SLIDE_TAGS));
190     final List<String> watermarkTags = asList(workflowInstance.getConfiguration(WATERMARK_TAGS));
191 
192     Optional<String> layoutStringOpt = Optional.ofNullable(getOptConfig(workflowInstance, WATERMARK_LAYOUT).orNull());
193     Optional<String> watermarkLayoutVariable = Optional.ofNullable(
194         getOptConfig(workflowInstance, WATERMARK_LAYOUT_VARIABLE).orNull());
195 
196     List<String> layoutList = layoutStringOpt
197         .map(s -> Arrays.asList(s.split(";")))
198         .orElse(Collections.emptyList());
199 
200     try {
201       MediaPackage mediaPackage = workflowInstance.getMediaPackage();
202       String series = mediaPackage.getSeries();
203       if (series == null) {
204         logger.info("Skipping theme workflow operation, no series assigned to mediapackage {}",
205                 mediaPackage.getIdentifier());
206         return createResult(Action.SKIP);
207       }
208 
209       Long themeId;
210       try {
211         themeId = Long.parseLong(seriesService.getSeriesProperty(series, THEME_PROPERTY_NAME));
212       } catch (NotFoundException e) {
213         logger.info("Skipping theme workflow operation, no theme assigned to series {} on mediapackage {}.", series,
214                 mediaPackage.getIdentifier());
215         return createResult(Action.SKIP);
216       } catch (UnauthorizedException e) {
217         logger.warn("Skipping theme workflow operation, user not authorized to perform operation:", e);
218         return createResult(Action.SKIP);
219       }
220 
221       Theme theme;
222       try {
223         theme = themesServiceDatabase.getTheme(themeId);
224       } catch (NotFoundException e) {
225         logger.warn("Skipping theme workflow operation, no theme with id {} found.", themeId);
226         return createResult(Action.SKIP);
227       }
228 
229       logger.info("Applying theme {} to mediapackage {}", themeId, mediaPackage.getIdentifier());
230 
231       /* Make theme settings available to workflow instance */
232       workflowInstance.setConfiguration(THEME_ACTIVE, Boolean.toString(
233                  theme.isBumperActive()
234               || theme.isTrailerActive()
235               || theme.isTitleSlideActive()
236               || theme.isWatermarkActive()
237           )
238       );
239       workflowInstance.setConfiguration(THEME_BUMPER_ACTIVE, Boolean.toString(theme.isBumperActive()));
240       workflowInstance.setConfiguration(THEME_TRAILER_ACTIVE, Boolean.toString(theme.isTrailerActive()));
241       workflowInstance.setConfiguration(THEME_TITLE_SLIDE_ACTIVE, Boolean.toString(theme.isTitleSlideActive()));
242       workflowInstance.setConfiguration(
243           THEME_TITLE_SLIDE_UPLOADED,
244           Boolean.toString(StringUtils.isNotBlank(theme.getTitleSlideBackground())));
245       workflowInstance.setConfiguration(THEME_WATERMARK_ACTIVE, Boolean.toString(theme.isWatermarkActive()));
246 
247       if (theme.isBumperActive() && StringUtils.isNotBlank(theme.getBumperFile())) {
248         try (InputStream bumper = staticFileService.getFile(theme.getBumperFile())) {
249           addElement(mediaPackage, bumperFlavor, bumperTags, bumper,
250                   staticFileService.getFileName(theme.getBumperFile()), Type.Track);
251         } catch (NotFoundException e) {
252           logger.warn("Bumper file {} not found in static file service, skip applying it", theme.getBumperFile());
253         }
254       }
255 
256       if (theme.isTrailerActive() && StringUtils.isNotBlank(theme.getTrailerFile())) {
257         try (InputStream trailer = staticFileService.getFile(theme.getTrailerFile())) {
258           addElement(mediaPackage, trailerFlavor, trailerTags, trailer,
259                   staticFileService.getFileName(theme.getTrailerFile()), Type.Track);
260         } catch (NotFoundException e) {
261           logger.warn("Trailer file {} not found in static file service, skip applying it", theme.getTrailerFile());
262         }
263       }
264 
265       if (theme.isTitleSlideActive()) {
266         if (StringUtils.isNotBlank(theme.getTitleSlideBackground())) {
267           try (InputStream titleSlideBackground = staticFileService.getFile(theme.getTitleSlideBackground())) {
268             addElement(mediaPackage, titleSlideFlavor, titleSlideTags, titleSlideBackground,
269                     staticFileService.getFileName(theme.getTitleSlideBackground()), Type.Attachment);
270           } catch (NotFoundException e) {
271             logger.warn("Title slide file {} not found in static file service, skip applying it",
272                     theme.getTitleSlideBackground());
273           }
274         }
275 
276         // TODO add the title slide metadata to the workflow properties to be used by the cover-image WOH
277         // String titleSlideMetadata = theme.getTitleSlideMetadata();
278       }
279 
280       if (theme.isLicenseSlideActive()) {
281         if (StringUtils.isNotBlank(theme.getLicenseSlideBackground())) {
282           try (InputStream licenseSlideBackground = staticFileService.getFile(theme.getLicenseSlideBackground())) {
283             addElement(mediaPackage, licenseSlideFlavor, licenseSlideTags, licenseSlideBackground,
284                     staticFileService.getFileName(theme.getLicenseSlideBackground()), Type.Attachment);
285           } catch (NotFoundException e) {
286             logger.warn("License slide file {} not found in static file service, skip applying it",
287                     theme.getLicenseSlideBackground());
288           }
289         } else {
290           // TODO define what to do here (maybe extract image as background)
291         }
292 
293         // TODO add the license slide description to the workflow properties to be used by the cover-image WOH
294         // String licenseSlideDescription = theme.getLicenseSlideDescription();
295       }
296 
297       if (theme.isWatermarkActive() && StringUtils.isNotBlank(theme.getWatermarkFile())) {
298         try (InputStream watermark = staticFileService.getFile(theme.getWatermarkFile())) {
299           addElement(mediaPackage, watermarkFlavor, watermarkTags, watermark,
300                   staticFileService.getFileName(theme.getWatermarkFile()), Type.Attachment);
301         } catch (NotFoundException e) {
302           logger.warn("Watermark file {} not found in static file service, skip applying it", theme.getWatermarkFile());
303         }
304 
305         if (layoutStringOpt.isEmpty() || watermarkLayoutVariable.isEmpty()) {
306           throw new WorkflowOperationException(format("Configuration key '%s' or '%s' is either missing or empty",
307                   WATERMARK_LAYOUT, WATERMARK_LAYOUT_VARIABLE));
308         }
309 
310         AbsolutePositionLayoutSpec watermarkLayout = parseLayout(theme.getWatermarkPosition());
311         layoutList.set(layoutList.size() - 1, Serializer.json(watermarkLayout).toJson());
312         layoutStringOpt = Optional.of(String.join(";", layoutList));
313       }
314 
315       if (watermarkLayoutVariable.isPresent() && layoutStringOpt.isPresent()) {
316         workflowInstance.setConfiguration(watermarkLayoutVariable.get(), layoutStringOpt.get());
317       }
318 
319       return createResult(mediaPackage, Action.CONTINUE);
320     } catch (SeriesException | ThemesServiceDatabaseException | IllegalStateException | IllegalArgumentException
321             | IOException e) {
322       throw new WorkflowOperationException(e);
323     }
324   }
325 
326   private AbsolutePositionLayoutSpec parseLayout(String watermarkPosition) {
327     switch (watermarkPosition) {
328       case "topLeft":
329         return new AbsolutePositionLayoutSpec(new AnchorOffset(Anchors.TOP_LEFT, Anchors.TOP_LEFT, offset(20, 20)));
330       case "topRight":
331         return new AbsolutePositionLayoutSpec(new AnchorOffset(Anchors.TOP_RIGHT, Anchors.TOP_RIGHT, offset(-20, 20)));
332       case "bottomLeft":
333         return new AbsolutePositionLayoutSpec(new AnchorOffset(Anchors.BOTTOM_LEFT, Anchors.BOTTOM_LEFT,
334                 offset(20, -20)));
335       case "bottomRight":
336         return new AbsolutePositionLayoutSpec(new AnchorOffset(Anchors.BOTTOM_RIGHT, Anchors.BOTTOM_RIGHT, offset(-20,
337                 -20)));
338       default:
339         throw new IllegalStateException("Unknown watermark position: " + watermarkPosition);
340     }
341   }
342 
343   private void addElement(MediaPackage mediaPackage, final MediaPackageElementFlavor flavor, final List<String> tags,
344           InputStream file, String filename, Type type) throws IOException {
345     MediaPackageElement element = elementBuilderFactory.newElementBuilder().newElement(type, flavor);
346     element.setIdentifier(UUID.randomUUID().toString());
347     for (String tag : tags) {
348       element.addTag(tag);
349     }
350     URI uri = workspace.put(mediaPackage.getIdentifier().toString(), element.getIdentifier(), filename, file);
351     element.setURI(uri);
352     try {
353       MimeType mimeType = MimeTypes.fromString(filename);
354       element.setMimeType(mimeType);
355     } catch (UnknownFileTypeException e) {
356       logger.warn("Unable to detect the mime type of file {}", filename);
357     }
358     mediaPackage.add(element);
359   }
360 }