diff --git a/doc/intro.rst b/doc/intro.rst
index 9013aa7..2ed3ce1 100644
--- a/doc/intro.rst
+++ b/doc/intro.rst
@@ -33,6 +33,7 @@ installed via the ``pip`` command:
 
     $ git clone $REPO sms2jwplayer
     $ cd sms2jwplayer
+    $ pip install -r requirements.txt  # for specific package versions
     $ pip install .
 
 Where ``$REPO`` is replaced with the location of the ``sms2jwplayer``
diff --git a/doc/reference.rst b/doc/reference.rst
index e9391a4..8f0a1e9 100644
--- a/doc/reference.rst
+++ b/doc/reference.rst
@@ -45,3 +45,9 @@ Extracting video view stats
 
 .. automodule:: sms2jwplayer.analytics
     :members:
+
+Tidy database
+-------------
+
+.. automodule:: sms2jwplayer.tidy
+    :members:
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..33f16be
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+# Special version of jwplatform with bug fixes
+git+https://github.com/uisautomation/third-party-jwplatform-py#egg=jwplatform
diff --git a/sms2jwplayer/__init__.py b/sms2jwplayer/__init__.py
index 30bc537..18564b4 100644
--- a/sms2jwplayer/__init__.py
+++ b/sms2jwplayer/__init__.py
@@ -7,9 +7,10 @@ Usage:
         [--output=FILE] [--limit=NUMBER] [--offset=NUMBER] <csv>
     sms2jwplayer fetch [--verbose] [--base-name=NAME]
     sms2jwplayer genupdatejob [--verbose] [--strip-leading=N]
-        [--output=FILE] <csv> <metadata>...
-    sms2jwplayer applyupdatejob [--verbose] [<update>]
+        [--output=FILE] --base=URL --base-image-url=URL <csv> <metadata>...
+    sms2jwplayer applyupdatejob [--verbose] [--log-file=FILE] [<update>]
     sms2jwplayer analytics [--output=FILE] [--verbose] <date>
+    sms2jwplayer tidy [--output=FILE] [--verbose] <metadata>...
 
 Options:
     -h, --help          Show a brief usage summary.
@@ -42,7 +43,9 @@ Sub commands:
     genmrss             Generate an MRSS feed for the export.
     fetch               Fetch details on all videos in jwplayer.
     genupdatejob        Generate list of missing metadata for each video key.
+    applyupdatejob      Use JWPlatform API to update videos based on a job description file.
     analytics           Generate SMS analytics for a given day.
+    tidy                Generate an update job which tidies the jwplayer database.
 
 """
 import logging
@@ -67,3 +70,6 @@ def main():
     elif opts['analytics']:
         from . import analytics
         analytics.main(opts)
+    elif opts['tidy']:
+        from . import tidy
+        tidy.main(opts)
diff --git a/sms2jwplayer/applyupdatejob.py b/sms2jwplayer/applyupdatejob.py
index 294d889..bcd4403 100644
--- a/sms2jwplayer/applyupdatejob.py
+++ b/sms2jwplayer/applyupdatejob.py
@@ -4,19 +4,44 @@ jwplayer API are performed with exponential backoff.
 
 Takes as input a JSON document with the following schema.
 
-.. code:: json
+.. code:: js
 
     {
-        "updates": [
-            ...
-            {
-                "key": <jwplayer key>,
-                "custom": {
-                    <dictionary of custom properties to set>
-                }
-            },
-            ...
-        ],
+        "create": List<Create>,
+        "update": List<Update>
+    }
+
+The Create object specifies a list of JWPlatform resources which should be created:
+
+.. code:: js
+
+    {
+        "type": "videos",  // or "thumbnails", etc
+        "resource: {
+            // dictionary of resource properties
+        }
+    }
+
+The Update object specifies a list of JWPlatform resources which need to be updated:
+
+.. code:: js
+
+    {
+        "type": "videos",  // or "thumbnails", etc
+        "resource": {
+            // dictionary of properties to update
+        }
+    }
+
+The Delete object specifies a list of JWPlatform resources which need to be deleted:
+
+.. code:: js
+
+    {
+        "type": "videos",  // or "thumbnails", etc
+        "resource": {
+            // dictionary of parameters to delete request
+        }
     }
 
 """
@@ -25,11 +50,10 @@ import logging
 import sys
 import time
 
-import dateutil.parser
 import tqdm
 from jwplatform.errors import JWPlatformRateLimitExceededError
 
-from .util import input_stream, get_jwplatform_client, JWPlatformClientError
+from . import util
 
 
 LOG = logging.getLogger('applyupdatejob')
@@ -40,46 +64,136 @@ MAX_ATTEMPTS = 10
 
 def main(opts):
     try:
-        client = get_jwplatform_client()
-    except JWPlatformClientError as e:
+        client = util.get_jwplatform_client()
+    except util.JWPlatformClientError as e:
         LOG.error('jwplatform error: %s', e)
         sys.exit(1)
 
-    with input_stream(opts, '<update>') as f:
-        updates = json.load(f).get('updates', [])
+    with util.input_stream(opts, '<update>') as f:
+        jobs = json.load(f)
 
-    LOG.info('Update jobs to process: %s', len(updates))
+    updates, creates, deletes = [jobs.get(k, []) for k in ['update', 'create', 'delete']]
+
+    LOG.info('Number of update jobs to process: %s', len(updates))
+    LOG.info('Number of create jobs to process: %s', len(creates))
+    LOG.info('Number of delete jobs to process: %s', len(deletes))
 
     # If verbose flag is present, give a nice progress bar
     if opts['--verbose'] is not None:
-        update_iterable = tqdm.tqdm(updates)
-    else:
-        update_iterable = updates
-
+        updates = tqdm.tqdm(updates)
+        creates = tqdm.tqdm(creates)
+        deletes = tqdm.tqdm(deletes)
+
+    create_responses = list(
+        execute_api_calls_respecting_rate_limit(create_calls(client, creates))
+    )
+
+    update_responses = list(
+        execute_api_calls_respecting_rate_limit(update_calls(client, updates))
+    )
+
+    delete_responses = list(
+        execute_api_calls_respecting_rate_limit(delete_calls(client, deletes))
+    )
+
+    if opts['--log-file'] is not None:
+        with util.output_stream(opts, '--log-file') as f:
+            json.dump({
+                'create_responses': create_responses,
+                'update_responses': update_responses,
+                'delete_responses': delete_responses,
+            }, f)
+
+
+def create_calls(client, updates):
+    """
+    Return an iterator of callables representing the API calls for each create job.
+    """
+    for update in updates:
+        type_, resource = update.get('type'), update.get('resource', {})
+
+        if type_ == 'videos':
+            params = resource_to_params(resource)
+
+            # We wrap the entire create/update process in a function since we make use of two API
+            # calls (one is via key_for_media_id). Hence we want to re-try the entire thing if we
+            # hit the API rate limit.
+            def do_create():
+                # If video_key is set to anything other than None, an update of that video key will
+                # be done instead.
+                video_key = None
+
+                # See if the resource already exists. If so, perform an update instead.
+                media_id_prop = params.get('custom.sms_media_id')
+                if media_id_prop is not None:
+                    try:
+                        media_id = int(util.parse_custom_prop('media', media_id_prop))
+                    except ValueError:
+                        LOG.warning('Skipping video with bad media id prop: %s', media_id_prop)
+                    else:
+                        # Attempt to find a matching video for this media id. If None found, that's
+                        # OK.
+                        try:
+                            video_key = util.key_for_media_id(media_id)
+                        except util.VideoNotFoundError:
+                            pass
+
+                if video_key is not None:
+                    LOG.warning('Updating video %(video_key)s instead of creating new one',
+                                {'video_key': video_key})
+                    return client.videos.update(
+                        http_method='POST', video_key=video_key, **params)
+                else:
+                    return client.videos.create(http_method='POST', **params)
+
+            yield do_create
+        else:
+            LOG.warning('Skipping unknown update type: %s', type_)
+
+
+def update_calls(client, updates):
+    """
+    Return an iterator of callables representing the API calls for each update job.
+    """
+    for update in updates:
+        type_, resource = update.get('type'), update.get('resource', {})
+
+        if type_ == 'videos':
+            yield lambda: client.videos.update(http_method='POST', **resource_to_params(resource))
+        else:
+            LOG.warning('Skipping unknown update type: %s', type_)
+
+
+def delete_calls(client, deletes):
+    """
+    Return an iterator of callables representing the API calls for each delete job.
+    """
+    for delete in deletes:
+        type_, resource = delete.get('type'), delete.get('resource', {})
+
+        if type_ == 'videos':
+            yield lambda: client.videos.delete(http_method='POST', **resource_to_params(resource))
+        else:
+            LOG.warning('Skipping unknown delete type: %s', type_)
+
+
+def execute_api_calls_respecting_rate_limit(call_iterable):
+    """
+    A generator which takes an iterable of callables which represent calls to the JWPlatform API
+    and run them one after another. If a JWPlatformRateLimitExceededError is raised by the
+    callable, exponentially back off and retry. Since retries are possible, callables from
+    call_iterable may be called multiple times.
+
+    Yields the results of calling the update job.
+
+    """
     # delay between calls to not hit rate limit
     delay = 0.1  # seconds
 
-    for update in update_iterable:
-        try:
-            key = update['key']
-        except KeyError:
-            LOG.warning('Update lacks key: %s', repr(update))
-
-        params = {
-            'video_key': key
-        }
-
-        created_at = update.get('custom', {}).get('sms_created_at')
-        if created_at is not None:
-            date_str = ':'.join(created_at.split(':')[1:-1])
-            params['date'] = int(dateutil.parser.parse(date_str).timestamp())
-
-        for custom_key, custom_value in update.get('custom', {}).items():
-            params['custom.' + custom_key] = str(custom_value)
-
+    for api_call in call_iterable:
         for _ in range(MAX_ATTEMPTS):
             try:
-                client.videos.update(**params)
+                yield api_call()
 
                 # On a successful call, slightly shorten the delay
                 delay = max(1e-2, min(2., delay * 0.8))
@@ -87,3 +201,14 @@ def main(opts):
                 break
             except JWPlatformRateLimitExceededError:
                 delay = max(1e-2, min(2., 2. * delay))
+
+
+def resource_to_params(resource):
+    def iterate(d, prefix=''):
+        for k, v in d.items():
+            if isinstance(v, dict):
+                for p in iterate(v, prefix+k+'.'):
+                    yield p
+            else:
+                yield (prefix+k, v)
+    return dict(iterate(resource))
diff --git a/sms2jwplayer/genupdatejob.py b/sms2jwplayer/genupdatejob.py
index 0608a31..dad7593 100644
--- a/sms2jwplayer/genupdatejob.py
+++ b/sms2jwplayer/genupdatejob.py
@@ -1,35 +1,21 @@
 """
 The genupdatejob subcommand examines video metadata from jwplayer and the current SMS export and
-generates a list of updates which should be applied.
-
-It outputs a single JSON object with the following schema:
-
-.. code:: json
-
-    {
-        "updates": [
-            ...
-            {
-                "key": <jwplayer key>,
-                "custom": {
-                    <dictionary of custom properties to set>
-                }
-            },
-            ...
-        ],
-    }
+generates a list of updates which should be applied. See the documentation for
+:py:mod:`.applyupdatejob` for a description of the update job format.
 
 """
 import json
 import logging
 import re
-from urllib.parse import urlsplit
+import urllib.parse
+import dateutil.parser
+
 from sms2jwplayer.institutions import INSTIDS
 from . import csv as smscsv
-from .util import output_stream, get_key_path
+from .util import output_stream, get_key_path, parse_custom_prop
 
 
-LOG = logging.getLogger('genmrss')
+LOG = logging.getLogger(__name__)
 
 
 def main(opts):
@@ -50,10 +36,10 @@ def main(opts):
     LOG.info('Loaded %s media item(s) from export', len(items))
 
     with output_stream(opts) as fobj:
-        process_videos(fobj, items, videos)
+        process_videos(opts, fobj, items, videos)
 
 
-def process_videos(fobj, items, videos):
+def process_videos(opts, fobj, items, videos):
     """
     Process video metadata records with reference to a dictionary of media items keyed by the
     stripped path. Write results to file as JSON document.
@@ -61,86 +47,218 @@ def process_videos(fobj, items, videos):
     """
     # Statistics we record
     n_skipped = 0
-    n_unmatched = 0
-    n_matched = 0
 
-    updates = []
+    # The list of create, update and delete jobs which need to be performed.
+    creates, updates = [], []
+
+    # A set of item media_ids which could not be matched to a corresponding JWPlatform video. This
+    # starts full of all items but items are removed as matching happens.
+    new_media_ids = set([item.media_id for item in items.values()])
+
+    # A set of clip ids which already exist in JWPlatform
+    existing_clip_ids = set()
+
+    # A list of JWPlatform video resources which could not be matched to an SMS media object and
+    # hence should be deleted.
+    unmatched_videos = []
+
+    # A list of (item, video resource) tuples representing that a given SMS media item is
+    # represented by a JWPlatform video resource. This may be a one-to-many mapping; a single SMS
+    # media item may have more than one JWPlatform video resource associated with it.
+    associations = []
+
+    # A dictionary which allows retrieving media items by clip id. Stores an (item, path) tuple.
+    items_by_clip_id = dict((item.clip_id, (item, path)) for path, item in items.items())
+
+    # A dictionary, keyed by media id, of sequences of items associated with that media
+    items_by_media_id = {}
+    for _, item in items.items():
+        media_items = items_by_media_id.get(item.media_id, list())
+        media_items.append(item)
+        items_by_media_id[item.media_id] = media_items
 
     # Match jwplayer videos to SMS items
     for video in videos:
-        # Find original fetch URL
-        orig_url = get_key_path(video, 'custom.import_guid')
-        if orig_url is None:
+        # Find an existing SMS clip id
+        clip_id_prop = get_key_path(video, 'custom.sms_clip_id')
+        if clip_id_prop is None:
             n_skipped += 1
             continue
 
-        # Parse path components
-        path_components = urlsplit(orig_url).path.split('/')
+        # Retrieve the matching SMS media item (or record the inability to do so)
+        try:
+            item, _ = items_by_clip_id[int(parse_custom_prop('clip', clip_id_prop))]
+        except KeyError:
+            unmatched_videos.append(video)
+            continue
+
+        # Remove matched item from new_items set
+        new_media_ids -= {item.media_id}
+        existing_clip_ids.add(item.clip_id)
+
+        # We now have a match between a video and SMS media item. Record the match.
+        associations.append((item, video))
+
+    # Generate updates for existing videos
+    for item, video in associations:
+        expected_video = video_resource(opts, item)
+
+        # Calculate delta from resource which exists to expected resource
+        delta = updated_keys(video, expected_video)
+        if len(delta) > 0:
+            # The delta is non-empty, so construct an update request. FSR, the *update* request for
+            # JWPlatform requires the video be specified via 'video_key' but said key appears in
+            # the video resource returned by /videos/list as 'key'.
+            update = {'video_key': video['key']}
+            update.update(delta)
+            updates.append({
+                'type': 'videos',
+                'resource': update,
+            })
+
+    # Generate creates for new videos
+    create_clip_ids = set()
+    for media_items in (items_by_media_id[media_id] for media_id in new_media_ids):
+        video_item, audio_item = None, None
+        for item in media_items:
+            if item.format is smscsv.MediaFormat.VIDEO:
+                video_item = item
+            elif item.format is smscsv.MediaFormat.AUDIO:
+                audio_item = item
+            else:
+                LOG.warning('Unknown format: %s', item.format)
 
-        # Try to find item by joining path components
-        item = None
-        while len(path_components) > 0 and item is None:
-            item = items.get('/'.join(path_components))
-            path_components = path_components[1:]
+        # Prefer video items over audio ones
+        item = video_item if video_item is not None else audio_item
 
         if item is None:
-            n_unmatched += 1
+            LOG.warning('Could not match items %s to video or audio clip', [
+                i.clip_id for i in media_items
+            ])
             continue
 
-        # video and item now match
-        n_matched += 1
-
-        # form list of expected custom properties. We cuddle the id numbers in <type>:...: so that
-        # we can search for "exactly" the media id or clip id rather than simply a video whose id
-        # contains another. (E.g. searching for clip "10" is likely to being up "210", "310",
-        # "1045", etc.)
-        custom_props = {
-            'sms_media_id': 'media:{}:'.format(item.media_id),
-            'sms_clip_id': 'clip:{}:'.format(item.clip_id),
-            # format - migration not required
-            # filename - migration not required
-            'sms_created_at': 'created_at:{}:'.format(item.created_at.isoformat()),
-            # title - migrated as media item title
-            # description - migrated as media item description
-            'sms_collection_id': 'collection:{}:'.format(item.collection_id),
-            'sms_instid': 'instid:{}:'.format(item.instid),
-            'sms_aspect_ratio': 'aspect_ratio:{}:'.format(item.aspect_ratio),
-            'sms_created_by': 'created_by:{}:'.format(item.creator),
-            # in_dspace - migration not required
-            'sms_publisher': 'publisher:{}:'.format(item.publisher),
-            'sms_copyright': 'copyright:{}:'.format(item.copyright),
-            'sms_language': 'language:{}:'.format(item.language),
-            'sms_keywords': 'keywords:{}:'.format(item.keywords),
-            # visibility - migration merged with sms_acl
-            'sms_acl': 'acl:{}:'.format(convert_acl(item.visibility, item.acl)),
-            'sms_screencast': 'screencast:{}:'.format(item.screencast),
-            'sms_image_id': 'image_id:{}:'.format(item.image_id),
-            # dspace_path - migration not required
-            'sms_featured': 'featured:{}:'.format(item.featured),
-            'sms_branding': 'branding:{}:'.format(item.branding),
-            'sms_last_updated_at': 'last_updated_at:{}:'.format(item.last_updated_at),
-            'sms_updated_by': 'updated_by:{}:'.format(item.updated_by),
-            'sms_downloadable': 'downloadable:{}:'.format(item.downloadable),
-            'sms_withdrawn': 'withdrawn:{}:'.format(item.withdrawn),
-            # abstract - migration impractical
-            # priority - migration not required
-        }
-
-        # remove those which match
-        for k, v in list(custom_props.items()):
-            if get_key_path(video, 'custom.' + k) == v:
-                del custom_props[k]
-
-        # write a row if there is work to do
-        if len(custom_props) > 0:
-            updates.append({'key': get_key_path(video, 'key'), 'custom': custom_props})
-
-    LOG.info('Number of jwplayer videos matched to SMS media items: %s', n_matched)
-    LOG.info('Number of jwplayer videos not matched to SMS media items: %s', n_unmatched)
-    LOG.info('Number of jwplayer videos with no import URL: %s', n_skipped)
+        # If we've ended up with an existing clip, don't bother
+        if item.clip_id in existing_clip_ids or item.clip_id in create_clip_ids:
+            continue
+
+        create_clip_ids.add(item.clip_id)
+
+        video = video_resource(opts, item)
+        video.update({
+            'download_url': url(opts, item),
+        })
+        creates.append({
+            'type': 'videos',
+            'resource': video,
+        })
+
+    LOG.info('Number of JWPlatform videos matched to SMS media items: %s', len(associations))
+    LOG.info('Number of SMS media items with no existing video: %s', len(new_media_ids))
+    LOG.info('Number of JWPlatform videos not matched to SMS media items: %s',
+             len(unmatched_videos))
+    LOG.info('Number of JWPlatform videos with no import URL: %s', n_skipped)
+    LOG.info('Number of video creations: %s', len(creates))
     LOG.info('Number of video updates: %s', len(updates))
 
-    json.dump({'updates': updates}, fobj)
+    json.dump({'create': creates, 'update': updates}, fobj)
+
+
+def updated_keys(source, target):
+    """Return a dict which is the delta between source and target. Keys in target which have
+    different values or do not exist in source are returned.
+
+    """
+    # Initially, the delta is empty
+    delta = {}
+
+    for key, value in target.items():
+        try:
+            source_value = source[key]
+        except KeyError:
+            # Key is not in source, set it in delta
+            delta[key] = source_value
+        else:
+            # Key is in source
+            if isinstance(value, dict):
+                # Value is itself a dict so recurse
+                sub_delta = updated_keys(source_value, value)
+                if len(sub_delta) > 1:
+                    delta[key] = sub_delta
+            elif value != source_value:
+                # Value differs between source and target. Return delta.
+                delta[key] = value
+
+    return delta
+
+
+def video_resource(opts, item):
+    """
+    Construct what the JWPlatform video resource for a SMS media item should look like.
+
+    """
+    # Custom props
+    custom_props = custom_props_for_item(item)
+
+    # Start making the video resource
+    resource = {
+        "custom": custom_props,
+    }
+
+    # Add title and description if present
+    for key, value in (('title', item.title), ('description', item.description)):
+        value = sanitise(value)
+        if value.strip() != '':
+            resource[key] = value
+
+    # Add a created at date
+    created_at = custom_props.get('sms_created_at')
+    if created_at is not None:
+        date_str = ':'.join(created_at.split(':')[1:-1])
+        resource['date'] = int(dateutil.parser.parse(date_str).timestamp())
+
+    return resource
+
+
+def custom_props_for_item(item):
+    """
+    Return a dictionary of custom props which should be set on a particular video item.
+
+    """
+    # form list of expected custom properties. We cuddle the id numbers in <type>:...: so that
+    # we can search for "exactly" the media id or clip id rather than simply a video whose id
+    # contains another. (E.g. searching for clip "10" is likely to being up "210", "310",
+    # "1045", etc.)
+    return {
+        'sms_media_id': 'media:{}:'.format(item.media_id),
+        'sms_clip_id': 'clip:{}:'.format(item.clip_id),
+        # format - migration not required
+        # filename - migration not required
+        'sms_created_at': 'created_at:{}:'.format(item.created_at.isoformat()),
+        # title - migrated as media item title
+        # description - migrated as media item description
+        'sms_collection_id': 'collection:{}:'.format(item.collection_id),
+        'sms_instid': 'instid:{}:'.format(item.instid),
+        'sms_aspect_ratio': 'aspect_ratio:{}:'.format(item.aspect_ratio),
+        'sms_created_by': 'created_by:{}:'.format(item.creator),
+        # in_dspace - migration not required
+        'sms_publisher': 'publisher:{}:'.format(item.publisher),
+        'sms_copyright': 'copyright:{}:'.format(item.copyright),
+        'sms_language': 'language:{}:'.format(item.language),
+        'sms_keywords': 'keywords:{}:'.format(item.keywords),
+        # visibility - migration merged with sms_acl
+        'sms_acl': 'acl:{}:'.format(convert_acl(item.visibility, item.acl)),
+        'sms_screencast': 'screencast:{}:'.format(item.screencast),
+        'sms_image_id': 'image_id:{}:'.format(item.image_id),
+        # dspace_path - migration not required
+        'sms_featured': 'featured:{}:'.format(item.featured),
+        'sms_branding': 'branding:{}:'.format(item.branding),
+        'sms_last_updated_at': 'last_updated_at:{}:'.format(item.last_updated_at),
+        'sms_updated_by': 'updated_by:{}:'.format(item.updated_by),
+        'sms_downloadable': 'downloadable:{}:'.format(item.downloadable),
+        'sms_withdrawn': 'withdrawn:{}:'.format(item.withdrawn),
+        # abstract - migration impractical
+        # priority - migration not required
+    }
 
 
 # A regex pattern for CRSID matching.
@@ -179,3 +297,28 @@ def convert_acl(visibility, acl):
                 LOG.warning('The ACE "{}" cannot be resolved'.format(ace))
 
     return ",".join(new_acl)
+
+
+def url(opts, item):
+    """Return the URL for an item."""
+    path_items = item.filename.strip('/').split('/')
+    path_items = path_items[int(opts['--strip-leading']):]
+    return urllib.parse.urljoin(opts['--base'] + '/', '/'.join(path_items))
+
+
+def image_url(opts, item):
+    """Return the URL for an image_id."""
+    return urllib.parse.urljoin(opts['--base-image-url'], str(item.image_id)+".jpg")
+
+
+def sanitise(s, max_length=4096):
+    """
+    Strip odd characters from a string and sanitise the length to avoid JWPlatform complaining.
+
+    """
+    # Map control characters to empty string
+    s = s.translate(dict.fromkeys(range(32)))
+
+    # Truncate
+    s = s[:max_length]
+    return s
diff --git a/sms2jwplayer/tidy.py b/sms2jwplayer/tidy.py
new file mode 100644
index 0000000..0ac5279
--- /dev/null
+++ b/sms2jwplayer/tidy.py
@@ -0,0 +1,86 @@
+"""
+The tidy subcommand will examine the JWPlatform metadata and generate an update job which can be
+applied via applyupdatejob. This update job will "tidy" the JWPlatform database in the following
+ways:
+
+- Each media id will have exactly one video associated with it with preference given to ones of
+  type "video". Other videos will be deleted.
+
+"""
+import json
+import logging
+
+from . import util
+
+
+LOG = logging.getLogger(__name__)
+
+
+def main(opts):
+    videos = []
+    for metadata_fn in opts['<metadata>']:
+        with open(metadata_fn) as f:
+            videos.extend(json.load(f).get('videos', []))
+    LOG.info('Loaded metadata for %s videos', len(videos))
+
+    with util.output_stream(opts) as fobj:
+        process_videos(opts, fobj, videos)
+
+
+def process_videos(opts, fobj, videos):
+    """
+    Process videos and write update job to fobj.
+
+    """
+    # Delete jobs
+    deletes = []
+
+    # Group videos by media id
+    videos_by_media_id = {}
+    n_grouped = 0
+    for video in videos:
+        media_id_prop = util.get_key_path(video, 'custom.sms_media_id')
+        if media_id_prop is None:
+            continue
+
+        try:
+            media_id = int(util.parse_custom_prop('media', media_id_prop))
+        except ValueError:
+            LOG.error('Could not parse media id prop: %s', media_id_prop)
+        else:
+            group = videos_by_media_id.get(media_id, [])
+            group.append(video)
+            videos_by_media_id[media_id] = group
+            n_grouped += 1
+
+    LOG.info('Grouped %s videos by media id into %s groups', n_grouped, len(videos_by_media_id))
+    LOG.info('Videos without media id: %s', len(videos) - n_grouped)
+
+    for media_id, group in videos_by_media_id.items():
+        video_keys = set(video['key'] for video in group)
+        blessed_key = None
+
+        # Attempt to find a video clip first
+        for video in group:
+            if video['mediatype'] == 'video':
+                blessed_key = video['key']
+                break
+
+        # If failed, find an audio clip
+        if blessed_key is None:
+            for video in group:
+                if video['mediatype'] == 'audio':
+                    blessed_key = video['key']
+                    break
+
+        if blessed_key is None:
+            LOG.warning('Could not find video or audio media for media id %s', media_id)
+            continue
+
+        # Remove the blessed key from the video keys, the rest should be deleted
+        video_keys.remove(blessed_key)
+        for key in video_keys:
+            deletes.append({'type': 'videos', 'resource': {'video_key': key}})
+
+    LOG.info('Number of delete jobs: %s', len(deletes))
+    json.dump({'delete': deletes}, fobj)
diff --git a/sms2jwplayer/util.py b/sms2jwplayer/util.py
index 5662dc9..aada4b4 100644
--- a/sms2jwplayer/util.py
+++ b/sms2jwplayer/util.py
@@ -6,11 +6,16 @@ program to use.
 
 import contextlib
 import os
+import re
 import sys
 
 import jwplatform
 
 
+#: regex for parsing a custom prop field
+CUSTOM_PROP_VALUE_RE = re.compile(r'^([a-z][a-z0-9_]*):(.*):$')
+
+
 class JWPlatformClientError(RuntimeError):
     """
     An error which is thrown if appropriate credentials for the jwplatform cannot be found in the
@@ -82,3 +87,74 @@ def get_key_path(obj, keypath):
             return None
         obj = obj.get(key)
     return obj
+
+
+def parse_custom_prop(expected_type, field):
+    """
+    Parses a custom prop content of the form "<type>:<value>:". Returns the value tuple.
+    Raises ValueError if the field is of the wrong form or has wrong type.
+
+    """
+    match = CUSTOM_PROP_VALUE_RE.match(field)
+    if not match:
+        raise ValueError('Field has invalid format: {field}'.format(field=field))
+    prop_type, value = match.groups()
+    if prop_type != expected_type:
+        raise ValueError(
+            'Field has unexpected type "{prop_type}". Expected "{expected_type}".'.format(
+                prop_type=prop_type, expected_type=expected_type
+            ))
+    return value
+
+
+class VideoNotFoundError(RuntimeError):
+    """
+    The provided SMS media ID does not have a corresponding JWPlatform video.
+    """
+
+
+def key_for_media_id(media_id, preferred_media_type='video', client=None):
+    """
+    :param media_id: the SMS media ID of the required video
+    :type media_id: int
+    :param preferred_media_type: (optional) the preferred media type to return. One of ``'video'``
+        or ``'audio'``.
+    :param client: (options) an authenticated JWPlatform client as returned by
+        :py:func:`.get_jwplatform_client`. If ``None``, call :py:func:`.get_jwplatform_client`.
+    :raises: :py:class:`.VideoNotFoundError` if the media id does not correspond to a JWPlatform
+        video.
+
+    """
+    client = client if client is not None else get_jwplatform_client()
+
+    # The value of the sms_media_id custom property we search for
+    media_id_value = 'media:{:d}:'.format(media_id)
+
+    # Search for videos
+    response = client.videos.list(**{
+        'search:custom.sms_media_id': media_id_value,
+    })
+
+    # Loop through "videos" to find the preferred one based on mediatype
+    video_resource = None
+    for video in response.get('videos', []):
+        # Sanity check: skip videos with wrong media id since video search is
+        # not "is equal to", it is "contains".
+        if video.get('custom', {}).get('sms_media_id') != media_id_value:
+            continue
+
+        # use this video if it has the preferred mediatype or if we have nothing
+        # else
+        if (video.get('mediatype') == preferred_media_type
+                or video_resource is None):
+            video_resource = video
+
+    # If no video found, raise error
+    if video_resource is None:
+        raise VideoNotFoundError()
+
+    # Check the video we found has a non-None key
+    if video_resource.get('key') is None:
+        raise VideoNotFoundError()
+
+    return video_resource['key']
diff --git a/tox.ini b/tox.ini
index 4e78b76..8af1113 100644
--- a/tox.ini
+++ b/tox.ini
@@ -7,6 +7,7 @@ commands=python setup.py test --addopts="--cov=sms2jwplayer" {posargs}
 # Any version of Python 3
 [testenv:py3]
 basepython=python3
+deps=-rrequirements.txt
 
 # Build documentation
 [testenv:doc]

Only import audio if that is the source format
.. ie it isn't extracted from source video as a seperate clip

diff --git a/sms2jwplayer/test/test_applyupdatejob.py b/sms2jwplayer/test/test_applyupdatejob.py
index 271d70c..15e20e4 100644
--- a/sms2jwplayer/test/test_applyupdatejob.py
+++ b/sms2jwplayer/test/test_applyupdatejob.py
@@ -31,16 +31,19 @@ class ApplyUpdateJobTests(JWPlatformTestCase):
             jobfile = os.path.join(tmp_dir, 'job.json')
             with open(jobfile, 'w') as f:
                 json.dump({
-                    'updates': [
-                        {'key': 'abc', 'custom': {'one': 1}},
-                        {'key': 'def', 'custom': {'foo': 'bar', 'buzz': 3}},
+                    'update': [
+                        {'type': 'videos', 'resource': {'video_key': 'abc', 'custom': {'one': 1}}},
+                        {'type': 'videos', 'resource': {
+                            'video_key': 'def', 'custom': {'foo': 'bar', 'buzz': 3}
+                        }},
                     ]
                 }, f)
             applyupdatejob(jobfile)
 
         self.client.videos.update.assert_has_calls([
-            mock.call(**{'video_key': 'abc', 'custom.one': '1'}),
-            mock.call(**{'video_key': 'def', 'custom.foo': 'bar', 'custom.buzz': '3'}),
+            mock.call(**{'video_key': 'abc', 'custom.one': 1, 'http_method': 'POST'}),
+            mock.call(**{'video_key': 'def', 'custom.foo': 'bar', 'custom.buzz': 3,
+                         'http_method': 'POST'}),
         ], any_order=True)
 
 
diff --git a/sms2jwplayer/test/test_genupdatejob.py b/sms2jwplayer/test/test_genupdatejob.py
index 8e14eb5..a559162 100644
--- a/sms2jwplayer/test/test_genupdatejob.py
+++ b/sms2jwplayer/test/test_genupdatejob.py
@@ -96,4 +96,5 @@ class ConvertAclTests(unittest.TestCase):
                 convert_acl('acl-overrule', ['hpcr', 'aj333']),
                 'USER_aj333'
             )
-            log.check(('genmrss', 'WARNING', 'The ACE "hpcr" cannot be resolved'))
+            log.check(('sms2jwplayer.genupdatejob', 'WARNING',
+                       'The ACE "hpcr" cannot be resolved'))

pip install -e .[dev]
parse_log_pytest
pytest
pytest pytest-cov feedparser testfixtures
apt-get update apt-get install -y gcc
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
certifi==2025.1.31
charset-normalizer==3.4.1
coverage==7.8.0
docopt==0.6.2
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
feedparser==6.0.11
idna==3.10
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
Jinja2==3.1.6
jwplatform==2.2.2
MarkupSafe==3.0.2
neterr==1.1.1
packaging @ file:///croot/packaging_1734472117206/work
pluggy @ file:///croot/pluggy_1733169602837/work
pytest @ file:///croot/pytest_1738938843180/work
pytest-cov==6.0.0
python-dateutil==2.9.0.post0
requests==2.32.3
sgmllib3k==1.0.0
six==1.17.0
-e git+https://github.com/uisautomation/sms2jwplayer.git@924d6ab5dd9b7776a68198fa0eee744990fc8c6e#egg=sms2jwplayer
testfixtures==8.3.0
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work
tqdm==4.67.1
urllib3==2.3.0

name: sms2jwplayer
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py39h06a4308_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - coverage==7.8.0
      - docopt==0.6.2
      - feedparser==6.0.11
      - idna==3.10
      - jinja2==3.1.6
      - jwplatform==2.2.2
      - markupsafe==3.0.2
      - neterr==1.1.1
      - pytest-cov==6.0.0
      - python-dateutil==2.9.0.post0
      - requests==2.32.3
      - sgmllib3k==1.0.0
      - six==1.17.0
      - testfixtures==8.3.0
      - tqdm==4.67.1
      - urllib3==2.3.0
prefix: /opt/conda/envs/sms2jwplayer

sms2jwplayer/test/test_genupdatejob.py::ConvertAclTests::test_ace_not_resolved
sms2jwplayer/test/test_applyupdatejob.py::ApplyUpdateJobTests::test_basic_call
sms2jwplayer/test/test_genupdatejob.py::ConvertAclTests::test_lowercase_institutions sms2jwplayer/test/test_genupdatejob.py::ConvertAclTests::test_null_acls sms2jwplayer/test/test_genupdatejob.py::ConvertAclTests::test_visibility_acl_combinations
sms2jwplayer/__init__.py doc/intro.rst tox.ini doc/reference.rst sms2jwplayer/tidy.py sms2jwplayer/applyupdatejob.py requirements.txt sms2jwplayer/genupdatejob.py sms2jwplayer/util.py
sms2jwplayer/__init__.py doc/intro.rst tox.ini doc/reference.rst sms2jwplayer/tidy.py sms2jwplayer/applyupdatejob.py requirements.txt sms2jwplayer/genupdatejob.py sms2jwplayer/util.py