{"action":"created","comment":{"url":"https://api.github.com/repos/opencast/opencast/pulls/comments/303915558","pull_request_review_id":262424848,"id":303915558,"node_id":"MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDMwMzkxNTU1OA==","diff_hunk":"@@ -0,0 +1,1064 @@\n+/**\n+ * Licensed to The Apereo Foundation under one or more contributor license\n+ * agreements. See the NOTICE file distributed with this work for additional\n+ * information regarding copyright ownership.\n+ *\n+ *\n+ * The Apereo Foundation licenses this file to you under the Educational\n+ * Community License, Version 2.0 (the \"License\"); you may not use this file\n+ * except in compliance with the License. You may obtain a copy of the License\n+ * at:\n+ *\n+ *   http://opensource.org/licenses/ecl2.txt\n+ *\n+ * Unless required by applicable law or agreed to in writing, software\n+ * distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT\n+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the\n+ * License for the specific language governing permissions and limitations under\n+ * the License.\n+ *\n+ */\n+package org.opencastproject.transcription.googlespeech;\n+\n+import org.opencastproject.assetmanager.api.AssetManager;\n+import org.opencastproject.assetmanager.api.fn.Enrichments;\n+import org.opencastproject.assetmanager.api.query.AQueryBuilder;\n+import org.opencastproject.assetmanager.api.query.AResult;\n+import org.opencastproject.assetmanager.util.Workflows;\n+import org.opencastproject.job.api.AbstractJobProducer;\n+import org.opencastproject.job.api.Job;\n+import org.opencastproject.kernel.mail.SmtpService;\n+import org.opencastproject.mediapackage.Attachment;\n+import org.opencastproject.mediapackage.MediaPackageElement;\n+import org.opencastproject.mediapackage.MediaPackageElementBuilder;\n+import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;\n+import org.opencastproject.mediapackage.MediaPackageElementFlavor;\n+import org.opencastproject.mediapackage.MediaPackageElementParser;\n+import org.opencastproject.mediapackage.MediaPackageException;\n+import org.opencastproject.mediapackage.Track;\n+import org.opencastproject.security.api.DefaultOrganization;\n+import org.opencastproject.security.api.Organization;\n+import org.opencastproject.security.api.OrganizationDirectoryService;\n+import org.opencastproject.security.api.SecurityService;\n+import org.opencastproject.security.api.UserDirectoryService;\n+import org.opencastproject.security.util.SecurityUtil;\n+import org.opencastproject.serviceregistry.api.ServiceRegistry;\n+import org.opencastproject.serviceregistry.api.ServiceRegistryException;\n+import org.opencastproject.transcription.api.TranscriptionService;\n+import org.opencastproject.transcription.api.TranscriptionServiceException;\n+import org.opencastproject.transcription.persistence.TranscriptionDatabase;\n+import org.opencastproject.transcription.persistence.TranscriptionDatabaseException;\n+import org.opencastproject.transcription.persistence.TranscriptionJobControl;\n+import org.opencastproject.util.NotFoundException;\n+import org.opencastproject.util.OsgiUtil;\n+import org.opencastproject.util.PathSupport;\n+import org.opencastproject.util.data.Option;\n+import org.opencastproject.workflow.api.ConfiguredWorkflow;\n+import org.opencastproject.workflow.api.WorkflowDatabaseException;\n+import org.opencastproject.workflow.api.WorkflowDefinition;\n+import org.opencastproject.workflow.api.WorkflowInstance;\n+import org.opencastproject.workflow.api.WorkflowService;\n+import org.opencastproject.workingfilerepository.api.WorkingFileRepository;\n+import org.opencastproject.workspace.api.Workspace;\n+\n+import org.apache.commons.io.FilenameUtils;\n+import org.apache.http.HttpEntity;\n+import org.apache.http.HttpHeaders;\n+import org.apache.http.HttpStatus;\n+import org.apache.http.client.config.RequestConfig;\n+import org.apache.http.client.methods.CloseableHttpResponse;\n+import org.apache.http.client.methods.HttpGet;\n+import org.apache.http.client.methods.HttpPost;\n+import org.apache.http.entity.StringEntity;\n+import org.apache.http.impl.client.CloseableHttpClient;\n+import org.apache.http.impl.client.HttpClients;\n+import org.apache.http.util.EntityUtils;\n+import org.json.simple.JSONArray;\n+import org.json.simple.JSONObject;\n+import org.json.simple.parser.JSONParser;\n+import org.osgi.service.component.ComponentContext;\n+import org.slf4j.Logger;\n+import org.slf4j.LoggerFactory;\n+\n+import java.io.ByteArrayInputStream;\n+import java.io.File;\n+import java.io.IOException;\n+import java.net.URI;\n+import java.util.Arrays;\n+import java.util.HashMap;\n+import java.util.HashSet;\n+import java.util.List;\n+import java.util.Map;\n+import java.util.Set;\n+import java.util.concurrent.Executors;\n+import java.util.concurrent.ScheduledExecutorService;\n+import java.util.concurrent.TimeUnit;\n+\n+public class GoogleSpeechTranscriptionService extends AbstractJobProducer implements TranscriptionService {\n+\n+  /**\n+   * The logger\n+   */\n+  private static final Logger logger = LoggerFactory.getLogger(GoogleSpeechTranscriptionService.class);\n+\n+  private static final String JOB_TYPE = \"org.opencastproject.transcription.googlespeech\";\n+\n+  static final String TRANSCRIPT_COLLECTION = \"transcripts\";\n+  private static final int CONNECTION_TIMEOUT = 60000; // ms, 1 minute\n+  private static final int SOCKET_TIMEOUT = 60000; // ms, 1 minute\n+  private static final int ACCESS_TOKEN_MINIMUN_TIME = 60000; // ms , 1 minute\n+  // Default workflow to attach transcription results to mediapackage\n+  public static final String DEFAULT_WF_DEF = \"google-speech-attach-transcripts\";\n+  private static final long DEFAULT_COMPLETION_BUFFER = 300; // in seconds, default is 5 minutes\n+  private static final long DEFAULT_DISPATCH_INTERVAL = 60; // in seconds, default is 1 minute\n+  private static final long DEFAULT_MAX_PROCESSING_TIME = 5 * 60 * 60; // in seconds, default is 5 hours\n+  // Cleans up results files that are older than 7 days\n+  private static final int DEFAULT_CLEANUP_RESULTS_DAYS = 7;\n+  private static final boolean DEFAULT_PROFANITY_FILTER = false;\n+  private static final String DEFAULT_LANGUAGE = \"en-US\";\n+  private static final String GOOGLE_SPEECH_URL = \"https://speech.googleapis.com/v1\";\n+  private static final String GOOGLE_AUTH2_URL = \"https://www.googleapis.com/oauth2/v4/token\";\n+  private static final String REQUEST_PATH = \"/speech:longrunningrecognize\";\n+  private static final String RESULT_PATH = \"/operations/\";\n+  private static final String INVALID_TOKEN = \"-1\";\n+  private static final String PROVIDER = \"Google Speech\";\n+\n+  // Global configuration (custom.properties)\n+  public static final String ADMIN_URL_PROPERTY = \"org.opencastproject.admin.ui.url\";\n+  private static final String ADMIN_EMAIL_PROPERTY = \"org.opencastproject.admin.email\";\n+  private static final String DIGEST_USER_PROPERTY = \"org.opencastproject.security.digest.user\";\n+\n+  // Cluster name\n+  private static final String CLUSTER_NAME_PROPERTY = \"org.opencastproject.environment.name\";\n+  private String clusterName = \"\";\n+\n+  // The events we are interested in receiving notifications\n+  public interface JobEvent {\n+\n+    String COMPLETED_WITH_RESULTS = \"recognitions.completed_with_results\";\n+    String FAILED = \"recognitions.failed\";\n+  }\n+\n+  /**\n+   * Service dependencies\n+   */\n+  private ServiceRegistry serviceRegistry;\n+  private SecurityService securityService;\n+  private UserDirectoryService userDirectoryService;\n+  private OrganizationDirectoryService organizationDirectoryService;\n+  private Workspace workspace;\n+  private TranscriptionDatabase database;\n+  private AssetManager assetManager;\n+  private WorkflowService workflowService;\n+  private WorkingFileRepository wfr;\n+  private SmtpService smtpService;\n+\n+  // Only used by unit tests!\n+  private Workflows wfUtil;\n+\n+  private enum Operation {\n+    StartTranscription\n+  }\n+\n+  /**\n+   * Service configuration options\n+   */\n+  public static final String ENABLED_CONFIG = \"enabled\";\n+  public static final String GOOGLE_SPEECH_LANGUAGE = \"google.speech.language\";\n+  public static final String PROFANITY_FILTER = \"google.speech.profanity.filter\";\n+  public static final String WORKFLOW_CONFIG = \"workflow\";\n+  public static final String DISPATCH_WORKFLOW_INTERVAL_CONFIG = \"workflow.dispatch.interval\";\n+  public static final String COMPLETION_CHECK_BUFFER_CONFIG = \"completion.check.buffer\";\n+  public static final String MAX_PROCESSING_TIME_CONFIG = \"max.processing.time\";\n+  public static final String NOTIFICATION_EMAIL_CONFIG = \"notification.email\";\n+  public static final String CLEANUP_RESULTS_DAYS_CONFIG = \"cleanup.results.days\";\n+  public static final String GOOGLE_CLOUD_CLIENT_ID = \"google.cloud.client.id\";\n+  public static final String GOOGLE_CLOUD_CLIENT_SECRET = \"google.cloud.client.secret\";\n+  public static final String GOOGLE_CLOUD_REFRESH_TOKEN = \"google.cloud.refresh.token\";\n+  public static final String GOOGLE_CLOUD_BUCKET = \"google.cloud.storage.bucket\";\n+  public static final String GOOGLE_CLOUD_TOKEN_ENDPOINT_URL = \"google.cloud.token.endpoint.url\";\n+\n+  /**\n+   * Service configuration values\n+   */\n+  private boolean enabled = false; // Disabled by default\n+  private boolean profanityFilter = DEFAULT_PROFANITY_FILTER;\n+  private String language = DEFAULT_LANGUAGE;\n+  private String workflowDefinitionId = DEFAULT_WF_DEF;\n+  private long workflowDispatchInterval = DEFAULT_DISPATCH_INTERVAL;\n+  private long completionCheckBuffer = DEFAULT_COMPLETION_BUFFER;\n+  private long maxProcessingSeconds = DEFAULT_MAX_PROCESSING_TIME;\n+  private String toEmailAddress;\n+  private int cleanupResultDays = DEFAULT_CLEANUP_RESULTS_DAYS;\n+  private String clientId;\n+  private String clientSecret;\n+  private String clientToken;\n+  private String accessToken = INVALID_TOKEN;\n+  private String tokenEndpoint = GOOGLE_AUTH2_URL;\n+  private String storageBucket;\n+  private long tokenExpiryTime = 0;\n+  private String systemAccount;\n+  private ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(2);\n+\n+  public GoogleSpeechTranscriptionService() {\n+    super(JOB_TYPE);\n+  }\n+\n+  public void activate(ComponentContext cc) {\n+    if (cc != null) {\n+      // Has this service been enabled?\n+      enabled = OsgiUtil.getOptCfgAsBoolean(cc.getProperties(), ENABLED_CONFIG).get();\n+\n+      if (enabled) {\n+        // Mandatory API access properties\n+        clientId = OsgiUtil.getComponentContextProperty(cc, GOOGLE_CLOUD_CLIENT_ID);\n+        clientSecret = OsgiUtil.getComponentContextProperty(cc, GOOGLE_CLOUD_CLIENT_SECRET);\n+        clientToken = OsgiUtil.getComponentContextProperty(cc, GOOGLE_CLOUD_REFRESH_TOKEN);\n+        storageBucket = OsgiUtil.getComponentContextProperty(cc, GOOGLE_CLOUD_BUCKET);\n+\n+        // access token endpoint\n+        Option<String> tokenOpt = OsgiUtil.getOptCfg(cc.getProperties(), GOOGLE_CLOUD_TOKEN_ENDPOINT_URL);\n+        if (tokenOpt.isSome()) {\n+          tokenEndpoint = tokenOpt.get();\n+          logger.info(\"Access token endpoint is set to {}\", tokenEndpoint);\n+        } else {\n+          logger.info(\"Default access token endpoint will be used\");\n+        }\n+\n+        // profanity filter to use\n+        Option<String> profanityOpt = OsgiUtil.getOptCfg(cc.getProperties(), PROFANITY_FILTER);\n+        if (profanityOpt.isSome()) {\n+          profanityFilter = Boolean.parseBoolean(profanityOpt.get());\n+          logger.info(\"Profanity filter is set to {}\", profanityFilter);\n+        } else {\n+          logger.info(\"Default profanity filter will be used\");\n+        }\n+        // Language model to be used\n+        Option<String> languageOpt = OsgiUtil.getOptCfg(cc.getProperties(), GOOGLE_SPEECH_LANGUAGE);\n+        if (languageOpt.isSome()) {\n+          language = languageOpt.get();\n+          logger.info(\"Language used is {}\", language);\n+        } else {\n+          logger.info(\"Default language will be used\");\n+        }\n+        // Workflow to execute when getting callback (optional, with default)\n+        Option<String> wfOpt = OsgiUtil.getOptCfg(cc.getProperties(), WORKFLOW_CONFIG);\n+        if (wfOpt.isSome()) {\n+          workflowDefinitionId = wfOpt.get();\n+        }\n+        logger.info(\"Workflow definition is {}\", workflowDefinitionId);\n+        // Interval to check for completed transcription jobs and start workflows to attach transcripts\n+        Option<String> intervalOpt = OsgiUtil.getOptCfg(cc.getProperties(), DISPATCH_WORKFLOW_INTERVAL_CONFIG);\n+        if (intervalOpt.isSome()) {\n+          try {\n+            workflowDispatchInterval = Long.parseLong(intervalOpt.get());\n+          } catch (NumberFormatException e) {\n+            // Use default\n+          }\n+        }\n+        logger.info(\"Workflow dispatch interval is {} seconds\", workflowDispatchInterval);\n+        // How long to wait after a transcription is supposed to finish before starting checking\n+        Option<String> bufferOpt = OsgiUtil.getOptCfg(cc.getProperties(), COMPLETION_CHECK_BUFFER_CONFIG);\n+        if (bufferOpt.isSome()) {\n+          try {\n+            completionCheckBuffer = Long.parseLong(bufferOpt.get());\n+          } catch (NumberFormatException e) {\n+            // Use default\n+            logger.warn(\"Invalid configuration for {} : {}. Default used instead: {}\",\n+                    new Object[]{COMPLETION_CHECK_BUFFER_CONFIG, bufferOpt.get(), completionCheckBuffer});\n+          }\n+        }\n+        logger.info(\"Completion check buffer is {} seconds\", completionCheckBuffer);\n+        // How long to wait after a transcription is supposed to finish before marking the job as canceled in the db\n+        Option<String> maxProcessingOpt = OsgiUtil.getOptCfg(cc.getProperties(), MAX_PROCESSING_TIME_CONFIG);\n+        if (maxProcessingOpt.isSome()) {\n+          try {\n+            maxProcessingSeconds = Long.parseLong(maxProcessingOpt.get());\n+          } catch (NumberFormatException e) {\n+            // Use default\n+          }\n+        }\n+        logger.info(\"Maximum time a job is checked after it should have ended is {} seconds\", maxProcessingSeconds);\n+        // How long to keep result files in the working file repository\n+        Option<String> cleaupOpt = OsgiUtil.getOptCfg(cc.getProperties(), CLEANUP_RESULTS_DAYS_CONFIG);\n+        if (cleaupOpt.isSome()) {\n+          try {\n+            cleanupResultDays = Integer.parseInt(cleaupOpt.get());\n+          } catch (NumberFormatException e) {\n+            // Use default\n+          }\n+        }\n+        logger.info(\"Cleanup result files after {} days\", cleanupResultDays);\n+\n+        systemAccount = OsgiUtil.getContextProperty(cc, DIGEST_USER_PROPERTY);\n+\n+        // Schedule the workflow dispatching, starting in 2 minutes\n+        scheduledExecutor.scheduleWithFixedDelay(new WorkflowDispatcher(), 120, workflowDispatchInterval,\n+                TimeUnit.SECONDS);\n+\n+        // Schedule the cleanup of old results jobs from the collection in the wfr once a day\n+        scheduledExecutor.scheduleWithFixedDelay(new ResultsFileCleanup(), 1, 1, TimeUnit.DAYS);\n+\n+        // Notification email passed in this service configuration?\n+        Option<String> optTo = OsgiUtil.getOptCfg(cc.getProperties(), NOTIFICATION_EMAIL_CONFIG);\n+        if (optTo.isSome()) {\n+          toEmailAddress = optTo.get();\n+        } else {\n+          // Use admin email informed in custom.properties\n+          optTo = OsgiUtil.getOptContextProperty(cc, ADMIN_EMAIL_PROPERTY);\n+          if (optTo.isSome()) {\n+            toEmailAddress = optTo.get();\n+          }\n+        }\n+        if (toEmailAddress != null) {\n+          logger.info(\"Notification email set to {}\", toEmailAddress);\n+        } else {\n+          logger.warn(\"Email notification disabled\");\n+        }\n+\n+        Option<String> optCluster = OsgiUtil.getOptContextProperty(cc, CLUSTER_NAME_PROPERTY);\n+        if (optCluster.isSome()) {\n+          clusterName = optCluster.get();\n+        }\n+        logger.info(\"Environment name is {}\", clusterName);\n+\n+        logger.info(\"Activated!\");\n+      } else {\n+        logger.info(\"Service disabled. If you want to enable it, please update the service configuration.\");\n+      }\n+    } else {\n+      throw new IllegalArgumentException(\"Missing component context\");\n+    }\n+  }\n+\n+  @Override\n+  public Job startTranscription(String mpId, Track track, String language) throws TranscriptionServiceException {\n+    if (!enabled) {\n+      throw new TranscriptionServiceException(\n+              \"This service is disabled. If you want to enable it, please update the service configuration.\");\n+    }\n+\n+    try {\n+      return serviceRegistry.createJob(JOB_TYPE, Operation.StartTranscription.name(),\n+              Arrays.asList(mpId, MediaPackageElementParser.getAsXml(track), language));\n+    } catch (ServiceRegistryException e) {\n+      throw new TranscriptionServiceException(\"Unable to create a job\", e);\n+    } catch (MediaPackageException e) {\n+      throw new TranscriptionServiceException(\"Invalid track \" + track.toString(), e);\n+    }\n+  }\n+\n+  @Override\n+  public Job startTranscription(String mpId, Track track) throws TranscriptionServiceException {\n+    throw new UnsupportedOperationException(\"Not supported.\");\n+  }\n+\n+  @Override\n+  public void transcriptionDone(String mpId, Object obj) throws TranscriptionServiceException {\n+    JSONObject jsonObj = null;\n+    String jobId = null;\n+    String token = INVALID_TOKEN;\n+    try {\n+      token = getRefreshAccessToken();\n+    } catch (IOException ex) {\n+      logger.error(\"Unable to create access token, error: {}\", ex.toString());\n+    }\n+    if (token.equals(INVALID_TOKEN)) {\n+      throw new TranscriptionServiceException(\"Invalid access token\");\n+    }\n+    try {\n+      jsonObj = (JSONObject) obj;\n+      jobId = (String) jsonObj.get(\"name\");\n+      logger.info(\"Transcription done for mpId {}, jobId {}\", mpId, jobId);\n+      JSONArray resultsArray = getTranscriptionResult(jsonObj);\n+\n+      // Update state in database\n+      // If there's an optimistic lock exception here, it's ok because the workflow dispatcher\n+      // may be doing the same thing\n+      database.updateJobControl(jobId, TranscriptionJobControl.Status.TranscriptionComplete.name());\n+\n+      // Delete audio file from Google storage\n+      deleteStorageFile(mpId, token);\n+\n+      // Save results in file system if there exist\n+      if (resultsArray != null) {\n+        saveResults(jobId, jsonObj);\n+      }\n+    } catch (IOException e) {\n+      logger.warn(\"Could not save transcription results file for mpId {}, jobId {}: {}\",\n+              new Object[]{mpId, jobId, jsonObj == null ? \"null\" : jsonObj.toJSONString()});\n+      throw new TranscriptionServiceException(\"Could not save transcription results file\", e);\n+    } catch (TranscriptionDatabaseException e) {\n+      logger.warn(\"Transcription results file were saved but state in db not updated for mpId {}, jobId {}\", mpId,\n+              jobId);\n+      throw new TranscriptionServiceException(\"Could not update transcription job control db\", e);\n+    }\n+  }\n+\n+  @Override\n+  public String getLanguage() {\n+    return language;\n+  }\n+\n+  @Override\n+  public void transcriptionError(String mpId, Object obj) throws TranscriptionServiceException {\n+    JSONObject jsonObj = null;\n+    String jobId = null;\n+    try {\n+      jsonObj = (JSONObject) obj;\n+      jobId = (String) jsonObj.get(\"name\");\n+      // Update state in database\n+      database.updateJobControl(jobId, TranscriptionJobControl.Status.Error.name());\n+      TranscriptionJobControl jobControl = database.findByJob(jobId);\n+      logger.warn(String.format(\"Error received for media package %s, job id %s\",\n+              jobControl.getMediaPackageId(), jobId));\n+      // Send notification email\n+      sendEmail(\"Transcription ERROR\",\n+              String.format(\"There was a transcription error for for media package %s, job id %s.\",\n+                      jobControl.getMediaPackageId(), jobId));\n+    } catch (TranscriptionDatabaseException e) {\n+      logger.warn(\"Transcription error. State in db could not be updated to error for mpId {}, jobId {}\", mpId, jobId);\n+      throw new TranscriptionServiceException(\"Could not update transcription job control db\", e);\n+    }\n+  }\n+\n+  @Override\n+  protected String process(Job job) throws Exception {\n+    Operation op = null;\n+    String operation = job.getOperation();\n+    List<String> arguments = job.getArguments();\n+    String result = \"\";\n+    op = Operation.valueOf(operation);\n+    switch (op) {\n+      case StartTranscription:\n+        String mpId = arguments.get(0);\n+        Track track = (Track) MediaPackageElementParser.getFromXml(arguments.get(1));\n+        String languageCode = arguments.get(2);\n+        createRecognitionsJob(mpId, track, languageCode);\n+        break;\n+      default:\n+        throw new IllegalStateException(\"Don't know how to handle operation '\" + operation + \"'\");\n+    }\n+    return result;\n+  }\n+\n+  /**\n+   * Asynchronous Requests and Responses call to Google Speech API\n+   * https://cloud.google.com/speech-to-text/docs/basics\n+   */\n+  void createRecognitionsJob(String mpId, Track track, String languageCode) throws TranscriptionServiceException, IOException {\n+    // Use default language if not set by workflow\n+    if (languageCode == null || languageCode.isEmpty()) {\n+      languageCode = language;\n+    }\n+    String audioUrl;\n+    audioUrl = uploadAudioFileToGoogleStorage(mpId, track);\n+    CloseableHttpClient httpClient = makeHttpClient();\n+    CloseableHttpResponse response = null;\n+    String token = getRefreshAccessToken();\n+    if (token.equals(INVALID_TOKEN) || audioUrl == null) {\n+      throw new TranscriptionServiceException(\"Could not create recognition job. Audio file or access token invalid\");\n+    }\n+\n+    // Create json for configuration and audio file \n+    JSONObject configValues = new JSONObject();\n+    JSONObject audioValues = new JSONObject();\n+    JSONObject container = new JSONObject();\n+    configValues.put(\"languageCode\", languageCode);\n+    configValues.put(\"enableWordTimeOffsets\", true);\n+    configValues.put(\"profanityFilter\", profanityFilter);\n+    audioValues.put(\"uri\", audioUrl);\n+    container.put(\"config\", configValues);\n+    container.put(\"audio\", audioValues);\n+\n+    try {\n+      HttpPost httpPost = new HttpPost(GOOGLE_SPEECH_URL + REQUEST_PATH);\n+      logger.debug(\"Url to invoke Google speech service: {}\", httpPost.getURI().toString());\n+      StringEntity params = new StringEntity(container.toJSONString());\n+      httpPost.addHeader(\"Authorization\", \"Bearer \" + token); // add the authorization header to the request;\n+      httpPost.addHeader(HttpHeaders.CONTENT_TYPE, \"application/json; charset=utf-8\");\n+      httpPost.setEntity(params);\n+      response = httpClient.execute(httpPost);\n+      int code = response.getStatusLine().getStatusCode();\n+      HttpEntity entity = response.getEntity();\n+      String jsonString = EntityUtils.toString(response.getEntity());\n+      JSONParser jsonParser = new JSONParser();\n+      JSONObject jsonObject = (JSONObject) jsonParser.parse(jsonString);\n+\n+      switch (code) {\n+        case HttpStatus.SC_OK: // 200\n+          logger.info(\"Recognitions job has been successfully created\");\n+\n+          /**\n+           * Response returned is a json object: { \"name\":\n+           * \"7612202767953098924\", \"metadata\": { \"@type\":\n+           * \"type.googleapis.com/google.cloud.speech.v1.LongRunningRecognizeMetadata\",\n+           * \"progressPercent\": 90, \"startTime\": \"2017-07-20T16:36:55.033650Z\",\n+           * \"lastUpdateTime\": \"2017-07-20T16:37:17.158630Z\" } }\n+           */\n+          String jobId = (String) jsonObject.get(\"name\");\n+          logger.info(String.format(\n+                  \"Transcription for mp %s has been submitted. Job id: %s\", mpId,\n+                  jobId));\n+\n+          database.storeJobControl(mpId, track.getIdentifier(), jobId, TranscriptionJobControl.Status.Progress.name(),\n+                  track.getDuration() == null ? 0 : track.getDuration().longValue(), PROVIDER);\n+          EntityUtils.consume(entity);\n+          return;\n+        default:\n+          JSONObject errorObj = (JSONObject) jsonObject.get(\"error\");\n+          logger.warn(\"Invalid argument returned, status: {} with message: {}\", code, (String) errorObj.get(\"message\"));\n+          break;\n+      }\n+      throw new TranscriptionServiceException(\"Could not create recognition job. Status returned: \" + code);\n+    } catch (Exception e) {\n+      logger.warn(\"Exception when calling the recognitions endpoint\", e);\n+      throw new TranscriptionServiceException(\"Exception when calling the recognitions endpoint\", e);\n+    } finally {\n+      try {\n+        httpClient.close();\n+        if (response != null) {\n+          response.close();\n+        }\n+      } catch (IOException e) {\n+      }\n+    }\n+  }\n+\n+  /**\n+   * Get transcription job result: GET /v1/operations/{name}\n+   *\n+   * \"response\": { \"@type\":\n+   * \"type.googleapis.com/google.cloud.speech.v1.LongRunningRecognizeResponse\",\n+   * \"results\": [ { \"alternatives\": [ { \"transcript\": \"Four score and\n+   * twenty...\", \"confidence\": 0.97186122, \"words\": [ { \"startTime\": \"1.300s\",\n+   * \"endTime\": \"1.400s\", \"word\": \"Four\" }, { \"startTime\": \"1.400s\", \"endTime\":\n+   * \"1.600s\", \"word\": \"score\" }, { \"startTime\": \"1.600s\", \"endTime\": \"1.600s\",\n+   * \"word\": \"and\" }, { \"startTime\": \"1.600s\", \"endTime\": \"1.900s\", \"word\":\n+   * \"twenty\" }, ] } ] }\n+   */\n+  boolean getAndSaveJobResults(String jobId) throws TranscriptionServiceException, IOException {\n+    CloseableHttpClient httpClient = makeHttpClient();\n+    CloseableHttpResponse response = null;\n+    String mpId = \"unknown\";\n+    JSONArray resultsArray = null;\n+    String token = getRefreshAccessToken();\n+    if (token.equals(INVALID_TOKEN)) {\n+      return false;\n+    }\n+    try {\n+      HttpGet httpGet = new HttpGet(GOOGLE_SPEECH_URL + RESULT_PATH + jobId);\n+      logger.debug(\"Url to invoke Google speech service: {}\", httpGet.getURI().toString());\n+      // add the authorization header to the request;\n+      httpGet.addHeader(\"Authorization\", \"Bearer \" + token);\n+      response = httpClient.execute(httpGet);\n+      int code = response.getStatusLine().getStatusCode();\n+\n+      switch (code) {\n+        case HttpStatus.SC_OK: // 200\n+          HttpEntity entity = response.getEntity();\n+          // Response returned is a json object described above\n+          String jsonString = EntityUtils.toString(entity);\n+          JSONParser jsonParser = new JSONParser();\n+          JSONObject jsonObject = (JSONObject) jsonParser.parse(jsonString);\n+          Boolean jobDone = (Boolean) jsonObject.get(\"done\");\n+          if (jobDone) {\n+            resultsArray = getTranscriptionResult(jsonObject);\n+          }\n+          TranscriptionJobControl jc = database.findByJob(jobId);\n+          if (jc != null) {\n+            mpId = jc.getMediaPackageId();\n+          }\n+          logger.info(\"Recognitions job {} has been found, completed status {}\", jobId, jobDone.toString());\n+          EntityUtils.consume(entity);\n+\n+          if (jobDone && resultsArray != null) {\n+            transcriptionDone(mpId, jsonObject);\n+            return true;\n+          }\n+          return false;\n+        case HttpStatus.SC_NOT_FOUND: // 404\n+          logger.warn(\"Job not found: {}\", jobId);\n+          break;\n+        case HttpStatus.SC_SERVICE_UNAVAILABLE: // 503\n+          logger.warn(\"Service unavailable returned, status: {}\", code);\n+          break;\n+        default:\n+          logger.warn(\"Error return status: {}.\", code);\n+          break;\n+      }\n+      throw new TranscriptionServiceException(\n+              String.format(\"Could not check recognition job for media package %s, job id %s. Status returned: %d\",\n+                      mpId, jobId, code), code);\n+    } catch (TranscriptionServiceException e) {\n+      throw e;\n+    } catch (Exception e) {\n+      String msg = String.format(\"Exception when calling the recognitions endpoint for media package %s, job id %s\",\n+              mpId, jobId);\n+      logger.warn(String.format(msg, mpId, jobId), e);\n+      throw new TranscriptionServiceException(String.format(\n+              \"Exception when calling the recognitions endpoint for media package %s, job id %s\", mpId, jobId), e);\n+    } finally {\n+      try {\n+        httpClient.close();\n+        if (response != null) {\n+          response.close();\n+        }\n+      } catch (IOException e) {\n+      }\n+    }\n+  }\n+\n+  /**\n+   * Get transcription result: GET /v1/operations/{name} Method mainly used by\n+   * the REST endpoint\n+   *\n+   * @param jobId\n+   * @return job details\n+   * @throws org.opencastproject.transcription.api.TranscriptionServiceException\n+   * @throws java.io.IOException\n+   */\n+  public String getTranscriptionResults(String jobId)\n+          throws TranscriptionServiceException, IOException {\n+    CloseableHttpClient httpClient = makeHttpClient();\n+    CloseableHttpResponse response = null;\n+    String token = getRefreshAccessToken();\n+    if (token.equals(INVALID_TOKEN)) {\n+      logger.warn(\"Invalid access token\");\n+      return \"No results found\";\n+    }\n+    try {\n+      HttpGet httpGet = new HttpGet(GOOGLE_SPEECH_URL + RESULT_PATH + jobId);\n+      logger.debug(\"Url to invoke Google speech service: {}\", httpGet.getURI().toString());\n+      // add the authorization header to the request;\n+      httpGet.addHeader(\"Authorization\", \"Bearer \" + token);\n+      response = httpClient.execute(httpGet);\n+      int code = response.getStatusLine().getStatusCode();\n+\n+      switch (code) {\n+        case HttpStatus.SC_OK: // 200\n+          HttpEntity entity = response.getEntity();\n+          logger.info(\"Retrieved details for transcription with job id: '{}'\", jobId);\n+          return EntityUtils.toString(entity);\n+        default:\n+          logger.warn(\"Error retrieving details for transcription with job id: '{}', return status: {}.\", jobId, code);\n+          break;\n+      }\n+    } catch (Exception e) {\n+      String msg = String.format(\"Exception when calling the transcription service for job id: %s\", jobId);\n+      logger.warn(String.format(msg, jobId), e);\n+      throw new TranscriptionServiceException(String.format(\n+              \"Exception when calling the transcription service for job id: %s\", jobId), e);\n+    } finally {\n+      try {\n+        httpClient.close();\n+        if (response != null) {\n+          response.close();\n+        }\n+      } catch (IOException e) {\n+      }\n+    }\n+    return \"No results found\";\n+  }\n+\n+  private void saveResults(String jobId, JSONObject jsonObj) throws IOException {\n+    JSONArray resultsArray = getTranscriptionResult(jsonObj);\n+    if (resultsArray != null) {\n+      // Save the results into a collection\n+      workspace.putInCollection(TRANSCRIPT_COLLECTION, buildResultsFileName(jobId),\n+              new ByteArrayInputStream(jsonObj.toJSONString().getBytes()));\n+    }\n+  }\n+\n+  @Override\n+  public MediaPackageElement getGeneratedTranscription(String mpId, String jobId)\n+          throws TranscriptionServiceException {\n+    try {\n+      // If jobId is unknown, look for all jobs associated to that mpId\n+      if (jobId == null || \"null\".equals(jobId)) {\n+        jobId = null;\n+        for (TranscriptionJobControl jc : database.findByMediaPackage(mpId)) {\n+          if (TranscriptionJobControl.Status.Closed.name().equals(jc.getStatus())\n+                  || TranscriptionJobControl.Status.TranscriptionComplete.name().equals(jc.getStatus())) {\n+            jobId = jc.getTranscriptionJobId();\n+          }\n+        }\n+      }\n+\n+      if (jobId == null) {\n+        throw new TranscriptionServiceException(\n+                \"No completed or closed transcription job found in database for media package \" + mpId);\n+      }\n+\n+      // Results already saved?\n+      URI uri = workspace.getCollectionURI(TRANSCRIPT_COLLECTION, buildResultsFileName(jobId));\n+      try {\n+        workspace.get(uri);\n+      } catch (Exception e) {\n+        try {\n+          // Not saved yet so call the google speech service to get the results\n+          getAndSaveJobResults(jobId);\n+        } catch (IOException ex) {\n+          logger.error(\"Unable to retrieve transcription job, error: {}\", ex.toString());\n+        }\n+      }\n+      MediaPackageElementBuilder builder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();\n+      return builder.elementFromURI(uri, Attachment.TYPE, new MediaPackageElementFlavor(\"captions\", \"google-speech-json\"));\n+    } catch (TranscriptionDatabaseException e) {\n+      throw new TranscriptionServiceException(\"Job id not informed and could not find transcription\", e);\n+    }\n+  }\n+\n+  /**\n+   * Get mediapackage transcription status\n+   *\n+   * @param mpId, mediapackage id\n+   * @return transcription status\n+   * @throws TranscriptionServiceException\n+   */\n+  public String getTranscriptionStatus(String mpId) throws TranscriptionServiceException {\n+    try {\n+      for (TranscriptionJobControl jc : database.findByMediaPackage(mpId)) {\n+        return jc.getStatus();\n+      }\n+    } catch (TranscriptionDatabaseException e) {\n+      throw new TranscriptionServiceException(\"Mediapackage id transcription status unknown\", e);\n+    }\n+    return \"Unknown\";\n+  }\n+\n+  protected CloseableHttpClient makeHttpClient() throws IOException {\n+    RequestConfig reqConfig = RequestConfig.custom().setConnectTimeout(CONNECTION_TIMEOUT)\n+            .setSocketTimeout(SOCKET_TIMEOUT).setConnectionRequestTimeout(CONNECTION_TIMEOUT).build();\n+    return HttpClients.custom().setDefaultRequestConfig(reqConfig)\n+            .build();\n+  }\n+\n+  protected String refreshAccessToken(String clientId, String clientSecret, String refreshToken)\n+          throws TranscriptionServiceException, IOException {\n+    CloseableHttpClient httpClient = makeHttpClient();\n+    CloseableHttpResponse response = null;\n+\n+    try {\n+      HttpPost httpPost = new HttpPost(tokenEndpoint + String.format(\n+              \"?client_id=%s&client_secret=%s&refresh_token=%s&grant_type=refresh_token\",\n+              clientId, clientSecret, refreshToken));\n+      httpPost.addHeader(HttpHeaders.CONTENT_TYPE, \"application/x-www-form-urlencoded\");\n+      response = httpClient.execute(httpPost);\n+      int code = response.getStatusLine().getStatusCode();\n+      String jsonString = EntityUtils.toString(response.getEntity());\n+      JSONParser jsonParser = new JSONParser();\n+      JSONObject jsonObject = (JSONObject) jsonParser.parse(jsonString);\n+      switch (code) {\n+        case HttpStatus.SC_OK: // 200\n+          accessToken = (String) jsonObject.get(\"access_token\");\n+          long duration = (long) jsonObject.get(\"expires_in\"); // Duration in second\n+          tokenExpiryTime = (System.currentTimeMillis() + (duration * 1000)); // time in millisecond\n+          if (!INVALID_TOKEN.equals(accessToken)) {\n+            logger.info(\"Google Cloud Service access token created\");\n+            return accessToken;\n+          }\n+          return INVALID_TOKEN;\n+        case HttpStatus.SC_BAD_REQUEST: // 400\n+        case HttpStatus.SC_UNAUTHORIZED: // 401\n+          String error = (String) jsonObject.get(\"error\");\n+          String errorDetails = (String) jsonObject.get(\"error_description\");\n+          logger.warn(\"Invalid argument returned, status: {}\", code);\n+          logger.warn(\"Unable to refresh Google Cloud Serice token, error: {}, error details: {}\", error, errorDetails);\n+          break;\n+        default:\n+          logger.warn(\"Invalid argument returned, status: {}\", code);\n+      }\n+      throw new TranscriptionServiceException(\n+              String.format(\"Could not create Google access token. Status returned: %d\", code), code);\n+    } catch (TranscriptionServiceException e) {\n+      throw e;\n+    } catch (Exception e) {\n+      logger.warn(\"Unable to generate access token for Google Cloud Services\");\n+      return INVALID_TOKEN;\n+    } finally {\n+      try {\n+        httpClient.close();\n+        if (response != null) {\n+          response.close();\n+        }\n+      } catch (IOException e) {\n+      }\n+    }\n+  }\n+\n+  protected String getRefreshAccessToken() throws TranscriptionServiceException, IOException {\n+    // Check that token hasn't expired\n+    if ((!INVALID_TOKEN.equals(accessToken)) && (System.currentTimeMillis() < (tokenExpiryTime - ACCESS_TOKEN_MINIMUN_TIME))) {\n+      return accessToken;\n+    }\n+    return refreshAccessToken(clientId, clientSecret, clientToken);\n+  }\n+\n+  protected String uploadAudioFileToGoogleStorage(String mpId, Track track)\n+          throws TranscriptionServiceException, IOException {\n+    File audioFile;\n+    String audioUrl = null;\n+    String fileExtension;\n+    int audioRespone;\n+    CloseableHttpClient httpClientStorage = makeHttpClient();\n+    GoogleSpeechTranscriptionServiceStorage storage = new GoogleSpeechTranscriptionServiceStorage();\n+    try {\n+      audioFile = workspace.get(track.getURI());\n+      fileExtension = FilenameUtils.getExtension(audioFile.getName());\n+      long fileSize = audioFile.length();\n+      String contentType = track.getMimeType().toString();\n+      String token = getRefreshAccessToken();\n+      // Upload file to google cloud storage\n+      audioRespone = storage.startUpload(httpClientStorage, storageBucket, mpId, fileExtension,\n+              audioFile, String.valueOf(fileSize), contentType, token);\n+      if (audioRespone == HttpStatus.SC_OK) {\n+        audioUrl = String.format(\"gs://%s/%s.%s\", storageBucket, mpId, fileExtension);\n+        return audioUrl;\n+      }\n+      logger.error(\"Errow when uploading audio to Google Storage, error code: {}\", audioRespone);\n+      return audioUrl;\n+    } catch (Exception e) {\n+      throw new TranscriptionServiceException(\"Error reading audio track\", e);\n+    }\n+  }\n+\n+  private JSONArray getTranscriptionResult(JSONObject jsonObj) {\n+    JSONObject responseObj = (JSONObject) jsonObj.get(\"response\");\n+    JSONArray resultsArray = (JSONArray) responseObj.get(\"results\");\n+    return resultsArray;\n+  }\n+\n+  protected void deleteStorageFile(String mpId, String token) throws IOException {\n+    CloseableHttpClient httpClientDel = makeHttpClient();\n+    GoogleSpeechTranscriptionServiceStorage storage = new GoogleSpeechTranscriptionServiceStorage();\n+    storage.deleteGoogleStorageFile(httpClientDel, storageBucket, mpId + \".flac\", token);\n+  }\n+\n+  private void sendEmail(String subject, String body) {\n+    if (toEmailAddress == null) {\n+      logger.info(\"Skipping sending email notification. Message is {}.\", body);\n+      return;\n+    }\n+    try {\n+      logger.debug(\"Sending e-mail notification to {}\", toEmailAddress);\n+      smtpService.send(toEmailAddress, String.format(\"%s (%s)\", subject, clusterName), body);\n+      logger.info(\"Sent e-mail notification to {}\", toEmailAddress);\n+    } catch (Exception e) {\n+      logger.error(String.format(\"Could not send email: %s\\n%s\", subject, body), e);\n+    }\n+  }\n+\n+  private String buildResultsFileName(String jobId) {\n+    return PathSupport.toSafeName(jobId + \".json\");\n+  }\n+\n+  public void setServiceRegistry(ServiceRegistry serviceRegistry) {\n+    this.serviceRegistry = serviceRegistry;\n+  }\n+\n+  public void setSecurityService(SecurityService securityService) {\n+    this.securityService = securityService;\n+  }\n+\n+  public void setUserDirectoryService(UserDirectoryService userDirectoryService) {\n+    this.userDirectoryService = userDirectoryService;\n+  }\n+\n+  public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectoryService) {\n+    this.organizationDirectoryService = organizationDirectoryService;\n+  }\n+\n+  public void setSmtpService(SmtpService service) {\n+    this.smtpService = service;\n+  }\n+\n+  public void setWorkspace(Workspace ws) {\n+    this.workspace = ws;\n+  }\n+\n+  public void setWorkingFileRepository(WorkingFileRepository wfr) {\n+    this.wfr = wfr;\n+  }\n+\n+  public void setDatabase(TranscriptionDatabase service) {\n+    this.database = service;\n+  }\n+\n+  public void setAssetManager(AssetManager service) {\n+    this.assetManager = service;\n+  }\n+\n+  public void setWorkflowService(WorkflowService service) {\n+    this.workflowService = service;\n+  }\n+\n+  @Override\n+  protected ServiceRegistry getServiceRegistry() {\n+    return serviceRegistry;\n+  }\n+\n+  @Override\n+  protected SecurityService getSecurityService() {\n+    return securityService;\n+  }\n+\n+  @Override\n+  protected UserDirectoryService getUserDirectoryService() {\n+    return userDirectoryService;\n+  }\n+\n+  @Override\n+  protected OrganizationDirectoryService getOrganizationDirectoryService() {\n+    return organizationDirectoryService;\n+  }\n+\n+  // Only used by unit tests!\n+  void setWfUtil(Workflows wfUtil) {\n+    this.wfUtil = wfUtil;\n+  }\n+\n+  class WorkflowDispatcher implements Runnable {\n+\n+    /**\n+     * {@inheritDoc}\n+     *\n+     * @see java.lang.Thread#run()\n+     */\n+    @Override\n+    public void run() {\n+      logger.debug(\"WorkflowDispatcher waking up...\");\n+\n+      try {\n+        // Find jobs that are in progress and jobs that had transcription complete\n+        List<TranscriptionJobControl> jobs = database.findByStatus(TranscriptionJobControl.Status.Progress.name(),\n+                TranscriptionJobControl.Status.TranscriptionComplete.name());\n+        for (TranscriptionJobControl j : jobs) {\n+          String mpId = j.getMediaPackageId();\n+          String jobId = j.getTranscriptionJobId();\n+\n+          // If the job in progress, check if it should already have finished.\n+          if (TranscriptionJobControl.Status.Progress.name().equals(j.getStatus())) {\n+            // If job should already have been completed, try to get the results. Consider a buffer factor so that we\n+            // don't try it too early.\n+            if (j.getDateCreated().getTime() + j.getTrackDuration() + completionCheckBuffer * 1000 < System","path":"modules/transcription-service-google-speech-impl/src/main/java/org/opencastproject/transcription/googlespeech/GoogleSpeechTranscriptionService.java","position":null,"original_position":944,"commit_id":"083507923ea40c92f0a39fa94026cbc845071b3c","original_commit_id":"14def41b008ec52618f0d6b2e8de841b609ae698","user":{"login":"tyfranck","id":35366608,"node_id":"MDQ6VXNlcjM1MzY2NjA4","avatar_url":"https://avatars3.githubusercontent.com/u/35366608?v=4","gravatar_id":"","url":"https://api.github.com/users/tyfranck","html_url":"https://github.com/tyfranck","followers_url":"https://api.github.com/users/tyfranck/followers","following_url":"https://api.github.com/users/tyfranck/following{/other_user}","gists_url":"https://api.github.com/users/tyfranck/gists{/gist_id}","starred_url":"https://api.github.com/users/tyfranck/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/tyfranck/subscriptions","organizations_url":"https://api.github.com/users/tyfranck/orgs","repos_url":"https://api.github.com/users/tyfranck/repos","events_url":"https://api.github.com/users/tyfranck/events{/privacy}","received_events_url":"https://api.github.com/users/tyfranck/received_events","type":"User","site_admin":false},"body":"fixed","created_at":"2019-07-16T13:42:24Z","updated_at":"2019-07-16T13:42:25Z","html_url":"https://github.com/opencast/opencast/pull/823#discussion_r303915558","pull_request_url":"https://api.github.com/repos/opencast/opencast/pulls/823","author_association":"CONTRIBUTOR","_links":{"self":{"href":"https://api.github.com/repos/opencast/opencast/pulls/comments/303915558"},"html":{"href":"https://github.com/opencast/opencast/pull/823#discussion_r303915558"},"pull_request":{"href":"https://api.github.com/repos/opencast/opencast/pulls/823"}},"in_reply_to_id":276431705},"pull_request":{"url":"https://api.github.com/repos/opencast/opencast/pulls/823","id":266145089,"node_id":"MDExOlB1bGxSZXF1ZXN0MjY2MTQ1MDg5","html_url":"https://github.com/opencast/opencast/pull/823","diff_url":"https://github.com/opencast/opencast/pull/823.diff","patch_url":"https://github.com/opencast/opencast/pull/823.patch","issue_url":"https://api.github.com/repos/opencast/opencast/issues/823","number":823,"state":"open","locked":false,"title":"MH-13265, Automatic caption using Google speech to text api","user":{"login":"tyfranck","id":35366608,"node_id":"MDQ6VXNlcjM1MzY2NjA4","avatar_url":"https://avatars3.githubusercontent.com/u/35366608?v=4","gravatar_id":"","url":"https://api.github.com/users/tyfranck","html_url":"https://github.com/tyfranck","followers_url":"https://api.github.com/users/tyfranck/followers","following_url":"https://api.github.com/users/tyfranck/following{/other_user}","gists_url":"https://api.github.com/users/tyfranck/gists{/gist_id}","starred_url":"https://api.github.com/users/tyfranck/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/tyfranck/subscriptions","organizations_url":"https://api.github.com/users/tyfranck/orgs","repos_url":"https://api.github.com/users/tyfranck/repos","events_url":"https://api.github.com/users/tyfranck/events{/privacy}","received_events_url":"https://api.github.com/users/tyfranck/received_events","type":"User","site_admin":false},"body":"Implementation of automatic captions/transcriptions using Google speech to text. \r\nPlease review :-)","created_at":"2019-04-01T08:45:28Z","updated_at":"2019-07-16T13:42:25Z","closed_at":null,"merged_at":null,"merge_commit_sha":"b947a041283054118b7ac1075a03c857383a27a2","assignee":{"login":"gregorydlogan","id":5639561,"node_id":"MDQ6VXNlcjU2Mzk1NjE=","avatar_url":"https://avatars2.githubusercontent.com/u/5639561?v=4","gravatar_id":"","url":"https://api.github.com/users/gregorydlogan","html_url":"https://github.com/gregorydlogan","followers_url":"https://api.github.com/users/gregorydlogan/followers","following_url":"https://api.github.com/users/gregorydlogan/following{/other_user}","gists_url":"https://api.github.com/users/gregorydlogan/gists{/gist_id}","starred_url":"https://api.github.com/users/gregorydlogan/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/gregorydlogan/subscriptions","organizations_url":"https://api.github.com/users/gregorydlogan/orgs","repos_url":"https://api.github.com/users/gregorydlogan/repos","events_url":"https://api.github.com/users/gregorydlogan/events{/privacy}","received_events_url":"https://api.github.com/users/gregorydlogan/received_events","type":"User","site_admin":false},"assignees":[{"login":"gregorydlogan","id":5639561,"node_id":"MDQ6VXNlcjU2Mzk1NjE=","avatar_url":"https://avatars2.githubusercontent.com/u/5639561?v=4","gravatar_id":"","url":"https://api.github.com/users/gregorydlogan","html_url":"https://github.com/gregorydlogan","followers_url":"https://api.github.com/users/gregorydlogan/followers","following_url":"https://api.github.com/users/gregorydlogan/following{/other_user}","gists_url":"https://api.github.com/users/gregorydlogan/gists{/gist_id}","starred_url":"https://api.github.com/users/gregorydlogan/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/gregorydlogan/subscriptions","organizations_url":"https://api.github.com/users/gregorydlogan/orgs","repos_url":"https://api.github.com/users/gregorydlogan/repos","events_url":"https://api.github.com/users/gregorydlogan/events{/privacy}","received_events_url":"https://api.github.com/users/gregorydlogan/received_events","type":"User","site_admin":false}],"requested_reviewers":[],"requested_teams":[],"labels":[{"id":843360799,"node_id":"MDU6TGFiZWw4NDMzNjA3OTk=","url":"https://api.github.com/repos/opencast/opencast/labels/feature","name":"feature","color":"bfd657","default":false}],"milestone":null,"commits_url":"https://api.github.com/repos/opencast/opencast/pulls/823/commits","review_comments_url":"https://api.github.com/repos/opencast/opencast/pulls/823/comments","review_comment_url":"https://api.github.com/repos/opencast/opencast/pulls/comments{/number}","comments_url":"https://api.github.com/repos/opencast/opencast/issues/823/comments","statuses_url":"https://api.github.com/repos/opencast/opencast/statuses/083507923ea40c92f0a39fa94026cbc845071b3c","head":{"label":"tyfranck:f/MH-13265_Automate_caption_using_google_speech_API","ref":"f/MH-13265_Automate_caption_using_google_speech_API","sha":"083507923ea40c92f0a39fa94026cbc845071b3c","user":{"login":"tyfranck","id":35366608,"node_id":"MDQ6VXNlcjM1MzY2NjA4","avatar_url":"https://avatars3.githubusercontent.com/u/35366608?v=4","gravatar_id":"","url":"https://api.github.com/users/tyfranck","html_url":"https://github.com/tyfranck","followers_url":"https://api.github.com/users/tyfranck/followers","following_url":"https://api.github.com/users/tyfranck/following{/other_user}","gists_url":"https://api.github.com/users/tyfranck/gists{/gist_id}","starred_url":"https://api.github.com/users/tyfranck/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/tyfranck/subscriptions","organizations_url":"https://api.github.com/users/tyfranck/orgs","repos_url":"https://api.github.com/users/tyfranck/repos","events_url":"https://api.github.com/users/tyfranck/events{/privacy}","received_events_url":"https://api.github.com/users/tyfranck/received_events","type":"User","site_admin":false},"repo":{"id":168692029,"node_id":"MDEwOlJlcG9zaXRvcnkxNjg2OTIwMjk=","name":"opencast","full_name":"tyfranck/opencast","private":false,"owner":{"login":"tyfranck","id":35366608,"node_id":"MDQ6VXNlcjM1MzY2NjA4","avatar_url":"https://avatars3.githubusercontent.com/u/35366608?v=4","gravatar_id":"","url":"https://api.github.com/users/tyfranck","html_url":"https://github.com/tyfranck","followers_url":"https://api.github.com/users/tyfranck/followers","following_url":"https://api.github.com/users/tyfranck/following{/other_user}","gists_url":"https://api.github.com/users/tyfranck/gists{/gist_id}","starred_url":"https://api.github.com/users/tyfranck/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/tyfranck/subscriptions","organizations_url":"https://api.github.com/users/tyfranck/orgs","repos_url":"https://api.github.com/users/tyfranck/repos","events_url":"https://api.github.com/users/tyfranck/events{/privacy}","received_events_url":"https://api.github.com/users/tyfranck/received_events","type":"User","site_admin":false},"html_url":"https://github.com/tyfranck/opencast","description":"The free and open source solution for automated video capture and distribution at scale.","fork":true,"url":"https://api.github.com/repos/tyfranck/opencast","forks_url":"https://api.github.com/repos/tyfranck/opencast/forks","keys_url":"https://api.github.com/repos/tyfranck/opencast/keys{/key_id}","collaborators_url":"https://api.github.com/repos/tyfranck/opencast/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/tyfranck/opencast/teams","hooks_url":"https://api.github.com/repos/tyfranck/opencast/hooks","issue_events_url":"https://api.github.com/repos/tyfranck/opencast/issues/events{/number}","events_url":"https://api.github.com/repos/tyfranck/opencast/events","assignees_url":"https://api.github.com/repos/tyfranck/opencast/assignees{/user}","branches_url":"https://api.github.com/repos/tyfranck/opencast/branches{/branch}","tags_url":"https://api.github.com/repos/tyfranck/opencast/tags","blobs_url":"https://api.github.com/repos/tyfranck/opencast/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/tyfranck/opencast/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/tyfranck/opencast/git/refs{/sha}","trees_url":"https://api.github.com/repos/tyfranck/opencast/git/trees{/sha}","statuses_url":"https://api.github.com/repos/tyfranck/opencast/statuses/{sha}","languages_url":"https://api.github.com/repos/tyfranck/opencast/languages","stargazers_url":"https://api.github.com/repos/tyfranck/opencast/stargazers","contributors_url":"https://api.github.com/repos/tyfranck/opencast/contributors","subscribers_url":"https://api.github.com/repos/tyfranck/opencast/subscribers","subscription_url":"https://api.github.com/repos/tyfranck/opencast/subscription","commits_url":"https://api.github.com/repos/tyfranck/opencast/commits{/sha}","git_commits_url":"https://api.github.com/repos/tyfranck/opencast/git/commits{/sha}","comments_url":"https://api.github.com/repos/tyfranck/opencast/comments{/number}","issue_comment_url":"https://api.github.com/repos/tyfranck/opencast/issues/comments{/number}","contents_url":"https://api.github.com/repos/tyfranck/opencast/contents/{+path}","compare_url":"https://api.github.com/repos/tyfranck/opencast/compare/{base}...{head}","merges_url":"https://api.github.com/repos/tyfranck/opencast/merges","archive_url":"https://api.github.com/repos/tyfranck/opencast/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/tyfranck/opencast/downloads","issues_url":"https://api.github.com/repos/tyfranck/opencast/issues{/number}","pulls_url":"https://api.github.com/repos/tyfranck/opencast/pulls{/number}","milestones_url":"https://api.github.com/repos/tyfranck/opencast/milestones{/number}","notifications_url":"https://api.github.com/repos/tyfranck/opencast/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/tyfranck/opencast/labels{/name}","releases_url":"https://api.github.com/repos/tyfranck/opencast/releases{/id}","deployments_url":"https://api.github.com/repos/tyfranck/opencast/deployments","created_at":"2019-02-01T12:08:07Z","updated_at":"2019-02-01T12:08:23Z","pushed_at":"2019-07-16T13:28:34Z","git_url":"git://github.com/tyfranck/opencast.git","ssh_url":"git@github.com:tyfranck/opencast.git","clone_url":"https://github.com/tyfranck/opencast.git","svn_url":"https://github.com/tyfranck/opencast","homepage":"https://opencast.org","size":151722,"stargazers_count":0,"watchers_count":0,"language":"Java","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":0,"license":{"key":"ecl-2.0","name":"Educational Community License v2.0","spdx_id":"ECL-2.0","url":"https://api.github.com/licenses/ecl-2.0","node_id":"MDc6TGljZW5zZTMw"},"forks":0,"open_issues":0,"watchers":0,"default_branch":"develop"}},"base":{"label":"opencast:develop","ref":"develop","sha":"9830c9b5141948303b284952951cea061a06baca","user":{"login":"opencast","id":5639632,"node_id":"MDEyOk9yZ2FuaXphdGlvbjU2Mzk2MzI=","avatar_url":"https://avatars0.githubusercontent.com/u/5639632?v=4","gravatar_id":"","url":"https://api.github.com/users/opencast","html_url":"https://github.com/opencast","followers_url":"https://api.github.com/users/opencast/followers","following_url":"https://api.github.com/users/opencast/following{/other_user}","gists_url":"https://api.github.com/users/opencast/gists{/gist_id}","starred_url":"https://api.github.com/users/opencast/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/opencast/subscriptions","organizations_url":"https://api.github.com/users/opencast/orgs","repos_url":"https://api.github.com/users/opencast/repos","events_url":"https://api.github.com/users/opencast/events{/privacy}","received_events_url":"https://api.github.com/users/opencast/received_events","type":"Organization","site_admin":false},"repo":{"id":115198687,"node_id":"MDEwOlJlcG9zaXRvcnkxMTUxOTg2ODc=","name":"opencast","full_name":"opencast/opencast","private":false,"owner":{"login":"opencast","id":5639632,"node_id":"MDEyOk9yZ2FuaXphdGlvbjU2Mzk2MzI=","avatar_url":"https://avatars0.githubusercontent.com/u/5639632?v=4","gravatar_id":"","url":"https://api.github.com/users/opencast","html_url":"https://github.com/opencast","followers_url":"https://api.github.com/users/opencast/followers","following_url":"https://api.github.com/users/opencast/following{/other_user}","gists_url":"https://api.github.com/users/opencast/gists{/gist_id}","starred_url":"https://api.github.com/users/opencast/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/opencast/subscriptions","organizations_url":"https://api.github.com/users/opencast/orgs","repos_url":"https://api.github.com/users/opencast/repos","events_url":"https://api.github.com/users/opencast/events{/privacy}","received_events_url":"https://api.github.com/users/opencast/received_events","type":"Organization","site_admin":false},"html_url":"https://github.com/opencast/opencast","description":"The free and open source solution for automated video capture and distribution at scale.","fork":false,"url":"https://api.github.com/repos/opencast/opencast","forks_url":"https://api.github.com/repos/opencast/opencast/forks","keys_url":"https://api.github.com/repos/opencast/opencast/keys{/key_id}","collaborators_url":"https://api.github.com/repos/opencast/opencast/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/opencast/opencast/teams","hooks_url":"https://api.github.com/repos/opencast/opencast/hooks","issue_events_url":"https://api.github.com/repos/opencast/opencast/issues/events{/number}","events_url":"https://api.github.com/repos/opencast/opencast/events","assignees_url":"https://api.github.com/repos/opencast/opencast/assignees{/user}","branches_url":"https://api.github.com/repos/opencast/opencast/branches{/branch}","tags_url":"https://api.github.com/repos/opencast/opencast/tags","blobs_url":"https://api.github.com/repos/opencast/opencast/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/opencast/opencast/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/opencast/opencast/git/refs{/sha}","trees_url":"https://api.github.com/repos/opencast/opencast/git/trees{/sha}","statuses_url":"https://api.github.com/repos/opencast/opencast/statuses/{sha}","languages_url":"https://api.github.com/repos/opencast/opencast/languages","stargazers_url":"https://api.github.com/repos/opencast/opencast/stargazers","contributors_url":"https://api.github.com/repos/opencast/opencast/contributors","subscribers_url":"https://api.github.com/repos/opencast/opencast/subscribers","subscription_url":"https://api.github.com/repos/opencast/opencast/subscription","commits_url":"https://api.github.com/repos/opencast/opencast/commits{/sha}","git_commits_url":"https://api.github.com/repos/opencast/opencast/git/commits{/sha}","comments_url":"https://api.github.com/repos/opencast/opencast/comments{/number}","issue_comment_url":"https://api.github.com/repos/opencast/opencast/issues/comments{/number}","contents_url":"https://api.github.com/repos/opencast/opencast/contents/{+path}","compare_url":"https://api.github.com/repos/opencast/opencast/compare/{base}...{head}","merges_url":"https://api.github.com/repos/opencast/opencast/merges","archive_url":"https://api.github.com/repos/opencast/opencast/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/opencast/opencast/downloads","issues_url":"https://api.github.com/repos/opencast/opencast/issues{/number}","pulls_url":"https://api.github.com/repos/opencast/opencast/pulls{/number}","milestones_url":"https://api.github.com/repos/opencast/opencast/milestones{/number}","notifications_url":"https://api.github.com/repos/opencast/opencast/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/opencast/opencast/labels{/name}","releases_url":"https://api.github.com/repos/opencast/opencast/releases{/id}","deployments_url":"https://api.github.com/repos/opencast/opencast/deployments","created_at":"2017-12-23T13:54:35Z","updated_at":"2019-07-11T16:14:17Z","pushed_at":"2019-07-16T13:28:36Z","git_url":"git://github.com/opencast/opencast.git","ssh_url":"git@github.com:opencast/opencast.git","clone_url":"https://github.com/opencast/opencast.git","svn_url":"https://github.com/opencast/opencast","homepage":"https://opencast.org","size":150401,"stargazers_count":91,"watchers_count":91,"language":"Java","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":81,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":29,"license":{"key":"ecl-2.0","name":"Educational Community License v2.0","spdx_id":"ECL-2.0","url":"https://api.github.com/licenses/ecl-2.0","node_id":"MDc6TGljZW5zZTMw"},"forks":81,"open_issues":29,"watchers":91,"default_branch":"develop"}},"_links":{"self":{"href":"https://api.github.com/repos/opencast/opencast/pulls/823"},"html":{"href":"https://github.com/opencast/opencast/pull/823"},"issue":{"href":"https://api.github.com/repos/opencast/opencast/issues/823"},"comments":{"href":"https://api.github.com/repos/opencast/opencast/issues/823/comments"},"review_comments":{"href":"https://api.github.com/repos/opencast/opencast/pulls/823/comments"},"review_comment":{"href":"https://api.github.com/repos/opencast/opencast/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/opencast/opencast/pulls/823/commits"},"statuses":{"href":"https://api.github.com/repos/opencast/opencast/statuses/083507923ea40c92f0a39fa94026cbc845071b3c"}},"author_association":"CONTRIBUTOR"}}
{"actor":{"display_login":"tyfranck"}}