1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
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
86 public static final float DEFAULT_YOUTUBE_PUBLISH_JOB_LOAD = 0.1f;
87
88
89 public static final float DEFAULT_YOUTUBE_RETRACT_JOB_LOAD = 0.1f;
90
91
92 public static final String YOUTUBE_PUBLISH_LOAD_KEY = "job.load.youtube.publish";
93
94
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
100 private float youtubePublishJobLoad = DEFAULT_YOUTUBE_PUBLISH_JOB_LOAD;
101
102
103 private float youtubeRetractJobLoad = DEFAULT_YOUTUBE_RETRACT_JOB_LOAD;
104
105
106 private static final long POLL_MILLISECONDS = 30L * 1000L;
107
108
109 private static final String CHANNEL_NAME = "youtube";
110
111
112 private static final Logger logger = LoggerFactory.getLogger(YouTubeV3PublicationServiceImpl.class);
113
114
115 private static final String MIME_TYPE = "text/html";
116
117
118 private enum Operation {
119 Publish, Retract
120 }
121
122
123 protected Workspace workspace = null;
124
125
126 protected ServiceRegistry serviceRegistry = null;
127
128
129 private OrganizationDirectoryService organizationDirectoryService;
130
131
132 private UserDirectoryService userDirectoryService;
133
134
135 private SecurityService securityService;
136
137
138 private final YouTubeAPIVersion3Service youTubeService;
139
140 private boolean enabled = false;
141
142
143
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
155
156
157 private int maxFieldLength;
158
159
160
161
162 YouTubeV3PublicationServiceImpl(final YouTubeAPIVersion3Service youTubeService) throws Exception {
163 super(JOB_TYPE);
164 this.youTubeService = youTubeService;
165 }
166
167
168
169
170 public YouTubeV3PublicationServiceImpl() throws Exception {
171 this(new YouTubeAPIVersion3ServiceImpl());
172 }
173
174
175
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
244
245
246
247
248
249
250
251
252
253
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
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
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
338
339
340
341
342
343
344
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
385
386
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
416
417
418
419
420 @Reference
421 protected void setWorkspace(Workspace workspace) {
422 this.workspace = workspace;
423 }
424
425
426
427
428
429
430
431 @Reference
432 protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
433 this.serviceRegistry = serviceRegistry;
434 }
435
436
437
438
439
440
441 @Override
442 protected ServiceRegistry getServiceRegistry() {
443 return serviceRegistry;
444 }
445
446
447
448
449
450
451
452 @Reference
453 public void setSecurityService(SecurityService securityService) {
454 this.securityService = securityService;
455 }
456
457
458
459
460
461
462
463 @Reference
464 public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
465 this.userDirectoryService = userDirectoryService;
466 }
467
468
469
470
471
472
473
474 @Reference
475 public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) {
476 this.organizationDirectoryService = organizationDirectory;
477 }
478
479
480
481
482
483
484 @Override
485 protected SecurityService getSecurityService() {
486 return securityService;
487 }
488
489
490
491
492
493
494 @Override
495 protected UserDirectoryService getUserDirectoryService() {
496 return userDirectoryService;
497 }
498
499
500
501
502
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 }