diff --git a/build.gradle b/build.gradle index d6f0ae0..d2f2afe 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ plugins { id 'eclipse' id 'maven-publish' id 'net.nemerosa.versioning' version '1.3.0' - id 'com.jfrog.bintray' version '1.1' + id 'com.jfrog.bintray' version '1.2' id 'com.jfrog.artifactory' version '3.0.1' } diff --git a/src/main/java/org/schedulesdirect/api/Airing.java b/src/main/java/org/schedulesdirect/api/Airing.java index d6c6e41..11eee16 100644 --- a/src/main/java/org/schedulesdirect/api/Airing.java +++ b/src/main/java/org/schedulesdirect/api/Airing.java @@ -72,7 +72,17 @@ static public enum DolbyStatus { /** * An unknown value was provided for Dolby status; report the unknown value as a bug ticket for future inclusion */ - UNKNOWN + UNKNOWN; + + public static DolbyStatus fromValue(String val) { + switch(val.toLowerCase().replaceAll("\\W", "")) { + case "dd": return DD; + case "dd51": return DD51; + case "dolby": return DOLBY; + } + + return UNKNOWN; + } } /** @@ -251,14 +261,10 @@ static public enum ContentType { case "stereo": stereo = true; break; case "dvs": descriptiveVideo = true; break; case "subtitled": subtitled = true; break; + case "SAP": sap = true; break; default: if(val.startsWith("D")) { // This is a Dolby marker - try { - dolbyStatus = DolbyStatus.valueOf(val.replaceAll(" ", "").replaceAll("\\.", "")); - } catch(IllegalArgumentException e) { - LOG.warn(String.format("Unknown DolbyStatus encountered! [%s]", val)); - dolbyStatus = DolbyStatus.UNKNOWN; - } + dolbyStatus = DolbyStatus.fromValue(val); } else LOG.warn(String.format("Unknown audio property encountered! [%s]", val)); } @@ -267,9 +273,6 @@ static public enum ContentType { timeApproximate = src.optBoolean("timeApproximate"); if(subtitled && src.has("subtitledLanguage")) subtitleLanguage = src.getString("subtitledLanguage"); - sap = src.optBoolean("sap"); - if(sap && src.has("sapLanguage")) - sapLanguage = src.getString("sapLanguage"); cableInTheClassroom = src.optBoolean("cableInTheClassroom"); subjectToBlackout = src.optBoolean("subjectToBlackout"); educational = src.optBoolean("educational"); @@ -703,7 +706,7 @@ public void setFinaleStatus(FinaleStatus finaleStatus) { } /** - * @param tvRating the tvRating to set + * @param tvRatings the tvRatings to set */ public void setTvRatings(ContentRating[] tvRatings) { this.tvRatings = tvRatings; diff --git a/src/main/java/org/schedulesdirect/api/Artwork.java b/src/main/java/org/schedulesdirect/api/Artwork.java new file mode 100644 index 0000000..eba4120 --- /dev/null +++ b/src/main/java/org/schedulesdirect/api/Artwork.java @@ -0,0 +1,127 @@ +package org.schedulesdirect.api; + +import org.json.JSONObject; + +public class Artwork { + + public static enum Size { + MASSIVE, + LARGE, + MEDIUM, + SMALL, + EXTRA_SMALL, + UNKNOWN; + + public static Size fromString(String val) { + if(val != null) { + switch(val) { + case "Ms": + return Size.MASSIVE; + case "Lg": + return Size.LARGE; + case "Md": + return Size.MEDIUM; + case "Sm": + return Size.SMALL; + case "Xs": + return Size.EXTRA_SMALL; + } + } + + return Size.UNKNOWN; + } + } + + private String aspect; + private int width; + private int height; + private boolean text; + private String category; + private String uri; + private String tier; + private Size size; + + public Artwork(JSONObject obj, EpgClient clnt) { + aspect = obj.optString("aspect"); + String width = obj.optString("width"); + this.width = width.length() == 0 ? 0 : Integer.parseInt(width); + String height = obj.optString("height"); + this.height = height.length() == 0 ? 0 : Integer.parseInt(height); + text = "yes".equalsIgnoreCase(obj.optString("text")); + category = obj.optString("category"); + tier = obj.optString("tier"); + String uri = obj.optString("uri"); + if(uri.matches("^https?:\\/\\/.*")) { + this.uri = uri; + } + else { + this.uri = String.format("%s/%s/image/%s", clnt.getBaseUrl(), EpgClient.API_VERSION, uri); + } + + size = Size.fromString(obj.optString("size")); + } + + public String getAspect() { + return aspect; + } + + public void setAspect(String aspect) { + this.aspect = aspect; + } + + public int getWidth() { + return width; + } + + public void setWidth(int width) { + this.width = width; + } + + public int getHeight() { + return height; + } + + public void setHeight(int height) { + this.height = height; + } + + public boolean isText() { + return text; + } + + public void setText(boolean text) { + this.text = text; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public String getTier() { + return tier; + } + + public void setTier(String tier) { + this.tier = tier; + } + + public Size getSize() { + return size; + } + + public void setSize(Size size) { + this.size = size; + } +} diff --git a/src/main/java/org/schedulesdirect/api/EpgClient.java b/src/main/java/org/schedulesdirect/api/EpgClient.java index fe21bda..576f383 100644 --- a/src/main/java/org/schedulesdirect/api/EpgClient.java +++ b/src/main/java/org/schedulesdirect/api/EpgClient.java @@ -51,7 +51,7 @@ static public String getUriPathForLineupId(String id) { /** * Constructor * @param userAgent The user agent to pass along to all SD HTTP requests - * @param baseUri The base URI to use when constructing URIs/URLs based on relative data in the raw JSON + * @param baseUrl The base URI to use when constructing URIs/URLs based on relative data in the raw JSON */ public EpgClient(final String userAgent, final String baseUrl) { this.userAgent = userAgent; @@ -64,7 +64,6 @@ public EpgClient(final String userAgent, final String baseUrl) { * @param location The 3 letter ISO country code; must be a country supported by the service (USA, CAN, etc.) * @param zip The zip/postal code to find headends for * @return An array of Lineup objects representing all available Lineups for the given zip; never returns null, but may return an empty array - * @throws InvalidZipCodeException Thrown if the given zip/postal code is invalid * @throws IOException Thrown if there is any kind of IO error accessing the raw data feed */ public final Lineup[] getLineups(final String location, final String zip) throws IOException { @@ -181,6 +180,14 @@ protected void writeLogoToFile(final Station station, final File dest) throws IO */ abstract protected Program fetchProgram(final String progId) throws IOException; + /** + * Fetches artwork for a single program + * @param progId The program id to fetch artwork for + * @return The Artwork instance for the given program id or null if unavailable + * @throws IOException Thrown on any IO error accessing the data + */ + abstract protected Artwork[] fetchArtwork(final String progId) throws IOException; + /** * Fetch multiple recording schedules in batch. * @@ -263,7 +270,7 @@ public String getBaseUrl() { } /** - * @param baseUri the baseUrl to set + * @param baseUrl the baseUrl to set */ public void setBaseUri(String baseUrl) { this.baseUrl = baseUrl; diff --git a/src/main/java/org/schedulesdirect/api/NetworkEpgClient.java b/src/main/java/org/schedulesdirect/api/NetworkEpgClient.java index 1c72886..c667a89 100644 --- a/src/main/java/org/schedulesdirect/api/NetworkEpgClient.java +++ b/src/main/java/org/schedulesdirect/api/NetworkEpgClient.java @@ -18,6 +18,9 @@ import java.io.IOException; import java.io.InputStream; import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -119,7 +122,7 @@ public NetworkEpgClient(final String id, final String pwd) throws InvalidCredent } /** - * ctor; typically only used for development & testing; allows overriding of JsonRequestFactory instance + * ctor; typically only used for development & testing; allows overriding of JsonRequestFactory instance * @param id * @param pwd * @param factory @@ -145,7 +148,7 @@ public NetworkEpgClient(final String id, final String pwd, final String userAgen } /** - * ctor; typically only used for development & testing; allows overriding of JsonRequestFactory instance + * ctor; typically only used for development & testing; allows overriding of JsonRequestFactory instance * @param id * @param pwd * @param userAgent @@ -165,7 +168,6 @@ public NetworkEpgClient(final String id, final String pwd, final String userAgen * @param userAgent The user agent to send on all requests to the SD servers * @param baseUrl The base URL to use for all HTTP communication; most should not set this value as it is for testing and development only! * @param useCache Should the client instance maintain a cache of created objects or hit the SD server on every request? Though memory intensive, use of the cache is greatly encouraged! - * @param factory The JsonRequestFactory to be used for this client to generate network requests * @throws InvalidCredentialsException Thrown if the given credentials were invalid * @throws IOException Thrown if there is any IO error communicating with the Schedules Direct servers * @throws ServiceOfflineException Thrown if the web service reports itself as offline/unavailable @@ -423,6 +425,39 @@ protected Airing[] fetchSchedule(final Station station) throws IOException { protected Program fetchProgram(final String progId) throws IOException { return fetchPrograms(new String[] { progId }).values().toArray(new Program[1])[0]; } + + @Override + protected Artwork[] fetchArtwork(String progId) throws IOException { + String artProgId = progId; + if(artProgId.length() > 10) { + artProgId = artProgId.substring(0, 10); + } + + List aList = new ArrayList<>(); + + JSONArray req = new JSONArray(); + req.put(artProgId); + + JSONArray resp = Config.get().getObjectMapper().readValue(factory.get(DefaultJsonRequest.Action.POST, RestNouns.METADATA, getHash(), getUserAgent(), getBaseUrl()).submitForJson(req), JSONArray.class); + for(int i=0; i ids = new ArrayList<>(); diff --git a/src/main/java/org/schedulesdirect/api/Program.java b/src/main/java/org/schedulesdirect/api/Program.java index 022a030..7717298 100644 --- a/src/main/java/org/schedulesdirect/api/Program.java +++ b/src/main/java/org/schedulesdirect/api/Program.java @@ -25,6 +25,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -87,11 +88,11 @@ static public enum ColorCode { */ COLOR, /** - * The program is in Black & White + * The program is in Black & White */ BW, /** - * The program is in both color and Black & White + * The program is in both color and Black & White */ COLOR_AND_BW, /** @@ -166,6 +167,39 @@ static public enum Role { VISUAL_EFFECTS } + /** + * Represents the entity type that is program represents + * @author Ben Fisler + * + */ + static public enum EntityType { + /** + * An unknown value was provided; provide the value in a bug ticket for future inclusion + */ + UNKNOWN, + SHOW, + EPISODE, + SPORTS, + MOVIE; + + public static EntityType fromValue(String value) { + if(value != null) { + switch(value) { + case "Show": + return SHOW; + case "Episode": + return EPISODE; + case "Sports": + return SPORTS; + case "Movie": + return MOVIE; + } + } + + return UNKNOWN; + } + } + /** * Represents a credit in a program * @@ -546,8 +580,12 @@ static public String convertToSeriesId(String id) { private String venue; private Team[] teams; private URL[] images; + private EntityType entityType; + private Map> keywords; + private Artwork[] artworks; private Program seriesInfo; + /** * Consutrctor * @param src The JSON object from which this instance is being constructed; cannot be null @@ -687,6 +725,8 @@ else if(o1 == null && o2 == null) String orig = src.optString("originalAirDate", ""); originalAirDate = orig.length() > 0 && !orig.startsWith("0") ? getOrigAirDateFormat().parse(src.getString("originalAirDate")) : null; descriptionLanguage = descs != null ? src.optString("descriptionLanguage", null) : null; + String entityTypeStr = src.optString("entityType"); + entityType = entityTypeStr.length() == 0 ? EntityType.UNKNOWN : EntityType.valueOf(entityTypeStr); String srcType = src.optString("sourceType").toUpperCase(); try { sourceType = srcType.length() == 0 ? SourceType.NONE : SourceType.valueOf(srcType); @@ -745,7 +785,37 @@ else if(o1 == null && o2 == null) } this.images = urls.toArray(new URL[0]); } - seriesInfo = id.startsWith("EP") ? clnt.fetchProgram(convertToSeriesId(id)) : null; + + this.keywords = new LinkedHashMap<>(); + JSONObject keyWords = src.optJSONObject("keyWords"); + if(keyWords != null) { + Iterator keyWordGroupNamesIt = keyWords.keys(); + while(keyWordGroupNamesIt.hasNext() == true) { + String keyWordGroupName = keyWordGroupNamesIt.next(); + JSONArray keywords = keyWords.optJSONArray(keyWordGroupName); + List keywordGroup = this.keywords.get(keyWordGroupName); + if(keywordGroup == null) { + keywordGroup = new ArrayList<>(); + this.keywords.put(keyWordGroupName, keywordGroup); + } + + if(keywords != null) { + for(int i=0; i < keywords.length(); ++i) { + String keyword = keywords.getString(i); + keywordGroup.add(keyword); + } + } + } + } + + if(src.optBoolean("hasImageArtwork") == true) { + artworks = clnt.fetchArtwork(id); + } + else { + artworks = new Artwork[0]; + } + + seriesInfo = (entityType == EntityType.EPISODE) ? clnt.fetchProgram(convertToSeriesId(id)) : null; } catch (Throwable t) { throw new InvalidJsonObjectException(String.format("Program[%s]: %s", id, t.getMessage()), t, src.toString(3)); } @@ -1184,7 +1254,7 @@ public void setMd5(String md5) { } /** - * @param mpaaRating the mpaaRating to set + * @param ratings the ratings to set */ public void setRatings(ContentRating[] ratings) { this.ratings = ratings; @@ -1388,4 +1458,40 @@ private String toString(Collection collection, int maxLen) { builder.append("]"); return builder.toString(); } + + /** + * @return the entityType + */ + public EntityType getEntityType() { + return entityType; + } + + /** + * @param entityType the entityType to set + */ + public void setEntityType(EntityType entityType) { + this.entityType = entityType; + } + + /** + * @return the keywords + */ + public Map> getKeywords() { + return keywords; + } + + /** + * @param keywords the keywords to set + */ + public void setKeywords(Map> keywords) { + this.keywords = keywords; + } + + public Artwork[] getArtworks() { + return artworks; + } + + public void setArtworks(Artwork[] artworks) { + this.artworks = artworks; + } } diff --git a/src/main/java/org/schedulesdirect/api/RestNouns.java b/src/main/java/org/schedulesdirect/api/RestNouns.java index f3c0489..4cef1b7 100644 --- a/src/main/java/org/schedulesdirect/api/RestNouns.java +++ b/src/main/java/org/schedulesdirect/api/RestNouns.java @@ -31,6 +31,7 @@ public final class RestNouns { static public final String SCHEDULE_MD5S = SCHEDULES + "/md5"; static public final String MESSAGES = "messages"; static public final String AVAILABLE = "available"; + static public final String METADATA = "metadata" + "/" + PROGRAMS; private RestNouns() {} } diff --git a/src/main/java/org/schedulesdirect/api/Station.java b/src/main/java/org/schedulesdirect/api/Station.java index 6340cc1..1e77e7a 100644 --- a/src/main/java/org/schedulesdirect/api/Station.java +++ b/src/main/java/org/schedulesdirect/api/Station.java @@ -80,13 +80,13 @@ public void writeImageToFile(File dest) throws IOException { /** * @return the url */ - URL getUrl() { + public URL getUrl() { return url; } /** * @param url the url to set */ - void setUrl(URL url) { + public void setUrl(URL url) { this.url = url; } /** diff --git a/src/main/java/org/schedulesdirect/api/ZipEpgClient.java b/src/main/java/org/schedulesdirect/api/ZipEpgClient.java index ec4f6c7..0147f64 100644 --- a/src/main/java/org/schedulesdirect/api/ZipEpgClient.java +++ b/src/main/java/org/schedulesdirect/api/ZipEpgClient.java @@ -49,16 +49,16 @@ import com.fasterxml.jackson.core.JsonParseException; /** - * An implementation of EpgClient that uses a local zip file as its data source + *

An implementation of EpgClient that uses a local zip file as its data source

* *

The zip file to be used must follow a specific format and structure. Such a zip file * can be generated by running the EPG Grabber application.

* - *

This implementation has two common uses: + *

This implementation has two common uses:

*
    *
  1. For development and testing of this API, you can download a zip file of listings data and reuse it instead of constantly hitting the Schedules Direct servers.
  2. *
  3. For production, applications should always simply download a zip file once a day and reuse that zip file via this client implentation, which provides a simple form a caching.
  4. - *

+ * * *

* Most every real world application should only be accessing EPG data via instances of this class. Apps should @@ -110,10 +110,25 @@ static public String scrubFileName(String input) { return input.replaceAll(INVALID_FILE_CHARS, "_"); } + /** + * Returns the first 10 characters of a programs ID + * @param input The program ID + * @return The first 10 characters + */ + static public String artworkId(String input) { + String ret = input; + if(ret.length() > 10) { + ret = ret.substring(0, 10); + } + + return ret; + } + private File src; private FileSystem vfs; private Map lineups; private Map progCache; + private Map> artCache; private boolean closed; private boolean detailsFetched; @@ -137,6 +152,7 @@ public ZipEpgClient(final File zip, final String baseUrl) throws IOException { super(null, baseUrl); src = zip; progCache = new HashMap(); + artCache = new HashMap<>(); URI fsUri; try { fsUri = new URI(String.format("jar:%s", zip.toURI())); @@ -286,6 +302,11 @@ protected Program fetchProgram(final String progId) throws IOException { Program p = progCache.get(progId); if(p == null) { Path path = vfs.getPath(String.format("programs/%s.txt", scrubFileName(progId))); + + if(!Files.exists(path) && progId.startsWith("SH")) { + path = vfs.getPath(String.format("seriesInfo/%s.txt", scrubFileName(progId))); + } + if(Files.exists(path)) { try(InputStream ins = Files.newInputStream(path)) { String data = IOUtils.toString(ins, ZIP_CHARSET.toString()); @@ -309,6 +330,49 @@ protected Program fetchProgram(final String progId) throws IOException { } return p; } + + @Override + protected Artwork[] fetchArtwork(String progId) throws IOException { + if(closed) + throw new IllegalStateException("Instance has already been closed!"); + + String aId = artworkId(progId); + + List artworks = artCache.get(aId); + + if(artworks == null) { + artworks = new ArrayList<>(); + artCache.put(aId, artworks); + + Path path = vfs.getPath(String.format("artwork/%s.txt", aId)); + if(Files.exists(path)) { + try(InputStream ins = Files.newInputStream(path)) { + String data = IOUtils.toString(ins, ZIP_CHARSET.toString()); + if(data != null) { + JSONObject artworkInfo; + try { + artworkInfo = Config.get().getObjectMapper().readValue(data, JSONObject.class); + Object temp = artworkInfo.get("data"); + if(temp instanceof JSONArray) { + JSONArray artworkArr = artworkInfo.getJSONArray("data"); + + for(int i=0; i fetchSchedules(final Lineup lineup) throws IOException { @@ -368,6 +432,7 @@ public void purgeCache() { if(closed) throw new IllegalStateException("Instance has already been closed!"); progCache.clear(); + artCache.clear(); } @Override diff --git a/src/main/java/org/schedulesdirect/api/json/DefaultJsonRequest.java b/src/main/java/org/schedulesdirect/api/json/DefaultJsonRequest.java index 99653d2..d3a54c8 100644 --- a/src/main/java/org/schedulesdirect/api/json/DefaultJsonRequest.java +++ b/src/main/java/org/schedulesdirect/api/json/DefaultJsonRequest.java @@ -125,7 +125,6 @@ private Request initRequest() { /** * Submit this request; returns the JSON object response received; only call if the request is expected to return a JSON object in response * @param reqData The supporting data for the request; this is dependent on the action and obj target specified - * @param ignoreContentType When true, the content type of the response is ignored (handles buggy SD server response for some request types) * @return The JSON encoded response received from the SD service * @throws IOException Thrown on any IO error encountered */ @@ -167,7 +166,7 @@ public InputStream submitForInputStream(Object reqData) throws IOException { /** * Submit this request; returns the raw input stream of the content; caller responsible for closing stream when done. * @param reqData The supporting data for the request; this is dependent on the action and obj target specified - * @param failOnStatusError If true and the status code of the HTTP request > 399 then throw an exception; if false just return the entity stream regardless + * @param failOnStatusError If true and the status code of the HTTP request > 399 then throw an exception; if false just return the entity stream regardless * @return The InputStream of data received in response to the request * @throws IOException Thrown on any IO error encountered * @throws IllegalStateException Thrown if called on a partially constructed object (the 2 arg ctor) diff --git a/src/test/java/org/schedulesdirect/api/ArtworkTest.java b/src/test/java/org/schedulesdirect/api/ArtworkTest.java new file mode 100644 index 0000000..9f89eac --- /dev/null +++ b/src/test/java/org/schedulesdirect/api/ArtworkTest.java @@ -0,0 +1,37 @@ +package org.schedulesdirect.api; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import org.json.JSONObject; +import org.junit.BeforeClass; +import org.junit.Test; +import org.schedulesdirect.api.Artwork.Size; + +public class ArtworkTest { + + private EpgClient MOCK_CLNT = null; + + @BeforeClass + public void setup() { + MOCK_CLNT = mock(EpgClient.class); + when(MOCK_CLNT.getBaseUrl()).thenReturn("http://testurl.com"); + } + + @Test + public void validateArtworkParse() { + String src = "{\"width\": \"135\",\"height\": \"180\",\"uri\": \"assets/p282288_b_v2_aa.jpg\",\"size\": \"Sm\",\"aspect\": \"3x4\",\"category\": \"Banner-L3\",\"text\": \"yes\",\"primary\": \"true\",\"tier\": \"Series\"}"; + JSONObject o = new JSONObject(src); + Artwork a = new Artwork(o, MOCK_CLNT); + + assertEquals(135, a.getWidth()); + assertEquals(180, a.getHeight()); + assertTrue(a.getUri().endsWith("assets/p282288_b_v2_aa.jpg")); + assertTrue(a.getSize() == Size.SMALL); + assertEquals("3x4", a.getAspect()); + assertEquals("Banner-L3", a.getCategory()); + assertTrue(a.isText()); + assertEquals("Series", a.getTier()); + } + +} diff --git a/src/test/java/org/schedulesdirect/api/ProgramTest.java b/src/test/java/org/schedulesdirect/api/ProgramTest.java index bb54794..9676ee3 100644 --- a/src/test/java/org/schedulesdirect/api/ProgramTest.java +++ b/src/test/java/org/schedulesdirect/api/ProgramTest.java @@ -221,4 +221,15 @@ public void dontAllowDuplicateGenres() throws Exception { assertEquals(1, p.getGenres().length); assertEquals("Special", p.getGenres()[0]); } + + @Test + public void validateKeywords() throws Exception { + JSONObject src = getRandomSampleProgram(); + JSONObject keywords = new JSONObject(); + keywords.put("General", new JSONArray(new String[] {"General1", "General2"})); + src.put("keywords", keywords); + Program p = new Program(src, CLNT); + assertTrue(p.getKeywords().containsKey("General")); + assertEquals(2, p.getKeywords().get("General").size()); + } }