diff --git a/src/codecs.js b/src/codecs.js
new file mode 100644
index 0000000..0fbdcca
--- /dev/null
+++ b/src/codecs.js
@@ -0,0 +1,130 @@
+/**
+ * Replace the old apple-style `avc1.
.` codec string with the standard
+ * `avc1.`
+ *
+ * @param {string} codec
+ * Codec string to translate
+ * @return {string}
+ * The translated codec string
+ */
+export const translateLegacyCodec = function(codec) {
+ if (!codec) {
+ return codec;
+ }
+
+ return codec.replace(/avc1\.(\d+)\.(\d+)/i, function(orig, profile, avcLevel) {
+ const profileHex = ('00' + Number(profile).toString(16)).slice(-2);
+ const avcLevelHex = ('00' + Number(avcLevel).toString(16)).slice(-2);
+
+ return 'avc1.' + profileHex + '00' + avcLevelHex;
+ });
+};
+
+/**
+ * Replace the old apple-style `avc1..` codec strings with the standard
+ * `avc1.`
+ *
+ * @param {string[]} codecs
+ * An array of codec strings to translate
+ * @return {string[]}
+ * The translated array of codec strings
+ */
+export const translateLegacyCodecs = function(codecs) {
+ return codecs.map(translateLegacyCodec);
+};
+
+/**
+ * Replace codecs in the codec string with the old apple-style `avc1..` to the
+ * standard `avc1.`.
+ *
+ * @param {string} codecString
+ * The codec string
+ * @return {string}
+ * The codec string with old apple-style codecs replaced
+ *
+ * @private
+ */
+export const mapLegacyAvcCodecs = function(codecString) {
+ return codecString.replace(/avc1\.(\d+)\.(\d+)/i, (match) => {
+ return translateLegacyCodecs([match])[0];
+ });
+};
+
+/**
+ * @typedef {Object} ParsedCodecInfo
+ * @property {number} codecCount
+ * Number of codecs parsed
+ * @property {string} [videoCodec]
+ * Parsed video codec (if found)
+ * @property {string} [videoObjectTypeIndicator]
+ * Video object type indicator (if found)
+ * @property {string|null} audioProfile
+ * Audio profile
+ */
+
+/**
+ * Parses a codec string to retrieve the number of codecs specified, the video codec and
+ * object type indicator, and the audio profile.
+ *
+ * @param {string} [codecs]
+ * The codec string to parse
+ * @return {ParsedCodecInfo}
+ * Parsed codec info
+ */
+export const parseCodecs = function(codecs = '') {
+ const result = {
+ codecCount: 0
+ };
+
+ result.codecCount = codecs.split(',').length;
+ result.codecCount = result.codecCount || 2;
+
+ // parse the video codec
+ const parsed = (/(^|\s|,)+(avc[13])([^ ,]*)/i).exec(codecs);
+
+ if (parsed) {
+ result.videoCodec = parsed[2];
+ result.videoObjectTypeIndicator = parsed[3];
+ }
+
+ // parse the last field of the audio codec
+ result.audioProfile =
+ (/(^|\s|,)+mp4a.[0-9A-Fa-f]+\.([0-9A-Fa-f]+)/i).exec(codecs);
+ result.audioProfile = result.audioProfile && result.audioProfile[2];
+
+ return result;
+};
+
+/**
+ * Returns a ParsedCodecInfo object for the default alternate audio playlist if there is
+ * a default alternate audio playlist for the provided audio group.
+ *
+ * @param {Object} master
+ * The master playlist
+ * @param {string} audioGroupId
+ * ID of the audio group for which to find the default codec info
+ * @return {ParsedCodecInfo}
+ * Parsed codec info
+ */
+export const audioProfileFromDefault = (master, audioGroupId) => {
+ if (!master.mediaGroups.AUDIO || !audioGroupId) {
+ return null;
+ }
+
+ const audioGroup = master.mediaGroups.AUDIO[audioGroupId];
+
+ if (!audioGroup) {
+ return null;
+ }
+
+ for (const name in audioGroup) {
+ const audioType = audioGroup[name];
+
+ if (audioType.default && audioType.playlists) {
+ // codec should be the same for all playlists within the audio type
+ return parseCodecs(audioType.playlists[0].attributes.CODECS).audioProfile;
+ }
+ }
+
+ return null;
+};
diff --git a/test/codecs.test.js b/test/codecs.test.js
new file mode 100644
index 0000000..b651332
--- /dev/null
+++ b/test/codecs.test.js
@@ -0,0 +1,242 @@
+import QUnit from 'qunit';
+import {
+ mapLegacyAvcCodecs,
+ translateLegacyCodecs,
+ parseCodecs,
+ audioProfileFromDefault
+} from '../src/codecs';
+
+QUnit.module('Legacy Codecs');
+
+QUnit.test('maps legacy AVC codecs', function(assert) {
+ assert.equal(
+ mapLegacyAvcCodecs('avc1.deadbeef'),
+ 'avc1.deadbeef',
+ 'does nothing for non legacy pattern'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('avc1.dead.beef, mp4a.something'),
+ 'avc1.dead.beef, mp4a.something',
+ 'does nothing for non legacy pattern'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('avc1.dead.beef,mp4a.something'),
+ 'avc1.dead.beef,mp4a.something',
+ 'does nothing for non legacy pattern'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('mp4a.something,avc1.dead.beef'),
+ 'mp4a.something,avc1.dead.beef',
+ 'does nothing for non legacy pattern'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('mp4a.something, avc1.dead.beef'),
+ 'mp4a.something, avc1.dead.beef',
+ 'does nothing for non legacy pattern'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('avc1.42001e'),
+ 'avc1.42001e',
+ 'does nothing for non legacy pattern'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('avc1.4d0020,mp4a.40.2'),
+ 'avc1.4d0020,mp4a.40.2',
+ 'does nothing for non legacy pattern'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('mp4a.40.2,avc1.4d0020'),
+ 'mp4a.40.2,avc1.4d0020',
+ 'does nothing for non legacy pattern'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('mp4a.40.40'),
+ 'mp4a.40.40',
+ 'does nothing for non video codecs'
+ );
+
+ assert.equal(
+ mapLegacyAvcCodecs('avc1.66.30'),
+ 'avc1.42001e',
+ 'translates legacy video codec alone'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('avc1.66.30, mp4a.40.2'),
+ 'avc1.42001e, mp4a.40.2',
+ 'translates legacy video codec when paired with audio'
+ );
+ assert.equal(
+ mapLegacyAvcCodecs('mp4a.40.2, avc1.66.30'),
+ 'mp4a.40.2, avc1.42001e',
+ 'translates video codec when specified second'
+ );
+});
+
+QUnit.test('translates legacy codecs', function(assert) {
+ assert.deepEqual(
+ translateLegacyCodecs(['avc1.66.30', 'avc1.66.30']),
+ ['avc1.42001e', 'avc1.42001e'],
+ 'translates legacy avc1.66.30 codec'
+ );
+
+ assert.deepEqual(
+ translateLegacyCodecs(['avc1.42C01E', 'avc1.42C01E']),
+ ['avc1.42C01E', 'avc1.42C01E'],
+ 'does not translate modern codecs'
+ );
+
+ assert.deepEqual(
+ translateLegacyCodecs(['avc1.42C01E', 'avc1.66.30']),
+ ['avc1.42C01E', 'avc1.42001e'],
+ 'only translates legacy codecs when mixed'
+ );
+
+ assert.deepEqual(
+ translateLegacyCodecs(['avc1.4d0020', 'avc1.100.41', 'avc1.77.41',
+ 'avc1.77.32', 'avc1.77.31', 'avc1.77.30',
+ 'avc1.66.30', 'avc1.66.21', 'avc1.42C01e']),
+ ['avc1.4d0020', 'avc1.640029', 'avc1.4d0029',
+ 'avc1.4d0020', 'avc1.4d001f', 'avc1.4d001e',
+ 'avc1.42001e', 'avc1.420015', 'avc1.42C01e'],
+ 'translates a whole bunch'
+ );
+});
+
+QUnit.module('parseCodecs');
+
+QUnit.test('parses video only codec string', function(assert) {
+ assert.deepEqual(
+ parseCodecs('avc1.42001e'),
+ {
+ codecCount: 1,
+ videoCodec: 'avc1',
+ videoObjectTypeIndicator: '.42001e',
+ audioProfile: null
+ },
+ 'parsed video only codec string'
+ );
+});
+
+QUnit.test('parses audio only codec string', function(assert) {
+ assert.deepEqual(
+ parseCodecs('mp4a.40.2'),
+ {
+ codecCount: 1,
+ audioProfile: '2'
+ },
+ 'parsed audio only codec string'
+ );
+});
+
+QUnit.test('parses video and audio codec string', function(assert) {
+ assert.deepEqual(
+ parseCodecs('avc1.42001e, mp4a.40.2'),
+ {
+ codecCount: 2,
+ videoCodec: 'avc1',
+ videoObjectTypeIndicator: '.42001e',
+ audioProfile: '2'
+ },
+ 'parsed video and audio codec string'
+ );
+});
+
+QUnit.module('audioProfileFromDefault');
+
+QUnit.test('returns falsey when no audio group ID', function(assert) {
+ assert.notOk(
+ audioProfileFromDefault(
+ { mediaGroups: { AUDIO: {} } },
+ '',
+ ),
+ 'returns falsey when no audio group ID'
+ );
+});
+
+QUnit.test('returns falsey when no matching audio group', function(assert) {
+ assert.notOk(
+ audioProfileFromDefault(
+ {
+ mediaGroups: {
+ AUDIO: {
+ au1: {
+ en: {
+ default: false,
+ playlists: [{
+ attributes: { CODECS: 'mp4a.40.2' }
+ }]
+ },
+ es: {
+ default: true,
+ playlists: [{
+ attributes: { CODECS: 'mp4a.40.5' }
+ }]
+ }
+ }
+ }
+ }
+ },
+ 'au2'
+ ),
+ 'returned falsey when no matching audio group'
+ );
+});
+
+QUnit.test('returns falsey when no default for audio group', function(assert) {
+ assert.notOk(
+ audioProfileFromDefault(
+ {
+ mediaGroups: {
+ AUDIO: {
+ au1: {
+ en: {
+ default: false,
+ playlists: [{
+ attributes: { CODECS: 'mp4a.40.2' }
+ }]
+ },
+ es: {
+ default: false,
+ playlists: [{
+ attributes: { CODECS: 'mp4a.40.5' }
+ }]
+ }
+ }
+ }
+ }
+ },
+ 'au1'
+ ),
+ 'returned falsey when no default for audio group'
+ );
+});
+
+QUnit.test('returns audio profile for default in audio group', function(assert) {
+ assert.deepEqual(
+ audioProfileFromDefault(
+ {
+ mediaGroups: {
+ AUDIO: {
+ au1: {
+ en: {
+ default: false,
+ playlists: [{
+ attributes: { CODECS: 'mp4a.40.2' }
+ }]
+ },
+ es: {
+ default: true,
+ playlists: [{
+ attributes: { CODECS: 'mp4a.40.5' }
+ }]
+ }
+ }
+ }
+ }
+ },
+ 'au1'
+ ),
+ '5',
+ 'returned parsed codec audio profile'
+ );
+});