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  
71  /**
72   * The workflow definition for handling "theme" operations
73   */
74  @Component(
75      immediate = true,
76      service = WorkflowOperationHandler.class,
77      property = {
78          "service.description=Theme Workflow Operation Handler",
79          "workflow.operation=theme"
80      }
81  )
82  public class ThemeWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
83  
84    private static final String BUMPER_FLAVOR = "bumper-flavor";
85    private static final String BUMPER_TAGS = "bumper-tags";
86  
87    private static final String TRAILER_FLAVOR = "trailer-flavor";
88    private static final String TRAILER_TAGS = "trailer-tags";
89  
90    private static final String TITLE_SLIDE_FLAVOR = "title-slide-flavor";
91    private static final String TITLE_SLIDE_TAGS = "title-slide-tags";
92  
93    private static final String LICENSE_SLIDE_FLAVOR = "license-slide-flavor";
94    private static final String LICENSE_SLIDE_TAGS = "license-slide-tags";
95  
96    private static final String WATERMARK_FLAVOR = "watermark-flavor";
97    private static final String WATERMARK_TAGS = "watermark-tags";
98    private static final String WATERMARK_LAYOUT = "watermark-layout";
99    private static final String WATERMARK_LAYOUT_VARIABLE = "watermark-layout-variable";
100 
101   /** Workflow property names */
102   private static final String THEME_ACTIVE = "theme_active";
103   private static final String THEME_BUMPER_ACTIVE = "theme_bumper_active";
104   private static final String THEME_TRAILER_ACTIVE = "theme_trailer_active";
105   private static final String THEME_TITLE_SLIDE_ACTIVE = "theme_title_slide_active";
106   private static final String THEME_TITLE_SLIDE_UPLOADED = "theme_title_slide_uploaded";
107   private static final String THEME_WATERMARK_ACTIVE = "theme_watermark_active";
108 
109   /** The series theme property name */
110   private static final String THEME_PROPERTY_NAME = "theme";
111 
112   /** The logging facility */
113   private static final Logger logger = LoggerFactory.getLogger(ThemeWorkflowOperationHandler.class);
114 
115   private static final MediaPackageElementBuilderFactory elementBuilderFactory = MediaPackageElementBuilderFactory
116           .newInstance();
117 
118   /** The series service */
119   private SeriesService seriesService;
120 
121   /** The themes database service */
122   private ThemesServiceDatabase themesServiceDatabase;
123 
124   /** The static file service */
125   private StaticFileService staticFileService;
126 
127   /** The workspace */
128   private Workspace workspace;
129 
130   /** OSGi callback for the series service. */
131   @Reference
132   public void setSeriesService(SeriesService seriesService) {
133     this.seriesService = seriesService;
134   }
135 
136   /** OSGi callback for the themes database service. */
137   @Reference
138   public void setThemesServiceDatabase(ThemesServiceDatabase themesServiceDatabase) {
139     this.themesServiceDatabase = themesServiceDatabase;
140   }
141 
142   /** OSGi callback for the static file service. */
143   @Reference
144   public void setStaticFileService(StaticFileService staticFileService) {
145     this.staticFileService = staticFileService;
146   }
147 
148   /** OSGi callback for the workspace. */
149   @Reference
150   public void setWorkspace(Workspace workspace) {
151     this.workspace = workspace;
152   }
153 
154   @Reference
155   @Override
156   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
157     super.setServiceRegistry(serviceRegistry);
158   }
159 
160   @Override
161   public WorkflowOperationResult start(final WorkflowInstance workflowInstance, JobContext context)
162           throws WorkflowOperationException {
163     logger.debug("Running theme workflow operation on workflow {}", workflowInstance.getId());
164 
165     final MediaPackageElementFlavor bumperFlavor = getOptConfig(workflowInstance, BUMPER_FLAVOR)
166         .map(MediaPackageElementFlavor::parseFlavor)
167         .orElse(new MediaPackageElementFlavor("branding", "bumper"));
168     final MediaPackageElementFlavor trailerFlavor = getOptConfig(workflowInstance, TRAILER_FLAVOR)
169         .map(MediaPackageElementFlavor::parseFlavor)
170         .orElse(new MediaPackageElementFlavor("branding", "trailer"));
171     final MediaPackageElementFlavor titleSlideFlavor = getOptConfig(workflowInstance, TITLE_SLIDE_FLAVOR)
172         .map(MediaPackageElementFlavor::parseFlavor)
173         .orElse(new MediaPackageElementFlavor("branding", "title-slide"));
174     final MediaPackageElementFlavor licenseSlideFlavor = getOptConfig(workflowInstance, LICENSE_SLIDE_FLAVOR)
175         .map(MediaPackageElementFlavor::parseFlavor)
176         .orElse(new MediaPackageElementFlavor("branding", "license-slide"));
177     final MediaPackageElementFlavor watermarkFlavor = getOptConfig(workflowInstance, WATERMARK_FLAVOR)
178         .map(MediaPackageElementFlavor::parseFlavor)
179         .orElse(new MediaPackageElementFlavor("branding", "watermark"));
180     final List<String> bumperTags = asList(workflowInstance.getConfiguration(BUMPER_TAGS));
181     final List<String> trailerTags = asList(workflowInstance.getConfiguration(TRAILER_TAGS));
182     final List<String> titleSlideTags = asList(workflowInstance.getConfiguration(TITLE_SLIDE_TAGS));
183     final List<String> licenseSlideTags = asList(workflowInstance.getConfiguration(LICENSE_SLIDE_TAGS));
184     final List<String> watermarkTags = asList(workflowInstance.getConfiguration(WATERMARK_TAGS));
185 
186     Optional<String> layoutStringOpt = getOptConfig(workflowInstance, WATERMARK_LAYOUT);
187     Optional<String> watermarkLayoutVariable = getOptConfig(workflowInstance, WATERMARK_LAYOUT_VARIABLE);
188 
189     List<String> layoutList = layoutStringOpt
190         .map(s -> Arrays.asList(s.split(";")))
191         .orElse(Collections.emptyList());
192 
193     try {
194       MediaPackage mediaPackage = workflowInstance.getMediaPackage();
195       String series = mediaPackage.getSeries();
196       if (series == null) {
197         logger.info("Skipping theme workflow operation, no series assigned to mediapackage {}",
198                 mediaPackage.getIdentifier());
199         return createResult(Action.SKIP);
200       }
201 
202       Long themeId;
203       try {
204         themeId = Long.parseLong(seriesService.getSeriesProperty(series, THEME_PROPERTY_NAME));
205       } catch (NotFoundException e) {
206         logger.info("Skipping theme workflow operation, no theme assigned to series {} on mediapackage {}.", series,
207                 mediaPackage.getIdentifier());
208         return createResult(Action.SKIP);
209       } catch (UnauthorizedException e) {
210         logger.warn("Skipping theme workflow operation, user not authorized to perform operation:", e);
211         return createResult(Action.SKIP);
212       }
213 
214       Theme theme;
215       try {
216         theme = themesServiceDatabase.getTheme(themeId);
217       } catch (NotFoundException e) {
218         logger.warn("Skipping theme workflow operation, no theme with id {} found.", themeId);
219         return createResult(Action.SKIP);
220       }
221 
222       logger.info("Applying theme {} to mediapackage {}", themeId, mediaPackage.getIdentifier());
223 
224       /* Make theme settings available to workflow instance */
225       workflowInstance.setConfiguration(THEME_ACTIVE, Boolean.toString(
226                  theme.isBumperActive()
227               || theme.isTrailerActive()
228               || theme.isTitleSlideActive()
229               || theme.isWatermarkActive()
230           )
231       );
232       workflowInstance.setConfiguration(THEME_BUMPER_ACTIVE, Boolean.toString(theme.isBumperActive()));
233       workflowInstance.setConfiguration(THEME_TRAILER_ACTIVE, Boolean.toString(theme.isTrailerActive()));
234       workflowInstance.setConfiguration(THEME_TITLE_SLIDE_ACTIVE, Boolean.toString(theme.isTitleSlideActive()));
235       workflowInstance.setConfiguration(
236           THEME_TITLE_SLIDE_UPLOADED,
237           Boolean.toString(StringUtils.isNotBlank(theme.getTitleSlideBackground())));
238       workflowInstance.setConfiguration(THEME_WATERMARK_ACTIVE, Boolean.toString(theme.isWatermarkActive()));
239 
240       if (theme.isBumperActive() && StringUtils.isNotBlank(theme.getBumperFile())) {
241         try (InputStream bumper = staticFileService.getFile(theme.getBumperFile())) {
242           addElement(mediaPackage, bumperFlavor, bumperTags, bumper,
243                   staticFileService.getFileName(theme.getBumperFile()), Type.Track);
244         } catch (NotFoundException e) {
245           logger.warn("Bumper file {} not found in static file service, skip applying it", theme.getBumperFile());
246         }
247       }
248 
249       if (theme.isTrailerActive() && StringUtils.isNotBlank(theme.getTrailerFile())) {
250         try (InputStream trailer = staticFileService.getFile(theme.getTrailerFile())) {
251           addElement(mediaPackage, trailerFlavor, trailerTags, trailer,
252                   staticFileService.getFileName(theme.getTrailerFile()), Type.Track);
253         } catch (NotFoundException e) {
254           logger.warn("Trailer file {} not found in static file service, skip applying it", theme.getTrailerFile());
255         }
256       }
257 
258       if (theme.isTitleSlideActive()) {
259         if (StringUtils.isNotBlank(theme.getTitleSlideBackground())) {
260           try (InputStream titleSlideBackground = staticFileService.getFile(theme.getTitleSlideBackground())) {
261             addElement(mediaPackage, titleSlideFlavor, titleSlideTags, titleSlideBackground,
262                     staticFileService.getFileName(theme.getTitleSlideBackground()), Type.Attachment);
263           } catch (NotFoundException e) {
264             logger.warn("Title slide file {} not found in static file service, skip applying it",
265                     theme.getTitleSlideBackground());
266           }
267         }
268 
269         // TODO add the title slide metadata to the workflow properties to be used by the cover-image WOH
270         // String titleSlideMetadata = theme.getTitleSlideMetadata();
271       }
272 
273       if (theme.isLicenseSlideActive()) {
274         if (StringUtils.isNotBlank(theme.getLicenseSlideBackground())) {
275           try (InputStream licenseSlideBackground = staticFileService.getFile(theme.getLicenseSlideBackground())) {
276             addElement(mediaPackage, licenseSlideFlavor, licenseSlideTags, licenseSlideBackground,
277                     staticFileService.getFileName(theme.getLicenseSlideBackground()), Type.Attachment);
278           } catch (NotFoundException e) {
279             logger.warn("License slide file {} not found in static file service, skip applying it",
280                     theme.getLicenseSlideBackground());
281           }
282         } else {
283           // TODO define what to do here (maybe extract image as background)
284         }
285 
286         // TODO add the license slide description to the workflow properties to be used by the cover-image WOH
287         // String licenseSlideDescription = theme.getLicenseSlideDescription();
288       }
289 
290       if (theme.isWatermarkActive() && StringUtils.isNotBlank(theme.getWatermarkFile())) {
291         try (InputStream watermark = staticFileService.getFile(theme.getWatermarkFile())) {
292           addElement(mediaPackage, watermarkFlavor, watermarkTags, watermark,
293                   staticFileService.getFileName(theme.getWatermarkFile()), Type.Attachment);
294         } catch (NotFoundException e) {
295           logger.warn("Watermark file {} not found in static file service, skip applying it", theme.getWatermarkFile());
296         }
297 
298         if (layoutStringOpt.isEmpty() || watermarkLayoutVariable.isEmpty()) {
299           throw new WorkflowOperationException(format("Configuration key '%s' or '%s' is either missing or empty",
300                   WATERMARK_LAYOUT, WATERMARK_LAYOUT_VARIABLE));
301         }
302 
303         AbsolutePositionLayoutSpec watermarkLayout = parseLayout(theme.getWatermarkPosition());
304         layoutList.set(layoutList.size() - 1, Serializer.json(watermarkLayout).toJson());
305         layoutStringOpt = Optional.of(String.join(";", layoutList));
306       }
307 
308       if (watermarkLayoutVariable.isPresent() && layoutStringOpt.isPresent()) {
309         workflowInstance.setConfiguration(watermarkLayoutVariable.get(), layoutStringOpt.get());
310       }
311 
312       return createResult(mediaPackage, Action.CONTINUE);
313     } catch (SeriesException | ThemesServiceDatabaseException | IllegalStateException | IllegalArgumentException
314             | IOException e) {
315       throw new WorkflowOperationException(e);
316     }
317   }
318 
319   private AbsolutePositionLayoutSpec parseLayout(String watermarkPosition) {
320     switch (watermarkPosition) {
321       case "topLeft":
322         return new AbsolutePositionLayoutSpec(new AnchorOffset(Anchors.TOP_LEFT, Anchors.TOP_LEFT, offset(20, 20)));
323       case "topRight":
324         return new AbsolutePositionLayoutSpec(new AnchorOffset(Anchors.TOP_RIGHT, Anchors.TOP_RIGHT, offset(-20, 20)));
325       case "bottomLeft":
326         return new AbsolutePositionLayoutSpec(new AnchorOffset(Anchors.BOTTOM_LEFT, Anchors.BOTTOM_LEFT,
327                 offset(20, -20)));
328       case "bottomRight":
329         return new AbsolutePositionLayoutSpec(new AnchorOffset(Anchors.BOTTOM_RIGHT, Anchors.BOTTOM_RIGHT, offset(-20,
330                 -20)));
331       default:
332         throw new IllegalStateException("Unknown watermark position: " + watermarkPosition);
333     }
334   }
335 
336   private void addElement(MediaPackage mediaPackage, final MediaPackageElementFlavor flavor, final List<String> tags,
337           InputStream file, String filename, Type type) throws IOException {
338     MediaPackageElement element = elementBuilderFactory.newElementBuilder().newElement(type, flavor);
339     element.generateIdentifier();
340     for (String tag : tags) {
341       element.addTag(tag);
342     }
343     URI uri = workspace.put(mediaPackage.getIdentifier().toString(), element.getIdentifier(), filename, file);
344     element.setURI(uri);
345     try {
346       MimeType mimeType = MimeTypes.fromString(filename);
347       element.setMimeType(mimeType);
348     } catch (UnknownFileTypeException e) {
349       logger.warn("Unable to detect the mime type of file {}", filename);
350     }
351     mediaPackage.add(element);
352   }
353 }