Skip to content

Commit

Permalink
Merge pull request #216 from nulib/4735-audio-download
Browse files Browse the repository at this point in the history
Implement audio download
  • Loading branch information
kdid committed Jun 17, 2024
2 parents d7637c7 + 8f1ca20 commit 45b1624
Show file tree
Hide file tree
Showing 8 changed files with 9,613 additions and 1,254 deletions.
10,709 changes: 9,475 additions & 1,234 deletions lambdas/package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion lambdas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"@aws-sdk/client-mediaconvert": "^3.410.0",
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4": "^3.130.0"
"@aws-sdk/signature-v4": "^3.130.0",
"aws-sdk": "^2.1640.0"
},
"dependencies": {
"fluent-ffmpeg": "2.1.2"
}
}
41 changes: 41 additions & 0 deletions lambdas/start-audio-transcode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const AWS = require('aws-sdk');
const ffmpeg = require('fluent-ffmpeg');
const stream = require('stream');

module.exports.handler = async (event) => {

const s3 = new AWS.S3();
const url = event.streamingUrl;
const referer = event.referer || null;
const inputOptions = referer ? ["-referer", referer] : [];
const bucket = event.destinationBucket;
const key = event.destinationKey;
const pass = new stream.PassThrough();

return new Promise((resolve, reject) => {
ffmpeg()
.input(url)
.inputOptions(inputOptions)
.format('mp3')
.output(pass, { end: true })
.on('start', () => {
s3.upload({
Bucket: bucket,
Key: key,
Body: pass,
}, (error, _data) => {
if (error) {
console.error('upload failed', error);
reject(error);
} else {
resolve({ success: true });
}
});
})
.on('error', (error) => {
console.error('ffmpeg error', error);
reject(error);
})
.run();
});
}
36 changes: 25 additions & 11 deletions node/src/handlers/get-file-set-download.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const path = require("path");
exports.handler = wrap(async (event) => {
const id = event.pathParameters.id;
const email = event.queryStringParameters?.email;
const referer = event.headers?.referer;

const allowPrivate =
event.userToken.isSuperUser() ||
Expand All @@ -32,14 +33,14 @@ exports.handler = wrap(async (event) => {

if (esResponse.statusCode == "200") {
const doc = JSON.parse(esResponse.body);
if (isVideoDownload(doc)) {
if (isAVDownload(doc)) {
if (!email) {
return invalidRequest(400, "Query string must include email address");
}
if (!event.userToken.isSuperUser()) {
return invalidRequest(401, "Unauthorized");
}
return await processAVDownload(doc, email);
return await processAVDownload(doc, email, referer);
} else if (isImageDownload(doc)) {
return await IIIFImageRequest(doc);
} else if (isAltFileDownload(doc)) {
Expand Down Expand Up @@ -70,14 +71,12 @@ function isAltFileDownload(doc) {
);
}

function isVideoDownload(doc) {
// Note - audio is not currently implemented due to an issue with AWS
// & MediaConvert and our .m3u8 files
function isAVDownload(doc) {
return (
doc.found &&
doc._source.role === "Access" &&
doc._source.mime_type != null &&
["video"].includes(doc._source.mime_type.split("/")[0]) &&
["audio", "video"].includes(doc._source.mime_type.split("/")[0]) &&
doc._source.streaming_url != null
);
}
Expand All @@ -91,6 +90,10 @@ function isImageDownload(doc) {
);
}

function isAudio(doc) {
return ["audio"].includes(doc._source.mime_type.split("/")[0]);
}

function derivativeKey(doc) {
const id = doc._id;
let prefix =
Expand Down Expand Up @@ -169,7 +172,7 @@ const IIIFImageRequest = async (doc) => {
};
};

async function processAVDownload(doc, email) {
async function processAVDownload(doc, email, referer) {
const stepFunctionConfig = process.env.STEP_FUNCTION_ENDPOINT
? { endpoint: process.env.STEP_FUNCTION_ENDPOINT }
: {};
Expand All @@ -184,26 +187,37 @@ async function processAVDownload(doc, email) {
const fileSetLabel = fileSet.label;
const workId = fileSet.work_id;
const fileType = fileSet.mime_type.split("/")[0];
const destinationKey = `av-downloads/${fileSetId}.mp4`; //TODO - account for audio
const destinationLocation = `s3://${destinationBucket}/av-downloads/${fileSetId}`; // TODO - account for audio
const settings = videoTranscodeSettings(sourceLocation, destinationLocation); // TODO - account for audio
const destinationKey = isAudio(doc)
? `av-downloads/${fileSetId}.mp3`
: `av-downloads/${fileSetId}.mp4`;
const destinationLocation = `s3://${destinationBucket}/av-downloads/${fileSetId}`;
const settings = isAudio(doc)
? {}
: videoTranscodeSettings(sourceLocation, destinationLocation);
const filename = isAudio(doc) ? `${fileSetId}.mp3` : `${fileSetId}.mp4`;

var params = {
stateMachineArn: process.env.AV_DOWNLOAD_STATE_MACHINE_ARN,
input: JSON.stringify({
configuration: {
startAudioTranscodeFunction: process.env.START_AUDIO_TRANSCODE_FUNCTION,
startTranscodeFunction: process.env.START_TRANSCODE_FUNCTION,
transcodeStatusFunction: process.env.TRANSCODE_STATUS_FUNCTION,
getDownloadLinkFunction: process.env.GET_DOWNLOAD_LINK_FUNCTION,
sendTemplatedEmailFunction: process.env.SEND_TEMPLATED_EMAIL_FUNCTION,
},
transcodeInput: {
settings: settings,
type: fileType,
streamingUrl: fileSet.streaming_url,
referer: referer,
destinationBucket: destinationBucket,
destinationKey: destinationKey,
},
presignedUrlInput: {
bucket: destinationBucket,
key: destinationKey,
disposition: `${fileSetId}.mp4`,
disposition: filename,
},
sendEmailInput: {
to: email,
Expand Down
4 changes: 2 additions & 2 deletions node/src/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 18 additions & 5 deletions node/test/integration/get-file-set-download.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,43 @@ describe("Download file set", () => {
expect(result.statusCode).to.eq(401);
});

it("returns an error if the mime-type is audio/*", async () => {
it("returns unauthorized for an audio without a superuser token", async () => {
mock
.get("/dc-v2-file-set/_doc/1234")
.reply(200, helpers.testFixture("mocks/fileset-audio-1234.json"));

const event = helpers
.mockEvent("GET", "/file-sets/{id}/download")
.pathParams({ id: 1234 })
.queryParams({ email: "example@example.com" })
.render();
const result = await handler(event);
expect(result.statusCode).to.eq(401);
});

it("returns an error for video if it does not contain an email query string parameters", async () => {
mock
.get("/dc-v2-file-set/_doc/1234")
.reply(200, helpers.testFixture("mocks/fileset-video-1234.json"));

const token = new ApiToken().superUser().sign();

const event = helpers
.mockEvent("GET", "/file-sets/{id}/download")
.pathParams({ id: 1234 })
.queryParams({ email: "example@example.com" })
.headers({
Cookie: `${process.env.API_TOKEN_NAME}=${token};`,
})
.render();

const result = await handler(event);
expect(result.statusCode).to.eq(405);
expect(result.statusCode).to.eq(400);
});

it("returns an error if it does not contain an email query string parameters", async () => {
it("returns an error for audio if it does not contain an email query string parameters", async () => {
mock
.get("/dc-v2-file-set/_doc/1234")
.reply(200, helpers.testFixture("mocks/fileset-video-1234.json"));
.reply(200, helpers.testFixture("mocks/fileset-audio-1234.json"));

const token = new ApiToken().superUser().sign();

Expand Down
24 changes: 23 additions & 1 deletion state_machines/av_download.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
{
"Comment": "HLS stiching and save as file in s3 and email download link",
"StartAt": "startTranscode",
"StartAt": "audioOrVideo",
"States": {
"audioOrVideo": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.transcodeInput.type",
"StringEquals": "audio",
"Next": "startAudioTranscode"
}
],
"Default": "startTranscode"
},
"startAudioTranscode": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"Payload.$": "$.transcodeInput",
"FunctionName.$": "$.configuration.startAudioTranscodeFunction"
},
"Next": "getDownloadLink",
"InputPath": "$",
"ResultPath": "$.audioTranscodeOutput"
},
"startTranscode": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
Expand Down
24 changes: 24 additions & 0 deletions template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ Resources:
MEDIA_CONVERT_ROLE_ARN: !Ref MediaConvertRoleArn
PYRAMID_BUCKET: !Ref PyramidBucket
REPOSITORY_EMAIL: !Ref RepositoryEmail
START_AUDIO_TRANSCODE_FUNCTION: !GetAtt startAudioTranscodeFunction.Arn
START_TRANSCODE_FUNCTION: !GetAtt startTranscodeFunction.Arn
TRANSCODE_STATUS_FUNCTION: !GetAtt transcodeStatusFunction.Arn
GET_DOWNLOAD_LINK_FUNCTION: !GetAtt getDownloadLinkFunction.Arn
Expand Down Expand Up @@ -721,6 +722,7 @@ Resources:
Action:
- lambda:InvokeFunction
Resource:
- !GetAtt startAudioTranscodeFunction.Arn
- !GetAtt startTranscodeFunction.Arn
- !GetAtt transcodeStatusFunction.Arn
- !GetAtt getDownloadLinkFunction.Arn
Expand Down Expand Up @@ -920,6 +922,28 @@ Resources:
<!--[if gte mso 15]></td></tr></table><![endif]-->
</body>
</html>
startAudioTranscodeFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: nodejs16.x
CodeUri: ./lambdas
Handler: start-audio-transcode.handler
Description: Performs audio transcode job with ffmpeg
Timeout: 900
MemorySize: 10240
Layers:
- !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:layer:ffmpeg:9"
Policies:
Version: 2012-10-17
Statement:
- Sid: BucketAccess
Effect: Allow
Action:
- s3:PutObject
Resource: !Sub "arn:aws:s3:::${MediaConvertDestinationBucket}/*"
Environment:
Variables:
MEDIA_CONVERT_DESTINATION_BUCKET: !Ref MediaConvertDestinationBucket
startTranscodeFunction:
Type: AWS::Serverless::Function
Properties:
Expand Down

0 comments on commit 45b1624

Please sign in to comment.