de.chaosfisch.google.youtube.upload.UploadJob.java Source code

Java tutorial

Introduction

Here is the source code for de.chaosfisch.google.youtube.upload.UploadJob.java

Source

/**************************************************************************************************
 * Copyright (c) 2014 Dennis Fischer.                                                             *
 * All rights reserved. This program and the accompanying materials                               *
 * are made available under the terms of the GNU Public License v3.0+                             *
 * which accompanies this distribution, and is available at                                       *
 * http://www.gnu.org/licenses/gpl.html                                                           *
 *                                                                                                *
 * Contributors: Dennis Fischer                                                                   *
 **************************************************************************************************/

package de.chaosfisch.google.youtube.upload;

import com.blogspot.nurkiewicz.asyncretry.AsyncRetryExecutor;
import com.blogspot.nurkiewicz.asyncretry.RetryContext;
import com.blogspot.nurkiewicz.asyncretry.RetryExecutor;
import com.blogspot.nurkiewicz.asyncretry.function.RetryRunnable;
import com.google.api.client.auth.oauth2.Credential;
import com.google.common.base.Charsets;
import com.google.common.base.Predicate;
import com.google.common.eventbus.EventBus;
import com.google.common.io.CharStreams;
import com.google.common.io.InputSupplier;
import com.google.common.util.concurrent.RateLimiter;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
import com.mashape.unirest.http.exceptions.UnirestException;
import de.chaosfisch.google.GDATAConfig;
import de.chaosfisch.google.YouTubeProvider;
import de.chaosfisch.google.youtube.upload.events.UploadJobProgressEvent;
import de.chaosfisch.google.youtube.upload.metadata.IMetadataService;
import de.chaosfisch.google.youtube.upload.metadata.MetaBadRequestException;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.SocketException;
import java.net.URI;
import java.net.URL;
import java.util.Calendar;
import java.util.Set;
import java.util.concurrent.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class UploadJob implements Callable<Upload> {

    private static final int SC_OK = 200;
    private static final int SC_CREATED = 201;
    private static final int SC_MULTIPLE_CHOICES = 300;
    private static final int SC_RESUME_INCOMPLETE = 308;
    private static final int SC_BAD_REQUEST = 400;
    private static final long chunkSize = 10485760;
    private static final int DEFAULT_BUFFER_SIZE = 65536;
    private static final Logger LOGGER = LoggerFactory.getLogger(UploadJob.class);
    private static final String METADATA_CREATE_RESUMEABLE_URL = "http://uploads.gdata.youtube.com/resumable/feeds/api/users/default/uploads";
    private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("-");
    private static final int SC_500 = 500;
    private static final Pattern RAW_FILE_PATTERN = Pattern.compile("[^a-zA-Z0-9.]+");

    /**
     * File that is uploaded
     */
    private File fileToUpload;
    private long start;
    private long bytesToUpload;
    private long totalBytesUploaded;
    private long fileSize;

    private final Set<UploadPreProcessor> uploadPreProcessors;
    private final Set<UploadPostProcessor> uploadPostProcessors;
    private final EventBus eventBus;
    private final IUploadService uploadService;
    private final RateLimiter rateLimiter;

    private UploadJobProgressEvent uploadProgress;
    private Upload upload;
    private final YouTubeProvider youTubeProvider;
    private final IMetadataService metadataService;
    private Credential credential;

    @Inject
    private UploadJob(@Assisted final Upload upload, @Assisted final RateLimiter rateLimiter,
            final Set<UploadPreProcessor> uploadPreProcessors, final Set<UploadPostProcessor> uploadPostProcessors,
            final EventBus eventBus, final IUploadService uploadService, final YouTubeProvider youTubeProvider,
            final IMetadataService metadataService) {
        this.upload = upload;
        this.rateLimiter = rateLimiter;
        this.uploadPreProcessors = uploadPreProcessors;
        this.uploadPostProcessors = uploadPostProcessors;
        this.eventBus = eventBus;
        this.uploadService = uploadService;
        this.youTubeProvider = youTubeProvider;
        this.metadataService = metadataService;
        this.eventBus.register(this);
    }

    @Override
    public Upload call() throws Exception {

        if (null == upload.getUploadurl()) {
            for (final UploadPreProcessor preProcessor : uploadPreProcessors) {
                try {
                    upload = preProcessor.process(upload);
                } catch (final Exception e) {
                    LOGGER.error("Preprocessor error", e);
                }
            }
        }

        final ScheduledExecutorService schedueler = Executors.newSingleThreadScheduledExecutor();
        final RetryExecutor executor = new AsyncRetryExecutor(schedueler)
                .withExponentialBackoff(TimeUnit.SECONDS.toMillis(3), 2).withMaxDelay(TimeUnit.MINUTES.toMillis(1))
                .retryOn(IOException.class).retryOn(RuntimeException.class).retryOn(UploadResponseException.class)
                .retryOn(SocketException.class).abortIf(new Predicate<Throwable>() {
                    @Override
                    public boolean apply(@Nullable final Throwable input) {
                        return input instanceof UploadResponseException
                                && SC_500 >= ((UploadResponseException) input).getStatus();
                    }
                }).abortOn(MetaBadRequestException.class).abortOn(FileNotFoundException.class)
                .abortOn(UploadFinishedException.class);

        try {
            // Schritt 1: Initialize
            initialize();
            // Schritt 2: MetadataUpload + UrlFetch
            executor.doWithRetry(metadata()).get();
            // Schritt 3: Chunkupload
            executor.doWithRetry(upload()).get();
        } catch (final InterruptedException ignored) {
            upload.getStatus().setAborted(true);
        } catch (final Exception e) {
            if (!upload.getStatus().isArchived()) {
                LOGGER.error("Upload error", e);
                upload.getStatus().setFailed(true);
            }
        } finally {
            schedueler.shutdownNow();
            eventBus.unregister(this);
        }

        if (upload.getStatus().isArchived()) {
            LOGGER.info("Starting postprocessing");
            for (final UploadPostProcessor postProcessor : uploadPostProcessors) {
                try {
                    upload = postProcessor.process(upload);
                } catch (final Exception e) {
                    LOGGER.error("Postprocessor error", e);
                }
            }
        }

        upload.getStatus().setRunning(false);
        uploadService.update(upload);
        return upload;
    }

    private void initialize() throws FileNotFoundException {
        // Set the time uploaded started
        upload.setDateTimeOfStart(DateTime.now());
        uploadService.update(upload);

        // Get File and Check if existing
        fileToUpload = upload.getFile();

        if (!fileToUpload.exists()) {
            throw new FileNotFoundException("Datei existiert nicht.");
        }
    }

    private RetryRunnable metadata() {
        return new RetryRunnable() {

            @Override
            public void run(final RetryContext retryContext)
                    throws IOException, MetaBadRequestException, UnirestException {
                fileSize = fileToUpload.length();
                totalBytesUploaded = 0;
                start = 0;
                bytesToUpload = fileSize;

                if (null != upload.getUploadurl() && !upload.getUploadurl().isEmpty()) {
                    LOGGER.info("Uploadurl existing: {}", upload.getUploadurl());
                    return;
                }

                upload.setUploadurl(fetchUploadUrl(upload));
                uploadService.update(upload);

                // Log operation
                LOGGER.info("Uploadurl received: {}", upload.getUploadurl());
            }
        };
    }

    private String fetchUploadUrl(final Upload upload)
            throws MetaBadRequestException, UnirestException, IOException {
        // Upload atomData and fetch uploadUrl
        final String atomData = metadataService.atomBuilder(upload);
        final HttpResponse<String> response = Unirest.post(METADATA_CREATE_RESUMEABLE_URL)
                .header("GData-Version", GDATAConfig.GDATA_V2)
                .header("X-GData-Key", "key=" + GDATAConfig.DEVELOPER_KEY)
                .header("Content-Type", "application/atom+xml; charset=UTF-8;")
                .header("Slug", RAW_FILE_PATTERN.matcher(fileToUpload.getName()).replaceAll(""))
                .header("Authorization", getAuthHeader()).body(atomData).asString();

        LOGGER.info("fetchUploadUrl response code: {}", response.getCode());
        LOGGER.info("fetchUploadUrl response headers: {}", response.getHeaders());
        LOGGER.info("fetchUploadUrl response: {}", response.getBody());
        // Check the response code for any problematic codes.
        if (SC_BAD_REQUEST == response.getCode()) {
            throw new MetaBadRequestException(atomData, response.getCode());
        }
        // Check if uploadurl is available
        if (response.getHeaders().containsKey("location")) {
            return response.getHeaders().get("location");
        } else {
            throw new MetaBadRequestException("Location missing", response.getCode());
        }
    }

    private String getAuthHeader() throws IOException {
        if (null == credential) {
            credential = youTubeProvider.getCredential(upload.getAccount());
        }

        if (null == credential.getAccessToken()
                || null != credential.getExpiresInSeconds() && 60 >= credential.getExpiresInSeconds()) {
            credential.refreshToken();
        }
        return String.format("Bearer %s", credential.getAccessToken());
    }

    private RetryRunnable upload() {
        return new RetryRunnable() {

            @Override
            public void run(final RetryContext retryContext)
                    throws IOException, UploadResponseException, UploadFinishedException, UnirestException {
                if (null != upload.getUploadurl() || null != retryContext.getLastThrowable()) {
                    if (0 < retryContext.getRetryCount()) {
                        LOGGER.info("############ RETRY " + retryContext.getRetryCount() + " ############");
                    }
                    resumeinfo();
                }
                uploadChunks();
            }
        };
    }

    private void uploadChunks() throws IOException, UploadResponseException, UploadFinishedException {
        while (!Thread.currentThread().isInterrupted() && totalBytesUploaded != fileSize) {
            uploadChunk();
        }
    }

    private void uploadChunk() throws IOException, UploadResponseException, UploadFinishedException {
        // GET END SIZE
        final long end = generateEndBytes(start, bytesToUpload);

        // Log operation
        LOGGER.debug("start={} end={} filesize={}", start, end, fileSize);

        // Log operation
        LOGGER.debug("Uploaded {} bytes so far, using PUT method.", totalBytesUploaded);

        if (null == uploadProgress) {
            uploadProgress = new UploadJobProgressEvent(upload, upload.getFile().length());
            uploadProgress.setTime(Calendar.getInstance().getTimeInMillis());
        }

        // Calculating the chunk size
        final int chunk = (int) (end - start + 1);

        // Building PUT RequestImpl for chunk data
        final URL url = URI.create(upload.getUploadurl()).toURL();
        final HttpURLConnection request = (HttpURLConnection) url.openConnection();
        request.setRequestMethod("POST");
        request.setDoOutput(true);
        request.setFixedLengthStreamingMode(chunk);
        //Properties
        request.setRequestProperty("Content-Type", upload.getMimetype());
        request.setRequestProperty("Content-Range",
                String.format("bytes %d-%d/%d", start, end, fileToUpload.length()));
        request.setRequestProperty("Authorization", getAuthHeader());
        request.setRequestProperty("GData-Version", GDATAConfig.GDATA_V2);
        request.setRequestProperty("X-GData-Key", String.format("key=%s", GDATAConfig.DEVELOPER_KEY));
        request.connect();

        try (final TokenInputStream tokenInputStream = new TokenInputStream(new FileInputStream(upload.getFile()));
                final BufferedOutputStream throttledOutputStream = new BufferedOutputStream(
                        request.getOutputStream())) {
            tokenInputStream.skip(start);
            flowChunk(tokenInputStream, throttledOutputStream, start, end);

            switch (request.getResponseCode()) {
            case SC_OK:
            case SC_CREATED:
                //FILE UPLOADED
                final InputSupplier<InputStream> supplier = new InputSupplier<InputStream>() {
                    @Override
                    public InputStream getInput() throws IOException {
                        return request.getInputStream();
                    }
                };
                handleSuccessfulUpload(
                        CharStreams.toString(CharStreams.newReaderSupplier(supplier, Charsets.UTF_8)));

                break;
            case SC_RESUME_INCOMPLETE:
                // OK, the chunk completed succesfully
                LOGGER.debug("responseMessage={}", request.getResponseMessage());
                break;
            default:
                throw new UploadResponseException(request.getResponseCode());
            }

            bytesToUpload -= chunkSize;
            start = end + 1;
        }
    }

    private void resumeinfo()
            throws UploadFinishedException, UploadResponseException, UnirestException, IOException {
        final HttpResponse<String> response = Unirest.put(upload.getUploadurl())
                .header("GData-Version", GDATAConfig.GDATA_V2)
                .header("X-GData-Key", "key=" + GDATAConfig.DEVELOPER_KEY)
                .header("Content-Type", "application/atom+xml; charset=UTF-8;")
                .header("Authorization", getAuthHeader()).header("Content-Range", "bytes */*").asString();

        if (SC_OK <= response.getCode() && SC_MULTIPLE_CHOICES > response.getCode()) {
            handleSuccessfulUpload(response.getBody());
        } else if (SC_RESUME_INCOMPLETE != response.getCode()) {
            throw new UploadResponseException(response.getCode());
        }

        if (!response.getHeaders().containsKey("range")) {
            LOGGER.info("PUT to {} did not return Range-header.", upload.getUploadurl());
            totalBytesUploaded = 0;
        } else {
            LOGGER.info("Range header is: {}", response.getHeaders().get("range"));

            final String[] parts = RANGE_HEADER_PATTERN.split(response.getHeaders().get("range"));
            if (1 < parts.length) {
                totalBytesUploaded = Long.parseLong(parts[1]) + 1;
            } else {
                totalBytesUploaded = 0;
            }

            bytesToUpload = fileSize - totalBytesUploaded;
            start = totalBytesUploaded;
            LOGGER.info("Next byte to upload is {}.", start);
        }
        if (response.getHeaders().containsKey("location")) {
            upload.setUploadurl(response.getHeaders().get("location"));
            uploadService.update(upload);
        }
    }

    private void handleSuccessfulUpload(final String body) throws UploadFinishedException {
        upload.setVideoid(parseVideoId(body));
        upload.getStatus().setArchived(true);
        uploadService.update(upload);
        throw new UploadFinishedException();
    }

    String parseVideoId(final String atomData) {
        LOGGER.info(atomData);
        final Pattern pattern = Pattern.compile("<yt:videoid>(.*)</yt:videoid>");
        final Matcher matcher = pattern.matcher(atomData);

        if (matcher.find()) {
            return matcher.group(1);
        } else {
            return "missed";
        }
    }

    private long generateEndBytes(final long start, final double bytesToUpload) {
        final long end;
        if (0 < bytesToUpload - chunkSize) {
            end = start + chunkSize - 1;
        } else {
            end = start + (int) bytesToUpload - 1;
        }
        return end;
    }

    private void flowChunk(final InputStream inputStream, final OutputStream outputStream, final long startByte,
            final long endByte) throws IOException {

        // Write Chunk
        final byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
        long totalRead = 0;

        while (!Thread.currentThread().isInterrupted() && totalRead != endByte - startByte + 1) {
            // Upload bytes in buffer
            final int bytesRead = flowChunk(inputStream, outputStream, buffer, 0, DEFAULT_BUFFER_SIZE);
            // Calculate all uploadinformation
            totalRead += bytesRead;
        }
    }

    int flowChunk(final InputStream is, final OutputStream os, final byte[] buf, final int off, final int len)
            throws IOException {
        final int numRead;
        if (0 <= (numRead = is.read(buf, off, len))) {
            os.write(buf, 0, numRead);
        }
        os.flush();
        return numRead;
    }

    private class TokenInputStream extends BufferedInputStream {

        public TokenInputStream(final InputStream inputStream) {
            super(inputStream, DEFAULT_BUFFER_SIZE);
        }

        @Override
        public synchronized int read(final byte[] b, final int off, final int len) throws IOException {
            if (0 < rateLimiter.getRate()) {
                rateLimiter.acquire(b.length);
            }

            if (Thread.currentThread().isInterrupted()) {
                LOGGER.error("Upload aborted / stopped.");
                upload.getStatus().setAborted(true);
                throw new CancellationException("Thread cancled");
            }

            final int bytes = super.read(b, off, len);

            // Event Upload Progress
            // Calculate all uploadinformation
            totalBytesUploaded += b.length;
            final long diffTime = Calendar.getInstance().getTimeInMillis() - uploadProgress.getTime();
            if (1000 < diffTime) {
                uploadProgress.setBytes(totalBytesUploaded);
                uploadProgress.setTime(diffTime);
                eventBus.post(uploadProgress);
            }

            return bytes;
        }
    }

    private static class UploadResponseException extends Exception {

        private static final long serialVersionUID = 9064482080311824304L;
        private final int status;

        public UploadResponseException(final int status) {
            super(String.format("Upload response exception: %d", status));
            this.status = status;
        }

        private int getStatus() {
            return status;
        }
    }

    private static class UploadFinishedException extends Exception {

        private static final long serialVersionUID = -5907578118391546810L;

        public UploadFinishedException() {
            super("Upload finished!");
        }
    }
}