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.publication.youtube;
23  
24  import org.opencastproject.job.api.AbstractJobProducer;
25  import org.opencastproject.job.api.Job;
26  import org.opencastproject.mediapackage.MediaPackage;
27  import org.opencastproject.mediapackage.MediaPackageElement;
28  import org.opencastproject.mediapackage.MediaPackageElementParser;
29  import org.opencastproject.mediapackage.MediaPackageParser;
30  import org.opencastproject.mediapackage.Publication;
31  import org.opencastproject.mediapackage.PublicationImpl;
32  import org.opencastproject.mediapackage.Track;
33  import org.opencastproject.publication.api.PublicationException;
34  import org.opencastproject.publication.api.YouTubePublicationService;
35  import org.opencastproject.publication.youtube.auth.ClientCredentials;
36  import org.opencastproject.security.api.OrganizationDirectoryService;
37  import org.opencastproject.security.api.SecurityService;
38  import org.opencastproject.security.api.UserDirectoryService;
39  import org.opencastproject.serviceregistry.api.ServiceRegistry;
40  import org.opencastproject.serviceregistry.api.ServiceRegistryException;
41  import org.opencastproject.util.LoadUtil;
42  import org.opencastproject.util.MimeTypes;
43  import org.opencastproject.util.XProperties;
44  import org.opencastproject.workspace.api.Workspace;
45  
46  import com.google.api.services.youtube.model.Playlist;
47  import com.google.api.services.youtube.model.SearchResult;
48  import com.google.api.services.youtube.model.Video;
49  
50  import org.apache.commons.lang3.StringUtils;
51  import org.apache.commons.lang3.builder.ToStringBuilder;
52  import org.apache.commons.lang3.builder.ToStringStyle;
53  import org.osgi.service.cm.ConfigurationException;
54  import org.osgi.service.cm.ManagedService;
55  import org.osgi.service.component.ComponentContext;
56  import org.osgi.service.component.annotations.Activate;
57  import org.osgi.service.component.annotations.Component;
58  import org.osgi.service.component.annotations.Reference;
59  import org.slf4j.Logger;
60  import org.slf4j.LoggerFactory;
61  
62  import java.io.File;
63  import java.net.URL;
64  import java.util.ArrayList;
65  import java.util.Arrays;
66  import java.util.Date;
67  import java.util.Dictionary;
68  import java.util.List;
69  import java.util.UUID;
70  
71  /**
72   * Publishes media to a YouTube play list.
73   */
74  @Component(
75      immediate = true,
76      service = { ManagedService.class,YouTubePublicationService.class },
77      property = {
78          "service.description=Publication Service (YouTube API Version 3)"
79      }
80  )
81  public class YouTubeV3PublicationServiceImpl
82      extends AbstractJobProducer
83      implements YouTubePublicationService, ManagedService {
84  
85    /** The load on the system introduced by creating a publish job */
86    public static final float DEFAULT_YOUTUBE_PUBLISH_JOB_LOAD = 0.1f;
87  
88    /** The load on the system introduced by creating a retract job */
89    public static final float DEFAULT_YOUTUBE_RETRACT_JOB_LOAD = 0.1f;
90  
91    /** The key to look for in the service configuration file to override the {@link #DEFAULT_YOUTUBE_PUBLISH_JOB_LOAD} */
92    public static final String YOUTUBE_PUBLISH_LOAD_KEY = "job.load.youtube.publish";
93  
94    /** The key to look for in the service configuration file to override the {@link #DEFAULT_YOUTUBE_RETRACT_JOB_LOAD} */
95    public static final String YOUTUBE_RETRACT_LOAD_KEY = "job.load.youtube.retract";
96  
97    public static final String YOUTUBE_ENABLED_KEY = "org.opencastproject.publication.youtube.enabled";
98  
99    /** The load on the system introduced by creating a publish job */
100   private float youtubePublishJobLoad = DEFAULT_YOUTUBE_PUBLISH_JOB_LOAD;
101 
102   /** The load on the system introduced by creating a retract job */
103   private float youtubeRetractJobLoad = DEFAULT_YOUTUBE_RETRACT_JOB_LOAD;
104 
105   /** Time to wait between polling for status (milliseconds.) */
106   private static final long POLL_MILLISECONDS = 30L * 1000L;
107 
108   /** The channel name */
109   private static final String CHANNEL_NAME = "youtube";
110 
111   /** logger instance */
112   private static final Logger logger = LoggerFactory.getLogger(YouTubeV3PublicationServiceImpl.class);
113 
114   /** The mime-type of the published element */
115   private static final String MIME_TYPE = "text/html";
116 
117   /** List of available operations on jobs */
118   private enum Operation {
119     Publish, Retract
120   }
121 
122   /** workspace instance */
123   protected Workspace workspace = null;
124 
125   /** The remote service registry */
126   protected ServiceRegistry serviceRegistry = null;
127 
128   /** The organization directory service */
129   private OrganizationDirectoryService organizationDirectoryService;
130 
131   /** The user directory service */
132   private UserDirectoryService userDirectoryService;
133 
134   /** The security service */
135   private SecurityService securityService;
136 
137   /** YouTube configuration instance */
138   private final YouTubeAPIVersion3Service youTubeService;
139 
140   private boolean enabled = false;
141 
142   /**
143    * The default playlist to publish to, in case there is not enough information in the mediapackage to find a playlist
144    */
145   private String defaultPlaylist;
146 
147   private boolean makeVideosPrivate;
148 
149   private String[] tags;
150 
151   private XProperties properties = new XProperties();
152 
153   /**
154    * The maximum length of a Recording or Series title.
155    * A value of zero will be treated as no limit
156    */
157   private int maxFieldLength;
158 
159   /**
160    * Creates a new instance of the youtube publication service.
161    */
162   YouTubeV3PublicationServiceImpl(final YouTubeAPIVersion3Service youTubeService) throws Exception {
163     super(JOB_TYPE);
164     this.youTubeService = youTubeService;
165   }
166 
167   /**
168    * Creates a new instance of the youtube publication service.
169    */
170   public YouTubeV3PublicationServiceImpl() throws Exception {
171     this(new YouTubeAPIVersion3ServiceImpl());
172   }
173 
174   /**
175    * Called when service activates. Defined in OSGi resource file.
176    */
177   @Override
178   @Activate
179   public synchronized void activate(final ComponentContext cc) {
180     super.activate(cc);
181     properties.setBundleContext(cc.getBundleContext());
182 
183     logger.debug("Activated YouTube Publication Service");
184   }
185 
186   @Override
187   public void updated(final Dictionary props) throws ConfigurationException {
188     properties.merge(props);
189 
190     enabled = Boolean.valueOf((String) properties.get(YOUTUBE_ENABLED_KEY));
191 
192     final String dataStore = YouTubeUtils.get(properties, YouTubeKey.credentialDatastore);
193 
194     try {
195       if (enabled) {
196         final ClientCredentials clientCredentials = new ClientCredentials();
197         clientCredentials.setCredentialDatastore(dataStore);
198         final String path = YouTubeUtils.get(properties, YouTubeKey.clientSecretsV3);
199         File secretsFile = new File(path);
200         if (secretsFile.exists() && !secretsFile.isDirectory()) {
201           clientCredentials.setClientSecrets(secretsFile);
202           clientCredentials.setDataStoreDirectory(YouTubeUtils.get(properties, YouTubeKey.dataStore));
203           //
204           youTubeService.initialize(clientCredentials);
205           //
206           tags = StringUtils.split(YouTubeUtils.get(properties, YouTubeKey.keywords), ',');
207           defaultPlaylist = YouTubeUtils.get(properties, YouTubeKey.defaultPlaylist);
208           makeVideosPrivate = StringUtils
209                   .containsIgnoreCase(YouTubeUtils.get(properties, YouTubeKey.makeVideosPrivate), "true");
210           defaultMaxFieldLength(YouTubeUtils.get(properties, YouTubeKey.maxFieldLength, false));
211         } else {
212           logger.warn("Client information file does not exist: " + path);
213         }
214       } else {
215         logger.info("YouTube v3 publication service is disabled");
216       }
217     } catch (final Exception e) {
218       throw new ConfigurationException("Failed to load YouTube v3 properties", dataStore, e);
219     }
220 
221     youtubePublishJobLoad = LoadUtil.getConfiguredLoadValue(
222         properties, YOUTUBE_PUBLISH_LOAD_KEY, DEFAULT_YOUTUBE_PUBLISH_JOB_LOAD, serviceRegistry);
223     youtubeRetractJobLoad = LoadUtil.getConfiguredLoadValue(
224         properties, YOUTUBE_RETRACT_LOAD_KEY, DEFAULT_YOUTUBE_RETRACT_JOB_LOAD, serviceRegistry);
225   }
226 
227   @Override
228   public Job publish(final MediaPackage mediaPackage, final Track track) throws PublicationException {
229     if (mediaPackage.contains(track)) {
230       try {
231         final List<String> args = Arrays.asList(MediaPackageParser.getAsXml(mediaPackage), track.getIdentifier());
232         return serviceRegistry.createJob(JOB_TYPE, Operation.Publish.toString(), args, youtubePublishJobLoad);
233       } catch (ServiceRegistryException e) {
234         throw new PublicationException("Unable to create a job for track: " + track.toString(), e);
235       }
236     } else {
237       throw new IllegalArgumentException("Mediapackage does not contain track " + track.getIdentifier());
238     }
239 
240   }
241 
242   /**
243    * Publishes the element to the publication channel and returns a reference to the published version of the element.
244    *
245    * @param job
246    *          the associated job
247    * @param mediaPackage
248    *          the mediapackage
249    * @param elementId
250    *          the mediapackage element id to publish
251    * @return the published element
252    * @throws PublicationException
253    *           if publication fails
254    */
255   private Publication publish(final Job job, final MediaPackage mediaPackage, final String elementId)
256           throws PublicationException {
257     if (mediaPackage == null) {
258       throw new IllegalArgumentException("Mediapackage must be specified");
259     } else if (elementId == null) {
260       throw new IllegalArgumentException("Mediapackage ID must be specified");
261     }
262     final MediaPackageElement element = mediaPackage.getElementById(elementId);
263     if (element == null) {
264       throw new IllegalArgumentException("Mediapackage element must be specified");
265     }
266     if (element.getIdentifier() == null) {
267       throw new IllegalArgumentException("Mediapackage element must have an identifier");
268     }
269     if (element.getMimeType().toString().matches("text/xml")) {
270       throw new IllegalArgumentException("Mediapackage element cannot be XML");
271     }
272     try {
273       // create context strategy for publication
274       final YouTubePublicationAdapter c = new YouTubePublicationAdapter(mediaPackage, workspace);
275       final File file = workspace.get(element.getURI());
276       final String episodeName = c.getEpisodeName();
277       final UploadProgressListener operationProgressListener = new UploadProgressListener(mediaPackage, file);
278       final String privacyStatus = makeVideosPrivate ? "private" : "public";
279       final VideoUpload videoUpload = new VideoUpload(
280           truncateTitleToMaxFieldLength(episodeName, false),
281           c.getEpisodeDescription(), privacyStatus,
282           file, operationProgressListener, tags);
283       final Video video = youTubeService.addVideoToMyChannel(videoUpload);
284       final int timeoutMinutes = 60;
285       final long startUploadMilliseconds = new Date().getTime();
286       while (!operationProgressListener.isComplete()) {
287         Thread.sleep(POLL_MILLISECONDS);
288         final long howLongWaitingMinutes = (new Date().getTime() - startUploadMilliseconds) / 60000;
289         if (howLongWaitingMinutes > timeoutMinutes) {
290           throw new PublicationException(
291               "Upload to YouTube exceeded " + timeoutMinutes + " minutes for episode " + episodeName);
292         }
293       }
294       String playlistName = StringUtils.trimToNull(truncateTitleToMaxFieldLength(mediaPackage.getSeriesTitle(), true));
295       playlistName = (playlistName == null) ? this.defaultPlaylist : playlistName;
296       final Playlist playlist;
297       final Playlist existingPlaylist = youTubeService.getMyPlaylistByTitle(playlistName);
298       if (existingPlaylist == null) {
299         playlist = youTubeService.createPlaylist(playlistName, c.getContextDescription(), mediaPackage.getSeries());
300       } else {
301         playlist = existingPlaylist;
302       }
303       youTubeService.addPlaylistItem(playlist.getId(), video.getId());
304       // Create new publication element
305       final URL url = new URL("http://www.youtube.com/watch?v=" + video.getId());
306       return PublicationImpl.publication(
307           UUID.randomUUID().toString(), CHANNEL_NAME, url.toURI(), MimeTypes.parseMimeType(MIME_TYPE));
308     } catch (Exception e) {
309       logger.error("failed publishing to YouTube", e);
310       logger.warn("Error publishing {}, {}", element, e.getMessage());
311       if (e instanceof PublicationException) {
312         throw (PublicationException) e;
313       } else {
314         throw new PublicationException(
315             "YouTube publish failed on job: "
316                 + ToStringBuilder.reflectionToString(job, ToStringStyle.MULTI_LINE_STYLE),
317             e);
318       }
319     }
320   }
321 
322   @Override
323   public Job retract(final MediaPackage mediaPackage) throws PublicationException {
324     if (mediaPackage == null) {
325       throw new IllegalArgumentException("Mediapackage must be specified");
326     }
327     try {
328       List<String> arguments = new ArrayList<String>();
329       arguments.add(MediaPackageParser.getAsXml(mediaPackage));
330       return serviceRegistry.createJob(JOB_TYPE, Operation.Retract.toString(), arguments, youtubeRetractJobLoad);
331     } catch (ServiceRegistryException e) {
332       throw new PublicationException("Unable to create a job", e);
333     }
334   }
335 
336   /**
337    * Retracts the mediapackage from YouTube.
338    *
339    * @param job
340    *          the associated job
341    * @param mediaPackage
342    *          the mediapackage
343    * @throws PublicationException
344    *           if retract did not work
345    */
346   private Publication retract(final Job job, final MediaPackage mediaPackage) throws PublicationException {
347     logger.info("Retract video from YouTube: {}", mediaPackage);
348     Publication youtube = null;
349     for (Publication publication : mediaPackage.getPublications()) {
350       if (CHANNEL_NAME.equals(publication.getChannel())) {
351         youtube = publication;
352         break;
353       }
354     }
355     if (youtube == null) {
356       return null;
357     }
358     final YouTubePublicationAdapter contextStrategy = new YouTubePublicationAdapter(mediaPackage, workspace);
359     final String episodeName = contextStrategy.getEpisodeName();
360     try {
361       retract(mediaPackage.getSeriesTitle(), episodeName);
362     } catch (final Exception e) {
363       logger.error("Failure retracting YouTube media {}", e.getMessage());
364       throw new PublicationException("YouTube media retract failed on job: "
365           + ToStringBuilder.reflectionToString(job, ToStringStyle.MULTI_LINE_STYLE), e);
366     }
367     return youtube;
368   }
369 
370   private void retract(final String seriesTitle, final String episodeName) throws Exception {
371     final List<SearchResult> items = youTubeService.searchMyVideos(
372         truncateTitleToMaxFieldLength(episodeName, false), null, 1).getItems();
373     if (!items.isEmpty()) {
374       final String videoId = items.get(0).getId().getVideoId();
375       if (seriesTitle != null) {
376         final Playlist playlist = youTubeService.getMyPlaylistByTitle(truncateTitleToMaxFieldLength(seriesTitle, true));
377         youTubeService.removeVideoFromPlaylist(playlist.getId(), videoId);
378       }
379       youTubeService.removeMyVideo(videoId);
380     }
381   }
382 
383   /**
384    * {@inheritDoc}
385    *
386    * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
387    */
388   @Override
389   protected String process(final Job job) throws Exception {
390     Operation op = null;
391     try {
392       op = Operation.valueOf(job.getOperation());
393       List<String> arguments = job.getArguments();
394       MediaPackage mediapackage = MediaPackageParser.getFromXml(arguments.get(0));
395       switch (op) {
396         case Publish:
397           Publication publicationElement = publish(job, mediapackage, arguments.get(1));
398           return (publicationElement == null) ? null : MediaPackageElementParser.getAsXml(publicationElement);
399         case Retract:
400           Publication retractedElement = retract(job, mediapackage);
401           return (retractedElement == null) ? null : MediaPackageElementParser.getAsXml(retractedElement);
402         default:
403           throw new IllegalStateException("Don't know how to handle operation '" + job.getOperation() + "'");
404       }
405     } catch (final IllegalArgumentException e) {
406       throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'", e);
407     } catch (final IndexOutOfBoundsException e) {
408       throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e);
409     } catch (final Exception e) {
410       throw new ServiceRegistryException("Error handling operation '" + op + "'", e);
411     }
412   }
413 
414   /**
415    * Callback for the OSGi environment to set the workspace reference.
416    *
417    * @param workspace
418    *          the workspace
419    */
420   @Reference
421   protected void setWorkspace(Workspace workspace) {
422     this.workspace = workspace;
423   }
424 
425   /**
426    * Callback for the OSGi environment to set the service registry reference.
427    *
428    * @param serviceRegistry
429    *          the service registry
430    */
431   @Reference
432   protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
433     this.serviceRegistry = serviceRegistry;
434   }
435 
436   /**
437    * {@inheritDoc}
438    *
439    * @see org.opencastproject.job.api.AbstractJobProducer#getServiceRegistry()
440    */
441   @Override
442   protected ServiceRegistry getServiceRegistry() {
443     return serviceRegistry;
444   }
445 
446   /**
447    * Callback for setting the security service.
448    *
449    * @param securityService
450    *          the securityService to set
451    */
452   @Reference
453   public void setSecurityService(SecurityService securityService) {
454     this.securityService = securityService;
455   }
456 
457   /**
458    * Callback for setting the user directory service.
459    *
460    * @param userDirectoryService
461    *          the userDirectoryService to set
462    */
463   @Reference
464   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
465     this.userDirectoryService = userDirectoryService;
466   }
467 
468   /**
469    * Sets a reference to the organization directory service.
470    *
471    * @param organizationDirectory
472    *          the organization directory
473    */
474   @Reference
475   public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) {
476     this.organizationDirectoryService = organizationDirectory;
477   }
478 
479   /**
480    * {@inheritDoc}
481    *
482    * @see org.opencastproject.job.api.AbstractJobProducer#getSecurityService()
483    */
484   @Override
485   protected SecurityService getSecurityService() {
486     return securityService;
487   }
488 
489   /**
490    * {@inheritDoc}
491    *
492    * @see org.opencastproject.job.api.AbstractJobProducer#getUserDirectoryService()
493    */
494   @Override
495   protected UserDirectoryService getUserDirectoryService() {
496     return userDirectoryService;
497   }
498 
499   /**
500    * {@inheritDoc}
501    *
502    * @see org.opencastproject.job.api.AbstractJobProducer#getOrganizationDirectoryService()
503    */
504   @Override
505   protected OrganizationDirectoryService getOrganizationDirectoryService() {
506     return organizationDirectoryService;
507   }
508 
509   boolean isMaxFieldLengthSet() {
510     return maxFieldLength != 0;
511   }
512 
513   private String truncateTitleToMaxFieldLength(final String title, final boolean tolerateNull) {
514     if (StringUtils.isBlank(title) && !tolerateNull) {
515       throw new IllegalArgumentException("Title fields cannot be null, empty, or whitespace");
516     }
517     if (isMaxFieldLengthSet() && (title != null)) {
518       return StringUtils.left(title, maxFieldLength);
519     } else {
520       return title;
521     }
522   }
523 
524   private void defaultMaxFieldLength(String maxFieldLength) {
525     if (StringUtils.isBlank(maxFieldLength)) {
526       this.maxFieldLength = 0;
527     } else {
528       try {
529         this.maxFieldLength = Integer.parseInt(maxFieldLength);
530       } catch (NumberFormatException e) {
531         throw new IllegalArgumentException("maxFieldLength must be an integer");
532       }
533       if (this.maxFieldLength <= 0) {
534         throw new IllegalArgumentException("maxFieldLength must be greater than zero");
535       }
536     }
537   }
538 
539 }