org.ecocean.media.MediaAsset.java Source code

Java tutorial

Introduction

Here is the source code for org.ecocean.media.MediaAsset.java

Source

/*
 * This file is a part of Wildbook.
 * Copyright (C) 2015 WildMe
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Foobar is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Wildbook.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.ecocean.media;

import org.ecocean.Occurrence;
import org.ecocean.CommonConfiguration;
import org.ecocean.ImageAttributes;
import org.ecocean.Keyword;
import org.ecocean.Annotation;
import org.ecocean.AccessControl;
import org.ecocean.Shepherd;
import org.ecocean.servlet.ServletUtilities;
import org.ecocean.Util;
import org.ecocean.identity.IdentityServiceLog;
import org.ecocean.identity.IBEISIA;
//import org.ecocean.Encounter;
import java.net.URL;
import java.nio.file.Path;
import java.nio.file.Files;
//import java.time.LocalDateTime;
import org.joda.time.DateTime;
import java.util.Date;
import org.json.JSONObject;
import org.json.JSONArray;
import org.json.JSONException;
import java.util.Set;
import java.util.List;
import java.util.HashMap;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.builder.ToStringBuilder;
import java.util.UUID;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
//import java.io.FileInputStream;
import java.io.FileOutputStream;
import javax.jdo.Query;
import javax.xml.bind.DatatypeConverter;

/*
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
    
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.util.Iterator;
*/

/*
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.*;
import com.drew.metadata.exif.ExifSubIFDDirectory;
*/

/**
 * MediaAsset describes a photo or video that can be displayed or used
 * for processing and analysis.
 */
public class MediaAsset implements java.io.Serializable {
    static final long serialVersionUID = 8844223450447974780L;
    protected int id = MediaAssetFactory.NOT_SAVED;

    protected String uuid = null;

    protected AssetStore store;
    protected String parametersAsString;
    protected JSONObject parameters;

    protected Occurrence occurrence;

    protected Integer parentId;

    protected long revision;

    protected AccessControl accessControl = null;

    protected JSONObject derivationMethod = null;

    protected MediaAssetMetadata metadata = null;

    protected ArrayList<String> labels;

    protected ArrayList<Feature> features;

    protected ArrayList<Keyword> keywords;

    protected String hashCode;

    protected String detectionStatus;
    protected String identificationStatus;

    protected Double userLatitude;
    protected Double userLongitude;

    protected DateTime userDateTime;

    //protected MediaAssetType type;
    //protected Integer submitterid;

    //protected Set<String> tags;
    //protected Integer rootId;

    //protected AssetStore thumbStore;
    //protected Path thumbPath;
    //protected Path midPath;

    //private LocalDateTime metaTimestamp;
    //private Double metaLatitude;
    //private Double metaLongitude;

    /**
     * To be called by AssetStore factory method.
     */
    /*
        public MediaAsset(final AssetStore store, final JSONObject params, final String category)
        {
    this(MediaAssetFactory.NOT_SAVED, store, params, MediaAssetType.fromFilename(path.toString()), category);
        }
    */

    public MediaAsset(final AssetStore store, final JSONObject params) {
        //this(store, params, null);
        this(MediaAssetFactory.NOT_SAVED, store, params);
    }

    public MediaAsset(final int id, final AssetStore store, final JSONObject params) {
        this.id = id;
        this.setUUID();
        this.store = store;
        this.parameters = params;
        if (params != null)
            this.parametersAsString = params.toString();
        this.setRevision();
        this.setHashCode();
    }

    public AccessControl getAccessControl() {
        return accessControl;
    }

    public void setAccessControl(AccessControl ac) {
        accessControl = ac;
    }

    public void setAccessControl(HttpServletRequest request) {
        this.setAccessControl(new AccessControl(request));
    }

    private URL getUrl(final AssetStore store, final Path path) {
        if (store == null) {
            return null;
        }

        return null; //store.webPath(path);
    }

    private String getUrlString(final URL url) {
        if (url == null) {
            return null;
        }

        return url.toExternalForm();
    }

    public int getId() {
        return id;
    }

    public void setId(int i) {
        id = i;
    }

    public Occurrence getOccurrence() {
        return this.occurrence;
    }

    public String getOccurrenceID() {
        if (this.occurrence == null)
            return null;
        return this.occurrence.getOccurrenceID();
    }

    public void setOccurrence(Occurrence occ) {
        this.occurrence = occ;
    }

    public String getDetectionStatus() {
        return this.detectionStatus;
    }

    public void setDetectionStatus(String status) {
        this.detectionStatus = status;
    }

    public String getIdentificationStatus() {
        return this.identificationStatus;
    }

    public void setIdentificationStatus(String status) {
        this.identificationStatus = status;
    }

    //this is for Annotation mostly?  provides are reproducible uuid based on the MediaAsset id
    public String getUUID() {
        if (uuid != null)
            return uuid;
        //UUID v3 seems to take an arbitrary bytearray in, so we construct one that is basically "Ma____" where "____" is the int id
        return generateUUIDFromId();
    }

    public void setUUID(String u) {
        uuid = u;
    }

    /* note: this is used for *new* MediaAssets (via constructor), so we want it to *always* give us something.
       this we try to get a value no matter what.  in 99% of the cases, a new MediaAsset will have id = -1, so generateUUIDv3() will fail.
       thus this essentially will almost always use a v4 uuid (random).  so be it! */
    private void setUUID() {
        uuid = this.generateUUIDFromId();
        if (uuid == null)
            uuid = Util.generateUUID();
    }

    //note this function will not allow "invalid" (< 0) ids... so see above for hack for new MediaAssets
    public String generateUUIDFromId() {
        if (this.id == MediaAssetFactory.NOT_SAVED)
            return null;
        if (this.id < 0)
            return null;
        return this.generateUUIDv3(this.id, (byte) 77, (byte) 97);
    }

    public static String generateUUIDv3(int id, byte b1, byte b2) {
        if (id == MediaAssetFactory.NOT_SAVED)
            return null;
        if (id < 0)
            return null;
        byte[] b = new byte[6];
        b[0] = b1;
        b[1] = b2;
        b[2] = (byte) (id >> 24);
        b[3] = (byte) (id >> 16);
        b[4] = (byte) (id >> 8);
        b[5] = (byte) (id >> 0);
        return UUID.nameUUIDFromBytes(b).toString();
    }

    public AssetStore getStore() {
        return store;
    }

    public Integer getParentId() {
        return parentId;
    }

    public void setParentId(Integer pid) {
        parentId = pid;
    }

    public MediaAsset getParentRoot(Shepherd myShepherd) {
        Integer pid = this.getParentId();
        if (pid == null)
            return this;
        MediaAsset par = MediaAssetFactory.load(pid, myShepherd);
        if (par == null)
            return this; //orphaned!  fail!!
        return par.getParentRoot(myShepherd);
    }

    public JSONObject getParameters() {
        if (parameters != null)
            return parameters;
        //System.out.println("NOTE: getParameters() on " + this + " was null, so trying to get from parametersAsString()");
        JSONObject j = Util.stringToJSONObject(parametersAsString);
        parameters = j;
        return j;
    }

    public void setParameters(JSONObject p) {
        if (p == null) {
            System.out.println("WARNING: attempted to set null parameters on " + this + "; ignoring");
            return;
        }
        parameters = p;
        parametersAsString = p.toString();
    }

    ///note: really the only place that should call getParametersAsString or setParametersAsString is datanucleus...
    ///  always use getParameters() and setParameters() instead!
    public String getParametersAsString() {
        if (parametersAsString != null)
            return parametersAsString;
        if (parameters == null)
            return null;
        parametersAsString = parameters.toString();
        return parametersAsString;
    }

    public void setParametersAsString(String p) {
        if (p == null) {
            System.out.println("WARNING: attempted to set null parametersAsString on " + this + "; ignoring");
            return;
        }
        parametersAsString = p;
        //now we also set parameters as the JSONObject (or try)
        JSONObject j = Util.stringToJSONObject(p);
        if (j != null)
            parameters = j;
    }

    public JSONObject getDerivationMethod() {
        return derivationMethod;
    }

    public void setDerivationMethod(JSONObject dm) {
        derivationMethod = dm;
    }

    public void addDerivationMethod(String k, Object val) {
        if (derivationMethod == null)
            derivationMethod = new JSONObject();
        derivationMethod.put(k, val);
    }

    public String getDerivationMethodAsString() {
        if (derivationMethod == null)
            return null;
        return derivationMethod.toString();
    }

    public void setDerivationMethodAsString(String d) {
        if (d == null) {
            derivationMethod = null;
            return;
        }
        try {
            derivationMethod = new JSONObject(d);
        } catch (JSONException je) {
            System.out.println(this + " -- error parsing derivation json string (" + d + "): " + je.toString());
            derivationMethod = null;
        }
    }

    public long setRevision() {
        this.revision = System.currentTimeMillis();
        return this.revision;
    }

    public String setHashCode() {
        if (store == null)
            return null;
        this.hashCode = store.hashCode(getParameters());
        //System.out.println("hashCode on " + this + " = " + this.hashCode);
        return this.hashCode;
    }

    //this is store-specific, and null should be interpreted to mean "i guess i dont really have one"
    // in some cases, this might be some sort of unique-ish identifier (e.g. youtube id), so ymmv
    public String getFilename() {
        if (store == null)
            return null;
        return store.getFilename(this);
    }

    public ArrayList<String> getLabels() {
        return labels;
    }

    public void setLabels(ArrayList<String> l) {
        labels = l;
    }

    public void addLabel(String s) {
        if (labels == null)
            labels = new ArrayList<String>();
        if (!labels.contains(s))
            labels.add(s);
    }

    public boolean hasLabel(String s) {
        if (labels == null)
            return false;
        return labels.contains(s);
    }

    public ArrayList<Feature> getFeatures() {
        return features;
    }

    public void setFeatures(ArrayList<Feature> f) {
        features = f;
    }

    public void addFeature(Feature f) {
        if (features == null)
            features = new ArrayList<Feature>();
        if (!features.contains(f)) {
            features.add(f);
            f.asset = this;
        }
    }

    //kinda sorta really only for Encounter.findAllMediaByFeatureId()
    public boolean hasFeatures(String[] featureIds) {
        if ((features == null) || (features.size() < 1))
            return false;
        for (Feature f : features) {
            for (int i = 0; i < featureIds.length; i++) {
                if (f.isType(featureIds[i]))
                    return true; //short-circuit on first match
            }
        }
        return false;
    }

    public Path localPath() {
        if (store == null)
            return null;
        return store.localPath(this);
    }

    public boolean cacheLocal() throws Exception {
        if (store == null)
            return false;
        return store.cacheLocal(this, false);
    }

    public boolean cacheLocal(boolean force) throws Exception {
        if (store == null)
            return false;
        return store.cacheLocal(this, force);
    }

    //indisputable attributes about the image (e.g. type, dimensions, colorspaces etc)
    // this is (seemingly?) always derived from MediaAssetMetadata, so .. yeah. make sure that is set (see note by getMetadata() )
    public ImageAttributes getImageAttributes() {
        if ((metadata == null) || (metadata.getData() == null))
            return null;
        JSONObject attr = metadata.getData().optJSONObject("attributes");
        if (attr == null)
            return null;
        double w = attr.optDouble("width", -1);
        double h = attr.optDouble("height", -1);
        String type = attr.optString("contentType");
        if ((w < 1) || (h < 1))
            return null;
        return new ImageAttributes(w, h, type);
    }

    public double getWidth() {
        ImageAttributes iattr = getImageAttributes();
        if (iattr == null)
            return 0;
        return iattr.getWidth();
    }

    public double getHeight() {
        ImageAttributes iattr = getImageAttributes();
        if (iattr == null)
            return 0;
        return iattr.getHeight();
    }

    /**
     this function resolves (how???) various difference in "when" this image was taken.  it might use different metadata (in EXIF etc) and/or
     human-input (e.g. perhaps encounter data might trump it?)   TODO wtf should we do?
     FOR NOW: we rely first on (a) metadata.attributes.dateTime (as iso8601 string),
          then (b) crawl metadata.exif for something date-y
    */
    public DateTime getDateTime() {
        if (this.userDateTime != null)
            return this.userDateTime;
        if (getMetadata() == null)
            return null;
        String adt = getMetadata().getAttributes().optString("dateTime", null);
        if (adt != null)
            return DateTime.parse(adt); //lets hope it is in iso8601 format like it should be!
        //meh, gotta find it the hard way then...
        return getMetadata().getDateTime();
    }

    public void setUserDateTime(DateTime dt) {
        this.userDateTime = dt;
    }

    public DateTime getUserDateTime() {
        return this.userDateTime;
    }

    /**
      like getDateTime() this is considered "definitive" -- so it must resolve differences in metadata vs other (e.g. encounter etc) values
    */
    public Double getUserLatitude() {
        return this.userLatitude;
    }

    public Double getLatitude() {
        if (this.userLatitude != null)
            return this.userLatitude;
        if (getMetadata() == null)
            return null;
        double lat = getMetadata().getAttributes().optDouble("latitude");
        if (!Double.isNaN(lat))
            return lat;
        return getMetadata().getLatitude();
    }

    public void setUserLatitude(Double lat) {
        this.userLatitude = lat;
    }

    public Double getUserLongitude() {
        return this.userLongitude;
    }

    public Double getLongitude() {
        if (this.userLongitude != null)
            return this.userLongitude;
        if (getMetadata() == null)
            return null;
        double lon = getMetadata().getAttributes().optDouble("longitude");
        if (!Double.isNaN(lon))
            return lon;
        return getMetadata().getLongitude();
    }

    public void setUserLongitude(Double lon) {
        this.userLongitude = lon;
    }

    //note: default behavior will add this to the features on this MediaAsset -- can pass false to disable
    //TODO expand to handle things other than images (some day)
    public Feature generateUnityFeature() {
        return generateUnityFeature(true);
    }

    public Feature generateUnityFeature(boolean addToMediaAsset) {
        Feature f = new Feature();
        if (addToMediaAsset)
            this.addFeature(f);
        return f;
    }

    //if unity feature is appropriate, generates that; otherwise does a boundingBox one
    public Feature generateFeatureFromBbox(double w, double h, double x, double y) {
        Feature f = null;
        if ((x != 0) || (y != 0) || (w != this.getWidth()) || (h != this.getHeight())) {
            JSONObject p = new JSONObject();
            p.put("width", w);
            p.put("height", h);
            p.put("x", x);
            p.put("y", y);
            f = new Feature("org.ecocean.boundingBox", p);
            this.addFeature(f);
        } else {
            f = this.generateUnityFeature();
        }
        return f;
    }

    public ArrayList<Annotation> getAnnotations() {
        ArrayList<Annotation> anns = new ArrayList<Annotation>();
        if ((this.getFeatures() == null) || (this.getFeatures().size() < 1))
            return anns;
        for (Feature f : this.getFeatures()) {
            if (f.getAnnotation() != null)
                anns.add(f.getAnnotation());
        }
        return anns;
    }

    /*
    return annotations;
        }
        
        //this will create the "trivial" Annotation (dimensions of the MediaAsset) iff no Annotations exist
        public ArrayList<Annotation> getAnnotationsGenerate(String species) {
    if (annotations == null) annotations = new ArrayList<Annotation>();
    if (annotations.size() < 1) addTrivialAnnotation(species);
    return annotations;
        }
        
        //TODO check if it is already here?  maybe?
        public Annotation addTrivialAnnotation(String species) {
    Annotation ann = new Annotation(this, species);  //this will add it to our .annotations collection as well
    String newId = generateUUIDv3((byte)65, (byte)110);  //set Annotation UUID relative to our ID  An___
    if (newId != null) ann.setId(newId);
    return ann;
        }
        
        public int getAnnotationCount() {
    if (annotations == null) return 0;
    return annotations.size();
        }
        
        public static MediaAsset findByAnnotation(Annotation annot, Shepherd myShepherd) {
    String queryString = "SELECT FROM org.ecocean.media.MediaAsset WHERE annotations.contains(ann) && ann.id == \"" + annot.getId() + "\"";
    Query query = myShepherd.getPM().newQuery(queryString);
    List results = (List)query.execute();
    if (results.size() < 1) return null;
    return (MediaAsset)results.get(0);
        }
    */

    /*
        public Path getThumbPath()
        {
    return thumbPath;
        }
        
        public Path getMidPath()
        {
    return midPath;
        }
    */

    /*
        public MediaAssetType getType() {
    return type;
        }
    */

    /**
     * Return a full web-accessible url to the asset, or null if the
     * asset is not web-accessible.
     *  NOTE: now you should *almost always* use .safeURL() to return something to a user -- this will hide original files when necessary
     */
    public URL webURL() {

        if (store == null) {
            System.out.println("MediaAsset " + this.getUUID() + " has no store!");
            return null;
        }

        try {
            int i = ((store.getUsage() == null) ? -1 : store.getUsage().indexOf("PLACEHOLDERHACK:"));
            if (i == 0)
                return new URL(store.getUsage().substring(16));
        } catch (java.net.MalformedURLException ex) {
        }

        return store.webURL(this);
    }

    /*    has been deprecated, cuz you should make a better choice about what you want the url of. see: safeURL() and friends
        public String webURLString() {
    return getUrlString(this.webURL());
        }
    */

    //the primary purpose here is to mask (i.e. never send) the original (uploaded) image file.
    //  right now "master" labelled image is used, if available, otherwise children are chosen by allChildTypes() order....
    public URL safeURL(Shepherd myShepherd, HttpServletRequest request, String bestType) {
        MediaAsset ma = bestSafeAsset(myShepherd, request, bestType);
        if (ma == null)
            return null;
        return ma.webURL();
    }

    public URL safeURL(Shepherd myShepherd, HttpServletRequest request) {
        return safeURL(myShepherd, request, null);
    }

    //this assumes you weakest privileges
    public URL safeURL(Shepherd myShepherd) {
        return safeURL(myShepherd, null);
    }

    public URL safeURL(HttpServletRequest request) {
        String context = "context0";
        if (request != null)
            context = ServletUtilities.getContext(request); //kinda rough, but....
        //the throw-away Shepherd object is [mostly!] ok here since we arent returning the MediaAsset it is used to find
        Shepherd myShepherd = new Shepherd(context);
        myShepherd.setAction("MediaAsset.safeURL");
        URL u = safeURL(myShepherd, request);
        myShepherd.rollbackDBTransaction();
        myShepherd.closeDBTransaction();
        return u;
    }

    public URL safeURL() {
        return safeURL((HttpServletRequest) null);
    }

    public MediaAsset bestSafeAsset(Shepherd myShepherd, HttpServletRequest request, String bestType) {
        if (store == null)
            return null;
        //this logic is simplistic now, but TODO make more complex (e.g. configurable) later....
        //TODO should be block "original" ???  is that overkill??
        if (bestType == null)
            bestType = "master";
        //note, this next line means bestType may get bumped *up* for anon user.... so we should TODO some logic in there if ever needed
        if (AccessControl.simpleUserString(request) == null)
            bestType = "watermark";
        if (store instanceof URLAssetStore)
            bestType = "original"; //this is cuz it is assumed to be a "public" url
        System.out.println(" = = = = bestSafeAsset() wanting bestType=" + bestType);

        //gotta consider that we are the best!
        if (this.hasLabel("_" + bestType))
            return this;

        //if we are a child asset, we need to find our parent then find best from there!
        MediaAsset top = this; //assume we are the parent-est
        if (parentId != null) {
            top = MediaAssetFactory.load(parentId, myShepherd);
            if (top == null)
                throw new RuntimeException("bestSafeAsset() failed to find parent on " + this);
        }

        boolean gotBest = false;
        List<String> types = store.allChildTypes(); //note: do we need to care that top may have changed stores????
        for (String t : types) {
            if (t.equals(bestType))
                gotBest = true;
            if (!gotBest)
                continue; //skip over any "better" types until we get to best we can use
            System.out.println("   ....  ??? do we have a " + t);
            //now try to see if we have one!
            ArrayList<MediaAsset> kids = top.findChildrenByLabel(myShepherd, "_" + t);
            if ((kids != null) && (kids.size() > 0))
                return kids.get(0); ///not sure how to pick if we have more than one!  "probably rare" case anyway....
        }
        return null; //got nothing!  :(
    }

    public MediaAsset bestSafeAsset(Shepherd myShepherd, HttpServletRequest request) {
        return bestSafeAsset(myShepherd, request, null);
    }

    public MediaAsset bestSafeAsset(Shepherd myShepherd) {
        return bestSafeAsset(myShepherd, null);
    }

    /*
        public String thumbWebPathString() {
    return getUrlString(thumbWebPath());
        }
        
        public String midWebPathString() {
    return getUrlString(midWebPath());
        }
        
        public URL thumbWebPath() {
    return getUrl(thumbStore, thumbPath);
        }
        
        public void setThumb(final AssetStore store, final Path path)
        {
    thumbStore = store;
    thumbPath = path;
        }
        
        public AssetStore getThumbstore() {
    return thumbStore;
        }
        
        public URL midWebPath() {
    if (midPath == null) {
        return webPath();
    }
        
    //
    // Just use thumb store for now.
    //
    return getUrl(thumbStore, midPath);
        }
        
        public void setMid(final Path path) {
    //
    // Just use thumb store for now.
    //
    this.midPath = path;
        }
        
    */

    /*
        public Integer getSubmitterId() {
    return submitterid;
        }
        
        public void setSubmitterId(final Integer submitterid) {
    this.submitterid = submitterid;
        }
    */

    /*
        public LocalDateTime getMetaTimestamp() {
    return metaTimestamp;
        }
        
        
        public void setMetaTimestamp(LocalDateTime metaTimestamp) {
    this.metaTimestamp = metaTimestamp;
        }
        
        
        public Double getMetaLatitude() {
    return metaLatitude;
        }
        
        
        public void setMetaLatitude(Double metaLatitude) {
    this.metaLatitude = metaLatitude;
        }
        
        
        public Double getMetaLongitude() {
    return metaLongitude;
        }
        
        
        public void setMetaLongitude(Double metaLongitude) {
    this.metaLongitude = metaLongitude;
        }
    */

    /*
        public void delete() {
    MediaAssetFactory.delete(this.id);
    MediaAssetFactory.deleteFromStore(this);
        }
    */

    //this takes contents of this MediaAsset and copies it to the target (note MediaAssets must exist with sufficient params already)
    //please note this uses *source* AssetStore for copying, which can/will affect how, for example, credentials in aws s3 are chosen.
    // for tighter control of this, you can call copyAsset() (or copyAssetAny()?) directly on desired store
    public void copyAssetTo(MediaAsset targetMA) throws IOException {
        if (store == null)
            throw new IOException("copyAssetTo(): store is null on " + this);
        store.copyAssetAny(this, targetMA);
    }

    public org.datanucleus.api.rest.orgjson.JSONObject sanitizeJson(HttpServletRequest request,
            org.datanucleus.api.rest.orgjson.JSONObject jobj)
            throws org.datanucleus.api.rest.orgjson.JSONException {
        return sanitizeJson(request, jobj, true);
    }

    //fullAccess just gets cascaded down from Encounter -> Annotation -> us... not sure if it should win vs security(request) TODO
    public org.datanucleus.api.rest.orgjson.JSONObject sanitizeJson(HttpServletRequest request,
            org.datanucleus.api.rest.orgjson.JSONObject jobj, boolean fullAccess)
            throws org.datanucleus.api.rest.orgjson.JSONException {
        jobj.put("id", this.getId());
        jobj.remove("parametersAsString");
        //jobj.put("guid", "http://" + CommonConfiguration.getURLLocation(request) + "/api/org.ecocean.media.MediaAsset/" + id);

        //TODO something better with store?  fix .put("store", store) ???
        HashMap<String, String> s = new HashMap<String, String>();
        s.put("type", store.getType().toString());
        jobj.put("store", s);
        ArrayList<Feature> fts = getFeatures();
        if ((fts != null) && (fts.size() > 0)) {
            org.datanucleus.api.rest.orgjson.JSONArray jarr = new org.datanucleus.api.rest.orgjson.JSONArray();
            for (int i = 0; i < fts.size(); i++) {
                org.datanucleus.api.rest.orgjson.JSONObject jf = new org.datanucleus.api.rest.orgjson.JSONObject();
                Feature ft = fts.get(i);
                jf.put("id", ft.getId());
                jf.put("type", ft.getType());
                JSONObject p = ft.getParameters();
                if (p != null)
                    jf.put("parameters", Util.toggleJSONObject(p));
                jarr.put(jf);
            }
            jobj.put("features", jarr);
        }
        if ((getMetadata() != null) && (getMetadata().getData() != null)
                && (getMetadata().getData().opt("attributes") != null)) {
            //jobj.put("metadata", new org.datanucleus.api.rest.orgjson.JSONObject(getMetadata().getData().getJSONObject("attributes").toString()));
            jobj.put("metadata", Util.toggleJSONObject(getMetadata().getData().getJSONObject("attributes")));
        }
        DateTime dt = getDateTime();
        if (dt != null)
            jobj.put("dateTime", dt.toString()); //DateTime.toString() gives iso8601, noice!

        //note? warning? i guess this will traverse... gulp?
        String context = ServletUtilities.getContext(request);
        Shepherd myShepherd = new Shepherd(context);
        myShepherd.setAction("MediaAsset.class");
        myShepherd.beginDBTransaction();

        URL u = safeURL(myShepherd, request);
        if (u != null)
            jobj.put("url", u.toString());

        ArrayList<MediaAsset> kids = this.findChildren(myShepherd);
        myShepherd.rollbackDBTransaction();
        if ((kids != null) && (kids.size() > 0)) {
            org.datanucleus.api.rest.orgjson.JSONArray k = new org.datanucleus.api.rest.orgjson.JSONArray();
            for (MediaAsset kid : kids) {
                k.put(kid.sanitizeJson(request, new org.datanucleus.api.rest.orgjson.JSONObject(), fullAccess));
            }
            jobj.put("children", k);
        }

        if (fullAccess) {
            jobj.put("userLatitude", this.getLatitude());
            jobj.put("userLongitude", this.getLongitude());
            jobj.put("userDateTime", this.getUserDateTime());
        }

        jobj.put("occurrenceID", this.getOccurrenceID());

        if (this.getLabels() != null)
            jobj.put("labels", this.getLabels());

        if ((this.getKeywords() != null) && (this.getKeywords().size() > 0)) {
            JSONArray ka = new JSONArray();
            for (Keyword kw : this.getKeywords()) {
                JSONObject kj = new JSONObject();
                kj.put("indexname", kw.getIndexname());
                kj.put("readableName", kw.getReadableName());
                ka.put(kj);
            }
            jobj.put("keywords", new org.datanucleus.api.rest.orgjson.JSONArray(ka.toString()));
        }

        myShepherd.rollbackDBTransaction();
        myShepherd.closeDBTransaction();

        return jobj;
    }

    public String toString() {
        return new ToStringBuilder(this).append("id", id).append("parent", parentId)
                .append("labels", ((labels == null) ? "" : labels.toString())).append("store", store.toString())
                .toString();
    }

    public void copyIn(File file) throws IOException {
        if (store == null)
            throw new IOException("copyIn(): store is null on " + this);
        store.copyIn(file, getParameters(), false);
    }

    public MediaAsset updateChild(String type, HashMap<String, Object> opts) throws IOException {
        if (store == null)
            throw new IOException("store is null on " + this);
        return store.updateChild(this, type, opts);
    }

    public MediaAsset updateChild(String type) throws IOException {
        return updateChild(type, null);
    }

    public ArrayList<MediaAsset> detachChildren(Shepherd myShepherd, String type) throws IOException {
        if (store == null)
            throw new IOException("store is null on " + this);
        ArrayList<MediaAsset> disposable = this.findChildrenByLabel(myShepherd, "_" + type);
        if ((disposable != null) && (disposable.size() > 0)) {
            for (MediaAsset ma : disposable) {
                ma.setParentId(null);
                ma.addDerivationMethod("detachedFrom", this.getId());
                System.out.println("INFO: detached child " + ma + " from " + this);
            }
        }
        return disposable;
    }

    public ArrayList<MediaAsset> findChildren(Shepherd myShepherd) {
        if (store == null)
            return null;
        ArrayList<MediaAsset> all = store.findAllChildren(this, myShepherd);
        return all;
    }

    public ArrayList<MediaAsset> findChildrenByLabel(Shepherd myShepherd, String label) {
        ArrayList<MediaAsset> all = this.findChildren(myShepherd);
        if ((all == null) || (all.size() < 1))
            return null;
        ArrayList<MediaAsset> matches = new ArrayList<MediaAsset>();
        for (MediaAsset ma : all) {
            if ((ma.getLabels() != null) && ma.getLabels().contains(label))
                matches.add(ma);
        }
        return matches;
    }

    // NOTE: these currrently do not recurse.  this makes a big assumption that one only wants children of _original
    //   (e.g. on an encounter) and will *probably* need to change in the future.    TODO?
    public static MediaAsset findOneByLabel(ArrayList<MediaAsset> mas, Shepherd myShepherd, String label) {
        ArrayList<MediaAsset> all = findAllByLabel(mas, myShepherd, label, true);
        if ((all == null) || (all.size() < 1))
            return null;
        return all.get(0);
    }

    public static ArrayList<MediaAsset> findAllByLabel(ArrayList<MediaAsset> mas, Shepherd myShepherd,
            String label) {
        return findAllByLabel(mas, myShepherd, label, false);
    }

    private static ArrayList<MediaAsset> findAllByLabel(ArrayList<MediaAsset> mas, Shepherd myShepherd,
            String label, boolean onlyOne) {
        if ((mas == null) || (mas.size() < 1))
            return null;
        ArrayList<MediaAsset> found = new ArrayList<MediaAsset>();
        for (MediaAsset ma : mas) {
            if ((ma.getLabels() != null) && ma.getLabels().contains(label)) {
                found.add(ma);
                if (onlyOne)
                    return found;
            }
            ArrayList<MediaAsset> kids = ma.findChildrenByLabel(myShepherd, label);
            if ((kids != null) && (kids.size() > 0)) {
                if (onlyOne) {
                    found.add(kids.get(0));
                    return found;
                } else {
                    found.addAll(kids);
                }
            }
        }
        return found;
    }

    //creates the "standard" derived children for a MediaAsset (thumb, mid, etc)
    public ArrayList<MediaAsset> updateStandardChildren() {
        if (store == null)
            return null;
        List<String> types = store.standardChildTypes();
        if ((types == null) || (types.size() < 1))
            return null;
        ArrayList<MediaAsset> mas = new ArrayList<MediaAsset>();
        for (String type : types) {
            System.out.println(">> updateStandardChildren(): type = " + type);
            MediaAsset c = null;
            try {
                c = this.updateChild(type);
            } catch (IOException ex) {
                System.out.println("updateStandardChildren() failed on type=" + type + ", ma=" + this + " with "
                        + ex.toString());
            }
            if (c != null)
                mas.add(c);
        }
        return mas;
    }

    //as above, but saves them too
    public ArrayList<MediaAsset> updateStandardChildren(Shepherd myShepherd) {
        ArrayList<MediaAsset> mas = updateStandardChildren();
        for (MediaAsset ma : mas) {
            MediaAssetFactory.save(ma, myShepherd);
        }
        return mas;
    }

    public void setKeywords(ArrayList<Keyword> kws) {
        keywords = kws;
    }

    public ArrayList<Keyword> addKeyword(Keyword k) {
        if (keywords == null)
            keywords = new ArrayList<Keyword>();
        if (!keywords.contains(k))
            keywords.add(k);
        return keywords;
    }

    public ArrayList<Keyword> getKeywords() {
        return keywords;
    }

    public boolean hasKeyword(String keywordName) {
        if (keywords != null) {
            int numKeywords = keywords.size();
            for (int i = 0; i < numKeywords; i++) {
                Keyword kw = keywords.get(i);
                if ((kw.getIndexname().equals(keywordName)) || (kw.getReadableName().equals(keywordName))) {
                    return true;
                }
            }
        }

        return false;
    }

    public boolean hasKeyword(Keyword key) {
        if (keywords != null) {
            if (keywords.contains(key)) {
                return true;
            }
        }
        return false;
    }

    //if we dont have the Annotation... which kinda sucks but okay
    public String toHtmlElement(HttpServletRequest request, Shepherd myShepherd) {
        return toHtmlElement(request, myShepherd, null);
    }

    public String toHtmlElement(HttpServletRequest request, Shepherd myShepherd, Annotation ann) {
        if (store == null)
            return "<!-- ERROR: MediaAsset.toHtmlElement() has no .store value for " + this.toString() + " -->";
        return store.mediaAssetToHtmlElement(this, request, myShepherd, ann);
    }

    //piggybacks off metadata, so that must be set first, otherwise it matches corresponding mime type (major/minor), case-insensitive
    //note: for return values, we standardize on all-lowercase. so there.
    public boolean isMimeTypeMajor(String type) {
        if (type == null)
            return false;
        return type.toLowerCase().equals(this.getMimeTypeMajor());
    }

    public boolean isMimeTypeMinor(String type) {
        if (type == null)
            return false;
        return type.toLowerCase().equals(this.getMimeTypeMinor());
    }

    public String getMimeTypeMajor() {
        String[] mt = this.getMimeType();
        if ((mt == null) || (mt.length < 1))
            return null;
        return mt[0];
    }

    public String getMimeTypeMinor() {
        String[] mt = this.getMimeType();
        if ((mt == null) || (mt.length < 2))
            return null;
        return mt[1];
    }

    public String[] getMimeType() {
        if (this.metadata == null)
            return null;
        String mt = this.metadata.getAttributes().optString("contentType", null); //note: getAttributes always  returns a JSONObject (even if empty)
        if (mt == null)
            return null;
        return mt.toLowerCase().split("/");
    }

    //note: we are going to assume Metadata "will just be there" so no magical updates. if it is null, it is null.
    // this implies basically that it is set once when the MediaAsset is created, so make sure that happens, *cough*
    public MediaAssetMetadata getMetadata() {
        return metadata;
    }

    public MediaAssetMetadata updateMetadata() throws IOException { //TODO should this overwrite existing, or append?
        if (store == null)
            return null;
        metadata = store.extractMetadata(this);
        return metadata;
    }

    //only gets the "attributes" portion -- which is usually all we need for derived images
    public MediaAssetMetadata updateMinimalMetadata() {
        if (store == null)
            return null;
        try {
            metadata = store.extractMetadata(this, true); //true means "attributes" only
        } catch (IOException ioe) { //we silently eat IOExceptions, but will return null
            System.out.println(
                    "WARNING: updateMinimalMetadata() on " + this + " got " + ioe.toString() + "; failed to set");
            return null;
        }
        return metadata;
    }

    //handy cuz we dont need the actual file (if we have these values from elsewhere) and usually the only stuff we "need"
    public MediaAssetMetadata setMinimalMetadata(int width, int height, String contentType) {
        //note, this will overwrite existing "attributes" value if it exists
        if (metadata == null)
            metadata = new MediaAssetMetadata();
        metadata.getData().put("width", width);
        metadata.getData().put("height", height);
        metadata.getData().put("contentType", contentType);
        return metadata;
    }

    public void refreshIAStatus(Shepherd myShepherd) {
        String s = IBEISIA.parseDetectionStatus(Integer.toString(this.getId()), myShepherd);
        if (s != null)
            this.setDetectionStatus(s);

        String cumulative = null;
        for (Annotation ann : this.getAnnotations()) {
            s = IBEISIA.parseIdentificationStatus(ann.getId(), myShepherd);
            if ((s != null) && ((cumulative == null) || !s.equals("complete")))
                cumulative = s;
        }
        if (cumulative != null)
            this.setIdentificationStatus(cumulative);
    }

    public JSONObject getIAStatus() {
        JSONObject rtn = new JSONObject();

        JSONObject j = new JSONObject();
        j.put("status", getDetectionStatus());
        rtn.put("detection", j);

        j = new JSONObject();
        j.put("status", getIdentificationStatus());
        rtn.put("identification", j);
        return rtn;
    }

    //takes base64 string and turns to binary content and copies that in as normal
    public void copyInBase64(String b64) throws IOException {
        if (b64 == null)
            throw new IOException("copyInBase64() null string");
        byte[] imgBytes = new byte[100];
        try {
            imgBytes = DatatypeConverter.parseBase64Binary(b64);
        } catch (IllegalArgumentException ex) {
            throw new IOException("copyInBase64() could not parse: " + ex.toString());
        }
        File file = (this.localPath() != null) ? this.localPath().toFile()
                : File.createTempFile("b64-" + Util.generateUUID(), ".tmp");
        FileOutputStream stream = new FileOutputStream(file);
        try {
            stream.write(imgBytes);
        } finally {
            stream.close();
        }
        if (file.exists()) {
            this.copyIn(file);
        } else {
            throw new IOException("copyInBase64() could not write " + file);
        }
    }

    public boolean isValidChildType(String type) {
        if (store == null)
            return false;
        return store.isValidChildType(type);
    }

}