From d7dbc7a9e7f835ceb95ca1b7db39715c3044e943 Mon Sep 17 00:00:00 2001 From: mh Date: Wed, 10 Oct 2001 02:01:11 +0000 Subject: [PATCH] - documentation for the Media handling interface. See MirMedia.java and MediaHandler* - makes ProducerTopics media aware. (i.e convert to the media interface) - make the right hand summary of the startpage *only* display newswire items not sure if this is a bug fix or a feature addition/removal. --- source/mir/media/MediaHandlerAudio.java | 9 +- source/mir/media/MediaHandlerGeneric.java | 19 +- source/mir/media/MediaHandlerImages.java | 12 +- source/mir/media/MediaHandlerImagesGif.java | 4 +- source/mir/media/MediaHandlerImagesJpeg.java | 4 +- source/mir/media/MirMedia.java | 194 +++++++++++++++++++-- source/mircoders/producer/ProducerStartPage.java | 40 +---- source/mircoders/producer/ProducerTopics.java | 67 ++++++- .../mircoders/servlet/ServletModuleOpenIndy.java | 30 +++- templates-dist/producer/topiclist.template | 34 +++- 10 files changed, 333 insertions(+), 80 deletions(-) diff --git a/source/mir/media/MediaHandlerAudio.java b/source/mir/media/MediaHandlerAudio.java index dc3e08b1..4be2dd51 100755 --- a/source/mir/media/MediaHandlerAudio.java +++ b/source/mir/media/MediaHandlerAudio.java @@ -13,11 +13,12 @@ import mir.storage.*; /** - * Interfacedefinition für Datenbank-Adpatoren. Die Adaptoren legen - * jeweils das Verhalten und die Befehlsmächtigkeit der Datenbank - * fest. + * Handles audio media, like mp3 and maybe it could also handle some other. + * It is MediaHandlerGeneric with different icons. * - * @author + * @see mir.media.MediaHandlerGeneric + * @see mir.media.MirMedia + * @author mh * @version 24.09.2001 */ diff --git a/source/mir/media/MediaHandlerGeneric.java b/source/mir/media/MediaHandlerGeneric.java index 253d871e..1710c955 100755 --- a/source/mir/media/MediaHandlerGeneric.java +++ b/source/mir/media/MediaHandlerGeneric.java @@ -13,11 +13,22 @@ import mir.storage.*; /** - * Interfacedefinition für Datenbank-Adpatoren. Die Adaptoren legen - * jeweils das Verhalten und die Befehlsmächtigkeit der Datenbank - * fest. + * This is the Generic MediaHandler. It stores the media data on + * the filesystem and keeps basic metadata (size, type...) in the + * DB. Usually only representation needs to be overridden. + * See the MediaHandlerAudio class to see an example of how one + * could override it. + *

+ * Most media handlers should override this class. + *

+ * In theory, it could be used to handle miscellaneous media that + * we don't have entered in the media_type table, (like RTF documents, + * PS, PDF, etc..) + *

+ * Of course it implements the MirMedia interface. * - * @author + * @see mir.media.MirMedia + * @author mh * @version 24.09.2001 */ diff --git a/source/mir/media/MediaHandlerImages.java b/source/mir/media/MediaHandlerImages.java index aa62a267..2e3ffb51 100755 --- a/source/mir/media/MediaHandlerImages.java +++ b/source/mir/media/MediaHandlerImages.java @@ -11,12 +11,18 @@ import mir.entity.*; /** * This class handles saving, fetching creating representations - * for all images. - * + * for all images. The image content is stored in the DB. The content is + * written out to a file at the ProducerImages level. + * Remember that Handlers for specific image types, Gif, Jpeg, etc.. + * should override it. + * It implements the MirMedia interface. + *

* ok. this is a big hack, it's cause putting the image in the DB * and fetching it from the DB needs low level db connections for - * some reason. -mh 25.09.2001 + * some reason. Does anyone know how to get around this? + * -mh 25.09.2001 * + * @see mir.media.MirMedia * @author mh * @version 24.09.2001 */ diff --git a/source/mir/media/MediaHandlerImagesGif.java b/source/mir/media/MediaHandlerImagesGif.java index b25058d2..0b22412e 100755 --- a/source/mir/media/MediaHandlerImagesGif.java +++ b/source/mir/media/MediaHandlerImagesGif.java @@ -10,8 +10,10 @@ import mir.entity.*; /** * This class handles saving, fetching creating representations - * for gif images. + * for gif images. it overrides MediaHandlerImages. * + * @see mir.media.MediaHandlerImages + * @see mir.media.MirMedia * @author mh * @version 24.09.2001 */ diff --git a/source/mir/media/MediaHandlerImagesJpeg.java b/source/mir/media/MediaHandlerImagesJpeg.java index 283e827e..ff01abc6 100755 --- a/source/mir/media/MediaHandlerImagesJpeg.java +++ b/source/mir/media/MediaHandlerImagesJpeg.java @@ -10,8 +10,10 @@ import mir.entity.*; /** * This class handles saving, fetching creating representations - * for jpeg images. + * for jpeg images. it overrides MediaHandlerImages. * + * @see mir.media.MediaHandlerImages + * @see mir.media.MirMedia * @author mh * @version 24.09.2001 */ diff --git a/source/mir/media/MirMedia.java b/source/mir/media/MirMedia.java index 34beee91..2a3a9b66 100755 --- a/source/mir/media/MirMedia.java +++ b/source/mir/media/MirMedia.java @@ -1,5 +1,6 @@ /* * put your module comment here + * */ @@ -10,32 +11,201 @@ import java.util.*; import mir.entity.*; /** - * Interfacedefinition für Datenbank-Adpatoren. Die Adaptoren legen - * jeweils das Verhalten und die Befehlsmächtigkeit der Datenbank - * fest. - * - * @author + * Interface for Media handling in Mir. All media handlers + * must implement this interface. Each specific media type, + * be it Gif, Jpeg, Mp3 audio, Real Audio or quicktime video + * has special needs when it comes to representation on the various + * pages (article, list, summary), must be stored differently and has a + * different URL, etc... This interface allows Mir to support + * an infinite (I hope) number of media types. Once this is done, + * no code at any other level in Mir needs to be changed other than + * adding the content-type <-> media handler name mapping in the + * media_type table. The following is an example of the media_type + * table: + *

+ * id | name | mime_type | classname | tablename | dcname
+ *---+---------+--------------------------+-----------+---------------+-------
+ * 2 | unknown | application/octet-stream | -- | UploadedMedia |
+ * 3 | jpg | image/gif | ImagesGif | Images |
+ * 4 | mp3 | audio/mp3 | Audio | UploadedMedia |
+ *

+ * The "id" field is used as a mapping in the table that contains the media type + * to the media_type table. For example, the images table has a to_media_type + * field that contains the id in the media_type table. + *

+ * The "name" field is used for various display/filenaming purposes. it should + * match a valid file extension name for a media_type (we could have used the + * content-type map for this....). + *

+ * The "mime_type" field is the most important as it does maps the type to Java + * classes (the storage and media_handler name). We call those classes using + * reflection. This way, once a Handler for a specific media type is implemented + * and entered into the media_type table, no other Mir code needs to be modified. + *

+ * The "classname" field is the name of the media handler (e.g MediaHandlerAudio) + * we use it to call the MediaHandler methods via reflection. + *

+ * The "tablename" is the name of the database storage classes (e.g DatabaseImages + * and EntityImages). We use this to fetch/storage the media (meta)data in the db. + * + * Most media handlers should just extend MediaHandlerGeneric (i.e inherit from + * ) and just override the things that need to be specific. see MediaHandlerAudio + * + * @author mh * @version 24.09.2001 */ public interface MirMedia{ - /* Liefert den Namen der Adaptorklasse - * @return Adaptorklasse als String - */ + /** + * Takes the uploaded media data itself, along with the media Entity + * which contains the Media metadata plus the MediaType entity containing + * all the info for the specific media type itself. It's job is store the + * Media data (content) itself, this could be on the local filesystem, in the + * DB or even on a remote host. It then inserts the MetaData in the DB. + * @param uploadedData, a byte array containing the uploaded data. + * @param ent, an Entity holding the media MetaData + * @param mediaType, an Entity holding the media_table entry + * @return boolean, success/fail + * @see mir.entity.Entity + */ public abstract boolean set (byte[] uploadedData, Entity ent, Entity mediaTypeEnt ); + + /** + * Get's the media data from storage and returns it as a byte array + * Not very useful for most media types as they are stored in a file, + * but very usefull for ones stored in the DB as it is necessary to get + * it first before making a file out of it (in Producer*). + * @param ent, an Entity holding the media MetaData + * @param mediaType, an Entity holding the media_table entry + * @return byte[] + * @see mir.entity.Entity + */ public abstract byte[] get (Entity ent, Entity mediaTypeEnt); + + /** + * Pretty much like get() above. But get's the specific Icon + * representation. useful for media stored in the DB. + * @param ent, an Entity holding the media MetaData + * @return byte[] + * @see mir.entity.Entity + */ public abstract byte[] getIcon (Entity ent); - public abstract String getURL (Entity ent, Entity mediaTypeEnt); - public abstract String getListView (Entity ent, Entity mediaTypeEnt); - public abstract String getStoragePath (); + + /** + * gets the final content representation for the media + * in the form of a URL (String) that allows someone to + * download, look at or listen to the media. (HREF, img src + * streaming link, etc..) + * It should use the helper functions in the StringUtil class to + * build URL's safely, eliminating any *illegal* user input. + * @param ent, an Entity holding the media MetaData + * @param mediaTypeEnt, an Entity holding the media_table entry + * @return String, the url. + * @see mir.entity.Entity + * @see mir.misc.StringUtil + */ + public abstract String getURL (Entity ent, Entity mediaTypeEnt); + + /** + * gets the summary representation for the media + * in the form of a URL (String). Usually the URL points + * to some sort of an icon that previews what kind of + * media an article will contain. + * It should use the helper functions in the StringUtil class to + * build URL's safely, eliminating any *illegal* user input. + * @param ent, an Entity holding the media MetaData + * @param mediaTypeEnt, an Entity holding the media_table entry + * @return String, the url. + * @see mir.entity.Entity + * @see mir.misc.StringUtil + */ + public abstract String getListView (Entity ent, Entity mediaTypeEnt); + + /** + * Returns the absolute filesystem path to where the media + * content should be stored. This path is usually defined + * in the configuration wich is accessible through the MirConfig + * class. + * @return String, the path. + * @see mir.misc.MirConfig + */ + public abstract String getStoragePath (); + + /** + * Returns the *relative* filesystem path to where the media + * icon content should be stored. It is relative to the path + * returned by getStoragePath() + * This path is usually defined + * in the configuration wich is accessible through the MirConfig + * class. + * @return String, the path. + * @see mir.misc.MirConfig + */ public abstract String getIconStoragePath (); + + /** + * Returns the base URL to that the media is accessible from + * to the end user. This could be a URL to another host. + * This is used in the Metadata stored in the DB and later on + * ,the templates use it. + * It is usually defined + * in the configuration wich is accessible through the MirConfig + * class. + * @return String, the base URL to the host. + * @see mir.misc.MirConfig + */ public abstract String getPublishHost (); + + /** + * Returns the file name of the Icon representing the media type. + * It is used in the summary view. + * It is usually defined + * in the configuration wich is accessible through the MirConfig + * class. + * @return String, the icon filename. + * @see mir.misc.MirConfig + */ public abstract String getBigIcon (); + + /** + * Returns the file name of the small Icon representing + * the media type. + * It is used in the right hand newswire list of the startpage. + * It is usually defined + * in the configuration wich is accessible through the MirConfig + * class. + * @return String, the icon filename. + * @see mir.misc.MirConfig + */ public abstract String getTinyIcon (); + + /** + * Returns the IMG SRC "ALT" text to be used + * for the Icon representations + * @return String, the ALT text. + */ public abstract String getIconAlt (); - public abstract boolean isVideo (); + + /** + * your all smart enough to figure it out. + * @return boolean. + */ + public abstract boolean isVideo (); + + /** + * your all smart enough to figure it out. + * @return boolean. + */ public abstract boolean isAudio (); + + /** + * your all smart enough to figure it out. + * @return boolean. + */ public abstract boolean isImage (); } diff --git a/source/mircoders/producer/ProducerStartPage.java b/source/mircoders/producer/ProducerStartPage.java index 8fe05c68..133b698d 100755 --- a/source/mircoders/producer/ProducerStartPage.java +++ b/source/mircoders/producer/ProducerStartPage.java @@ -94,7 +94,7 @@ public class ProducerStartPage extends Producer { SimpleList parentList = HTMLTemplateProcessor.makeSimpleList(theParentList); // get the newswire - whereClause="is_published=true AND to_article_type >= 1"; + whereClause="is_published=true AND to_article_type = 1"; entityList = contentModule.getContent(whereClause,"date desc, webdb_create desc",0,newsPerPage); SimpleList newsWireList = HTMLTemplateProcessor.makeSimpleList(entityList); for (int i=0; i < entityList.size();i++) { @@ -176,14 +176,13 @@ public class ProducerStartPage extends Producer { SimpleList startItemList = HTMLTemplateProcessor.makeSimpleList(entityList); for (int k=0; k < entityList.size();k++) { currentContent = (EntityContent)entityList.elementAt(k); - //images to content + //media to content currentMediaList = DatabaseContentToMedia.getInstance().getUploadedMedia(currentContent); if (currentMediaList!=null && currentMediaList.getCount()>=1) { SimpleList mediaListAudio = new SimpleList(); SimpleList mediaListImages = new SimpleList(); SimpleList mediaListVideo = new SimpleList(); SimpleList mediaListOther = new SimpleList(); - //SimpleHash allMediaSimpleHash = new SimpleHash(); for (int n=0; n < currentMediaList.size();n++) { upMedia = currentMediaList.elementAt(n); upMediaSimpleHash = HTMLTemplateProcessor.makeSimpleHash(upMedia); @@ -218,7 +217,6 @@ public class ProducerStartPage extends Producer { } //end if media_type != null } //end for try{ - //SimpleList contentList = (SimpleList)mergeData.get("contentlist"); contentHash = (SimpleHash)startItemList.get(k); contentHash.put("to_media_audio", mediaListAudio); contentHash.put("to_media_images", mediaListImages); @@ -228,21 +226,6 @@ public class ProducerStartPage extends Producer { } //end if currentMediaList != null } //enf for featurueList.size.. - - - /*for (int i=0; i < entityList.size();i++) { - currentContent = (EntityContent)entityList.elementAt(i); - //fetching/setting the images - imageEntityList = DatabaseContentToMedia.getInstance().getImages(currentContent); - if (imageEntityList!=null && imageEntityList.getCount()>=1) { - try{ - mediaList = HTMLTemplateProcessor.makeSimpleList(imageEntityList); - contentHash = (SimpleHash)startItemList.get(i); - contentHash.put("to_media", mediaList); - } catch (Exception e){} - } - }*/ - // get the breaking news // only the first 5 // todo: the number of breaking_news items have to be configurable @@ -257,14 +240,13 @@ public class ProducerStartPage extends Producer { SimpleList featureList = HTMLTemplateProcessor.makeSimpleList(entityList); for (int k=0; k < entityList.size();k++) { currentContent = (EntityContent)entityList.elementAt(k); - //images to content + //media to content currentMediaList = DatabaseContentToMedia.getInstance().getUploadedMedia(currentContent); if (currentMediaList!=null && currentMediaList.getCount()>=1) { SimpleList mediaListAudio = new SimpleList(); SimpleList mediaListImages = new SimpleList(); SimpleList mediaListVideo = new SimpleList(); SimpleList mediaListOther = new SimpleList(); - //SimpleHash allMediaSimpleHash = new SimpleHash(); for (int n=0; n < currentMediaList.size();n++) { upMedia = currentMediaList.elementAt(n); upMediaSimpleHash = HTMLTemplateProcessor.makeSimpleHash(upMedia); @@ -299,7 +281,6 @@ public class ProducerStartPage extends Producer { } //end if media_type != null } //end for try{ - //SimpleList contentList = (SimpleList)mergeData.get("contentlist"); contentHash = (SimpleHash)featureList.get(k); contentHash.put("to_media_audio", mediaListAudio); contentHash.put("to_media_images", mediaListImages); @@ -309,21 +290,6 @@ public class ProducerStartPage extends Producer { } //end if currentMediaList != null } //enf for featurueList.size.. - - - /*for (int i=0; i < entityList.size();i++) { - currentContent = (EntityContent)entityList.elementAt(i); - //fetching/setting the images - imageEntityList = DatabaseContentToMedia.getInstance().getImages(currentContent); - if (imageEntityList!=null && imageEntityList.getCount()>=1) { - try{ - mediaList = HTMLTemplateProcessor.makeSimpleList(imageEntityList); - contentHash = (SimpleHash)featureList.get(i); - contentHash.put("to_media", mediaList); - } catch (Exception e){} - } - }*/ - // Zusaetzlich Informationen startPageModel = new SimpleHash(); startPageModel.put("breakingnews", breakingList); diff --git a/source/mircoders/producer/ProducerTopics.java b/source/mircoders/producer/ProducerTopics.java index 7b8ac841..6b3d4183 100755 --- a/source/mircoders/producer/ProducerTopics.java +++ b/source/mircoders/producer/ProducerTopics.java @@ -2,12 +2,14 @@ package mircoders.producer; import java.io.*; import java.lang.*; +import java.lang.reflect.*; import java.util.*; import java.sql.*; import freemarker.template.*; import mir.misc.*; +import mir.media.*; import mir.storage.*; import mir.module.*; import mir.entity.*; @@ -20,6 +22,18 @@ import mircoders.storage.*; public class ProducerTopics extends ProducerList { public String where; + String currentMediaId; + EntityList upMediaEntityList; + EntityList imageEntityList; + EntityList currentMediaList; + Entity mediaType; + EntityMedia uploadedMedia; + Class mediaHandlerClass=null; + MirMedia mediaHandler=null; + String mediaHandlerName=null; + Database mediaStorage=null; + String tinyIcon; + String iconAlt; public void setAdditional(String key, TemplateModel value) { additional.put(key,value); @@ -88,12 +102,53 @@ public class ProducerTopics extends ProducerList { if(entityList != null && entityList.size()==1){ currentContent = (EntityContent)entityList.elementAt(0); SimpleHash specialHash = HTMLTemplateProcessor.makeSimpleHash(currentContent); - // get the images - EntityList currentMedia = DatabaseContentToMedia.getInstance().getImages(currentContent); - if (currentMedia!=null && currentMedia.getCount()>=1) { - SimpleList mediaList = HTMLTemplateProcessor.makeSimpleList(currentMedia); - specialHash.put("to_media",mediaList); - } + + currentMediaList = DatabaseContentToMedia.getInstance().getUploadedMedia(currentContent); + if (currentMediaList!=null && currentMediaList.getCount()>=1) { + SimpleList mediaListAudio = new SimpleList(); + SimpleList mediaListImages = new SimpleList(); + SimpleList mediaListVideo = new SimpleList(); + SimpleList mediaListOther = new SimpleList(); + for (int n=0; n < currentMediaList.size();n++) { + upMedia = currentMediaList.elementAt(n); + upMediaSimpleHash = HTMLTemplateProcessor.makeSimpleHash(upMedia); + mediaType = ((EntityMedia)upMedia).getMediaType(); + //must be a non-existant to_media_type entry.. + if (mediaType != null) { + try { + mediaHandlerName = mediaType.getValue("classname"); + mediaStorageName = mediaType.getValue("tablename"); + mediaStorageClass = Class.forName("mircoders.storage.Database"+mediaStorageName); + mediaHandlerClass = Class.forName("mir.media.MediaHandler"+mediaHandlerName); + mediaHandler = (MirMedia)mediaHandlerClass.newInstance(); + Method m = mediaStorageClass.getMethod("getInstance", null); + mediaStorage = (Database)m.invoke(null, null); + //we most likely need further info + upMedia = mediaStorage.selectById(upMedia.getId()); + } catch (Exception e) { + theLog.printError("ProducerList: problem in reflection: "+mediaHandlerName); + } //end catch + upMediaSimpleHash.put("url", mediaHandler.getListView(upMedia, mediaType)); + if (upMedia.getValue("is_published") == "1") { + if (mediaHandler.isImage()) { + mediaListImages.add(upMediaSimpleHash); + } else if (mediaHandler.isAudio()) { + mediaListAudio.add(upMediaSimpleHash); + } else if (mediaHandler.isVideo()) { + mediaListVideo.add(upMediaSimpleHash); + } else { + mediaListOther.add(upMediaSimpleHash); + } + } //end if is_published + } //end if media_type != null + } //end for + try{ + specialHash.put("to_media_audio", mediaListAudio); + specialHash.put("to_media_images", mediaListImages); + specialHash.put("to_media_video", mediaListVideo); + specialHash.put("to_media_other", mediaListOther); + } catch (Exception e){} + } //end if currentMediaList != null setAdditional("special",specialHash); } diff --git a/source/mircoders/servlet/ServletModuleOpenIndy.java b/source/mircoders/servlet/ServletModuleOpenIndy.java index d1a8980d..6bd741df 100755 --- a/source/mircoders/servlet/ServletModuleOpenIndy.java +++ b/source/mircoders/servlet/ServletModuleOpenIndy.java @@ -239,16 +239,25 @@ public class ServletModuleOpenIndy extends ServletModule for(Iterator it = mp.requestList.iterator(); it.hasNext();){ MpRequest mpReq = (MpRequest)it.next(); String fileName = mpReq.getFilename(); + //This is just a temporary way to get the content-type via + //the .extension , we need to use a magic method, by looking + //at the header (first few bytes) of the file. -mh + //the Oreilly method sucks cause it rely's on the what + //content-type the client browser sends and that's + //too often application-octet stream. String contentType = FileUtil.guessContentTypeFromName(fileName); HashMap mediaValues = new HashMap(); theLog.printError("CONTENT TYPE IS: "+contentType); - + + //The map file should be Mir/content-types.properties, it's the + //default Sun Java file+ some entries that it did not have. + //so if you support a new media type you have to make sure that + //it is in this file if ((contentType==null) || (contentType=="application/octet-stream")) { throw new ServletModuleException("ModuleException: One or more files of unrecognized types"); } - String mediaTitle=(String)withValues.get("media_title"+i); i++; @@ -262,6 +271,10 @@ public class ServletModuleOpenIndy extends ServletModule mediaValues.put("is_produced", "0"); mediaValues.put("is_published","1"); + //the where clause to find the media_type entry + //from the content-type. + //we use the media type entry to lookup the + //media Handler/Storage classes String wc = " mime_type='"+contentType+"'"; EntityList mediaTypesList = DatabaseMediaType.getInstance().selectByWhereClause(wc); @@ -269,13 +282,17 @@ public class ServletModuleOpenIndy extends ServletModule String mediaTypeId = null; String mediaStorageName = null; String mediaHandlerName = null; - + + //if we found an entry matching the + //content-type int the table. if (mediaTypesList.size() > 0) { + //get the class names from the media_type table. mediaTypeId = mediaTypesList.elementAt(0).getId(); mediaStorageName = mediaTypesList.elementAt(0).getValue("tablename"); mediaHandlerName = mediaTypesList.elementAt(0).getValue("classname"); mediaValues.put("to_media_type",mediaTypeId); - + + //load the classes via reflection String MediaId; try { Class mediaStorageClass = Class.forName("mircoders.storage.Database"+mediaStorageName); @@ -288,12 +305,13 @@ public class ServletModuleOpenIndy extends ServletModule Class mediaHandlerClass = Class.forName("mir.media.MediaHandler"+mediaHandlerName); MirMedia mediaHandler = (MirMedia)mediaHandlerClass.newInstance(); + //save and store the media data/metadata mediaHandler.set(mpReq.getMedia(), mediaEnt,mediaTypesList.elementAt(0)); //were done with mpReq at this point, dereference it. as it contains //mucho mem. -mh 01.10.2001 mpReq=null; - + if(mediaId!=null){ new ProducerMedia().handle(null, null, false, false, mediaId); } @@ -301,7 +319,7 @@ public class ServletModuleOpenIndy extends ServletModule theLog.printError("setting uploaded_media failed: "+e.toString()); } //end try-catch - + //we got this far, associate the media to the article try{ DatabaseContentToMedia.getInstance().addMedia(cid,mediaId); theLog.printError("setting content_x_media success"); diff --git a/templates-dist/producer/topiclist.template b/templates-dist/producer/topiclist.template index a8694762..04ab03cf 100755 --- a/templates-dist/producer/topiclist.template +++ b/templates-dist/producer/topiclist.template @@ -57,9 +57,21 @@

${special.title}

${special.creator}, ${special.webdb_create_formatted}

-

- - ${special.description}

+

+ + ${special.to_media_audio[0]["url"]} + + + ${special.to_media_video[0]["url"]} + + + ${special.to_media_other[0]["url"]} + +

+ + ${special.to_media_images[0]["url"]} + + ${special.description}

[read]

@@ -74,9 +86,19 @@

${i.title}

${i.creator}, ${i.webdb_create_formatted}

- - - + + ${i.to_media_audio[0]["url"]} + + + ${i.to_media_video[0]["url"]} + + + ${i.to_media_other[0]["url"]} + +

+ + ${i.to_media_images[0]["url"]} + ${i.description}

[read]

-- 2.11.0