Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Next

## Version 9.2.16 (2025-11-20)
* Fix for lockouts with SD EPG images bring requested with wrong agent and missing token
* Further fixes to send 14 character program ids
* Add SD healthcheck and add system alert if user is blocked

## Version 9.2.15 (2025-09-16)
* Fix for lockouts with SD EPG including better handling when run as service and using clients
* Ensure SD is sent 14 character program IDs
Expand Down
5 changes: 4 additions & 1 deletion i18n/SageTVCoreTranslations.properties
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,6 @@ DISKSPACE_INADEQUATE_ERR_MSG=Your system is out of diskspace for recording TV. S
SYSTEM_LOCKUP_DETECTION=System Lockup Detected
OUT_OF_MEMORY=Out of Memory Detected
OUT_OF_MEMORY_MSG=SageTV has detected that it has run out of memory. You may need to increase your JVM heap size or there could be a plugin/customization that is causing an issue.
SOFTWARE_UPDATE_AVAILABLE=Software Updates Available
PLUGIN_UPDATE_AVAILABLE_MSG=There is an update available of the plugin "{0}" to version {1}.
STORAGE_MONITOR=Storage Monitor
STORAGE_MONITOR_MSG=A degraded state has been detected for the RAID device "{0}" due to a hard disk failure. Please use the server Administrator console from your web browser to diagnose and resolve the problem.
Expand Down Expand Up @@ -483,6 +482,10 @@ LINEUP_SD_ACCOUNT_EXPIRED=Schedules Direct Account Expired
LINEUP_SD_ACCOUNT_EXPIRED_MSG=The Schedules Direct account has expired. Please renew the account subscription to continue to receive EPG updates.
LINEUP_SD_ACCOUNT_LOCKOUT=Schedules Direct Account Locked
LINEUP_SD_ACCOUNT_LOCKOUT_MSG=The Schedules Direct account is locked. There have been too many login failures. The account will unlock again in 15 minutes.
LINEUP_SD_ACCOUNT_BLOCKED=Schedules Direct Account Blocked
LINEUP_SD_ACCOUNT_BLOCKED_MSG=The Schedules Direct account is blocked. Please contact Schedules Direct to resolve this issue.
SOFTWARE_UPDATE_AVAILABLE=Software update available
SOFTWARE_UPDATE_AVAILABLE_MSG=New version available on Github: "{0}". Go to: "{1}" to check it out.
Team=Team
Guest_Voice=Guest Voice
Anchor=Anchor
Expand Down
33 changes: 29 additions & 4 deletions java/sage/MetaImage.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@
import java.awt.Shape;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
Expand All @@ -52,8 +50,8 @@
import java.util.Vector;
import java.util.WeakHashMap;
import sage.epg.sd.SDRipper;
import sage.epg.sd.SDSageSession;
import sage.epg.sd.SDSession;
import sage.epg.sd.SDUtils;

/*
* NOTE: DON'T DO THUMBNAIL GENERATION IN HERE. WE WANT TO STORE THEM IN SEPARATE FILES
Expand Down Expand Up @@ -3218,6 +3216,7 @@ private boolean loadCacheFile()
HttpURLConnection.setFollowRedirects(true);
URL myURL = (URL) src;
URLConnection myURLConn;
boolean isSDURL = false; //identifies an Schedules Direct image that needs special handling
try
{
while (true)
Expand All @@ -3227,6 +3226,7 @@ private boolean loadCacheFile()
myURLConn.setConnectTimeout(30000);
myURLConn.setReadTimeout(30000);
if(src.toString().startsWith(SDSession.URL_VERSIONED)){
isSDURL = true;
//this is an SD supplied image so a token is required to retreive it
if (Sage.DBG) System.out.println("MetaImage.loadCacheFile: Found SD image url. src = '" + src + "'");

Expand All @@ -3245,8 +3245,9 @@ private boolean loadCacheFile()
if (Sage.DBG) System.out.println("MetaImage.loadCacheFile: No token so skipping src = '" + src + "'");
break;
}else{
if (Sage.DBG) System.out.println("MetaImage.loadCacheFile: Adding token to Request. token = '" + sdToken + "'");
if (Sage.DBG && SDSession.debugEnabled()) System.out.println("MetaImage.loadCacheFile: Adding token to Request. token = '" + sdToken + "'");
myURLConn.addRequestProperty("token", sdToken);
myURLConn.setRequestProperty("User-Agent", SDSession.USER_AGENT);
//secret SD debug mode that will send requests to their debug server. Only enable when working with SD Support
if(Sage.getBoolean("debug_sd_support", false)) {
myURLConn.addRequestProperty("RouteTo", "debug");
Expand All @@ -3259,6 +3260,30 @@ private boolean loadCacheFile()
if (myURLConn instanceof HttpURLConnection)
{
HttpURLConnection httpConn = (HttpURLConnection) myURLConn;
if (isSDURL) {
if ("application/json".equals(httpConn.getContentType())){
//SD returned an error which is within the returned json
int imageErrorCode = SDUtils.handleSDJsonErrorFromHttpResponse(httpConn);
if(imageErrorCode==1004){ //no or invalid token
is.close();
is = null;
break;
}else if(imageErrorCode==5000){ //image does not exist - do not ask again
//create a blank image to store in cache so next request returns the blank rather than asking for an image that does not exist in SD
if (Sage.DBG) System.out.println("MetaImage.loadCacheFile: error 5000 - SD image does not exist - creating a blank image for the cache to avoid re-requesting");
is = SDUtils.createBlankImageInputStream(270,360,"jpg");
break;
}else if(imageErrorCode==5002 || imageErrorCode==5003){ //5002-max downloads, 5003-max downloads trial
is.close();
is = null;
break;
}else{
is.close();
is = null;
break;
}
}
}
if (httpConn.getResponseCode() / 100 == 3)
{
if (Sage.DBG) System.out.println("Internally processing HTTP redirect...");
Expand Down
2 changes: 1 addition & 1 deletion java/sage/Version.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class Version
{
public static final byte MAJOR_VERSION = 9;
public static final byte MINOR_VERSION = 2;
public static final byte MICRO_VERSION = 15;
public static final byte MICRO_VERSION = 16;

public static final String VERSION = MAJOR_VERSION + "." + MINOR_VERSION + "." + MICRO_VERSION + "." + SageConstants.BUILD_VERSION;

Expand Down
13 changes: 11 additions & 2 deletions java/sage/epg/sd/SDErrors.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package sage.epg.sd;

import java.io.IOException;
import sage.Sage;
import sage.SageTV;

public enum SDErrors
{
Expand Down Expand Up @@ -56,6 +56,7 @@ public enum SDErrors
SAGETV_TOKEN_RETURN_MISSING(-2000 /*, "Schedules Direct did not return a valid token."*/),
SAGETV_SERVICE_MISSING(-2001 /*, "The requested service is not currently available from Schedules Direct."*/),
SAGETV_NO_PASSWORD(-2002 /*, "A username and password have not been provided to connect to Schedules Direct."*/),
SD_ACCOUNT_BLOCKED(-2003 /*, "Schedules Direct account is blocked. Contact SD to resolve."*/),
SAGETV_UNKNOWN(-9999 /*, "Unknown error to SageTV."*/);

public final int CODE;
Expand Down Expand Up @@ -107,15 +108,23 @@ public static SDErrors getErrorForName(String name)
* @param code The code to look up and throw.
* @throws SDException Always thrown by this method.
*/
public static void throwErrorForCode(int code) throws SDException
public static void throwErrorForCode(int code) throws SDException, IOException
{
// We don't know why this is an error when the code is 0, but this method was called, so we will
// throw an error.
if (Sage.DBG) System.out.println("SDErrors: code:" + code + " : " + getErrorForCode(code).name());
if (code != 0)
{
//do an extra check to see if the error is due to the account being blocked
//if(SDUtils.isSDBlocked()){
//this will indicate the account is blocked
// throw new SDException(getErrorForCode(-2003));
//}

for (SDErrors error : SDErrors.values())
{
if (code == error.CODE){
if (Sage.DBG) System.out.println("SDErrors: THROWING code:" + code + " : " + getErrorForCode(code).name());
throw new SDException(error);
}
}
Expand Down
34 changes: 21 additions & 13 deletions java/sage/epg/sd/SDRipper.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
import static sage.epg.sd.SDErrors.SAGETV_UNKNOWN;
import static sage.epg.sd.SDErrors.SERVICE_OFFLINE;
import static sage.epg.sd.SDErrors.TOO_MANY_LOGINS;
import static sage.epg.sd.SDSession.debugEnabled;

public class SDRipper extends EPGDataSource
{
Expand Down Expand Up @@ -242,11 +243,10 @@ private static SDSession openNewSession() throws IOException, SDException
propUsername = serverProp.toString();
serverProp = SageTV.api("GetServerProperty", new Object[] { PROP_PASSWORD, null });
propPassword = serverProp.toString();
//if (Sage.DBG) System.out.println("***EPG*** getting from CLIENT user/pass: username:" + username + " password:" + password);
}
catch (Throwable t)
{
if (Sage.DBG) System.out.println("ERROR executing server API call of:" + t);
if (Sage.DBG) System.out.println("SDRipper:openNewSession: ERROR executing server API call of:" + t);
t.printStackTrace();
propUsername = null;
propPassword = null;
Expand All @@ -256,10 +256,7 @@ private static SDSession openNewSession() throws IOException, SDException
propPassword = Sage.get(PROP_PASSWORD, null);
}

//if (Sage.DBG) System.out.println("***EPG*** checking for user/pass: username:" + username + " password:" + password);

//get user/pass from old sdauth file
//if (Sage.DBG) System.out.println("***EPG*** reading user/pass from old file");
File authFile = new File(AUTH_FILE);
if (!authFile.exists() || authFile.length() == 0)
throw new SDException(SDErrors.SAGETV_NO_PASSWORD);
Expand All @@ -270,14 +267,14 @@ private static SDSession openNewSession() throws IOException, SDException
String auth = reader.readLine();
if (auth == null)
{
if (Sage.DBG) System.out.println("SDEPG Error: sdauth file is empty.");
if (Sage.DBG) System.out.println("SDRipper:openNewSession: Error: sdauth file is empty.");
throw new SDException(SDErrors.SAGETV_NO_PASSWORD);
}
int split = auth.indexOf(' ');
// If the file is not formatted correctly, it's as good as not existing.
if (split == -1)
{
if (Sage.DBG) System.out.println("SDEPG Error: sdauth file is missing a space between the username and password.");
if (Sage.DBG) System.out.println("SDRipper:openNewSession: Error: sdauth file is missing a space between the username and password.");
throw new SDException(SDErrors.SAGETV_NO_PASSWORD);
}

Expand Down Expand Up @@ -307,9 +304,8 @@ private static SDSession openNewSession() throws IOException, SDException
// This will throw an exception if there are any issues connecting.
returnValue = new SDSageSession(propUsername, propPassword);
authenticated = true;
if (Sage.DBG) System.out.println("SDEPG authenticated using prop based user/pass PASSED: username:" + propUsername + " password:" + propPassword);
} catch (Exception e) {
if (Sage.DBG) System.out.println("SDEPG checking for prop based user/pass FAILED: username:" + propUsername + " password:" + propPassword);
if (Sage.DBG) System.out.println("SDRipper:openNewSession: Checking for prop based user/pass FAILED");
}
}

Expand All @@ -321,22 +317,21 @@ private static SDSession openNewSession() throws IOException, SDException
authenticated = true;
Sage.put(PROP_USERNAME, fileUsername);
Sage.put(PROP_PASSWORD, filePassword);
if (Sage.DBG) System.out.println("SDEPG authenticated using file based user/pass PASSED: username:" + fileUsername + " password:" + filePassword);
} catch (Exception e) {
if (Sage.DBG) System.out.println("SDEPG checking for file based user/pass FAILED: username:" + fileUsername + " password:" + filePassword);
if (Sage.DBG) System.out.println("SDRipper:openNewSession: Checking for file based user/pass FAILED");
}
}

//we have now have tried both prop and file based user/pass
if(!authenticated){
if (Sage.DBG) System.out.println("SDEPG ERROR: checking for BOTH user/pass FAILED: throwing SAGETV_NO_PASSWORD");
if (Sage.DBG) System.out.println("SDRipper:openNewSession: ERROR: checking for BOTH user/pass FAILED: throwing SAGETV_NO_PASSWORD");
throw new SDException(SDErrors.SAGETV_NO_PASSWORD);
}

// We have just successfully authenticated, so this needs to be cleared so that updates can
// start immediately.
SDRipper.retryWait = 0;
if (Sage.DBG) System.out.println("SDEPG Successfully got token: " + returnValue.token);
if (Sage.DBG&& debugEnabled()) System.out.println("SDRipper:openNewSession: Successfully got token: " + returnValue.token);
return returnValue;
}

Expand Down Expand Up @@ -2953,6 +2948,19 @@ public static boolean isAvailable()
if (Sage.DBG) System.out.println("SDEPG Unable to use the Schedules Direct service at this time.");
return false;
}

try {
//2025-11-14 add SD Health check to avoid running if SD has the account blocked
if(SDUtils.isSDBlocked()){
if (Sage.DBG) System.out.println("SDEPG Account is blocked: Unable to use the Schedules Direct service at this time.");
return false;
} } catch (IOException ex) {
if (Sage.DBG) System.out.println("SDEPG Account is blocked: Unable to use the Schedules Direct service at this time. " + ex.getMessage());
return false;
} catch (SDException ex) {
if (Sage.DBG) System.out.println("SDEPG Account is blocked: Unable to use the Schedules Direct service at this time. " + ex.getMessage());
return false;
}

try
{
Expand Down
1 change: 0 additions & 1 deletion java/sage/epg/sd/SDSageSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.URL;
import java.util.Random;

public class SDSageSession extends SDSession
{
Expand Down
34 changes: 19 additions & 15 deletions java/sage/epg/sd/SDSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.logging.Level;
import java.util.logging.Logger;

public abstract class SDSession
{
Expand All @@ -85,7 +83,7 @@ public abstract class SDSession
// The character set to be used for outgoing communications.
protected static final Charset OUT_CHARSET = StandardCharsets.UTF_8;
// The expected character set to be used for incoming communications.
protected static final Charset IN_CHARSET = StandardCharsets.ISO_8859_1;
public static final Charset IN_CHARSET = StandardCharsets.ISO_8859_1;

// These are set in the static constructor because they can throw format exceptions.
// Returns a token if the credentials are valid.
Expand All @@ -95,7 +93,9 @@ public abstract class SDSession
// Get the current account status/saved lineups.
private static final URL GET_STATUS;
// Get a list of available services.
private static final URL GET_AVAILABLE;
public static final URL GET_AVAILABLE;
// Get weather the accounts ip is blocked.
public static final URL GET_IS_BLOCKED;
// Get the lineups associated with the authenticated account.
private static final URL GET_LINEUPS;
// Get requested guide data for specific programs.
Expand All @@ -116,6 +116,7 @@ public abstract class SDSession
URL newGetTokenCurrent;
URL newGetStatus;
URL newGetAvailable;
URL newGetIsBlocked;
URL newGetLineups;
URL newGetPrograms;
URL newGetSeriesDesc;
Expand All @@ -129,6 +130,7 @@ public abstract class SDSession
newGetTokenCurrent = new URL(URL_VERSIONED + "/token/current");
newGetStatus = new URL(URL_VERSIONED + "/status");
newGetAvailable = new URL(URL_VERSIONED + "/available");
newGetIsBlocked = new URL(URL_VERSIONED + "/ip_isblocked");
newGetLineups = new URL(URL_VERSIONED + "/lineups");
newGetPrograms = new URL(URL_VERSIONED + "/programs");
newGetSeriesDesc = new URL(URL_VERSIONED + "/metadata/description/");
Expand All @@ -146,6 +148,7 @@ public abstract class SDSession
newGetTokenCurrent = null;
newGetStatus = null;
newGetAvailable = null;
newGetIsBlocked = null;
newGetLineups = null;
newGetPrograms = null;
newGetSeriesDesc = null;
Expand All @@ -158,6 +161,7 @@ public abstract class SDSession
GET_TOKEN_CURRENT = newGetTokenCurrent;
GET_STATUS = newGetStatus;
GET_AVAILABLE = newGetAvailable;
GET_IS_BLOCKED = newGetIsBlocked;
GET_LINEUPS = newGetLineups;
GET_PROGRAMS = newGetPrograms;
GET_SERIES_DESC = newGetSeriesDesc;
Expand Down Expand Up @@ -384,8 +388,6 @@ private static String debugDateTime(){
*/
public synchronized void authenticate() throws IOException, SDException
{

//if(Sage.DBG) System.out.println("SDSession/authenticate: checking existing token:" + token + " with expiry:" + tokenExpiration + " against System:" + (System.currentTimeMillis()/1000));

// The token is still valid.
if (System.currentTimeMillis()/1000 < tokenExpiration && token != null)
Expand All @@ -397,6 +399,10 @@ public synchronized void authenticate() throws IOException, SDException
// Set the token to null so if we are getting a new token, it doesn't send the old token along
// for the authentication request.
token = null;
if(SDUtils.isSDBlocked()){
if(Sage.DBG) System.out.println("SDSession/authenticate: Account is blocked. Contact SD to resolve.");
return;
}

JsonObject authRequest = new JsonObject();
authRequest.addProperty("username", username);
Expand Down Expand Up @@ -952,7 +958,9 @@ public SDProgram[] getPrograms(Collection<String> programs) throws IOException,
JsonArray submit = new JsonArray();
for (String program : programs)
{
submit.add(SDUtils.fromSageTVtoProgram(program));
if(SDUtils.isValidProgramID(program)){
submit.add(SDUtils.fromSageTVtoProgram(program));
}
}

SDProgram[] returnValues = postAuthJson(GET_PROGRAMS, SDProgram[].class, submit);
Expand Down Expand Up @@ -1104,16 +1112,12 @@ public SDProgramImages[] getProgramImages(String[] programs) throws IOException,
JsonArray submit = new JsonArray();
for (String program : programs)
{
//08-12-2025 jusjoken - convert program to 14 chars needed by SD
program = SDUtils.fromSageTVtoProgram(program);
//08-12-2025 jusjoken - the below should no longer be required - remove after testing
//03-01-2025 jusjoken: added validation for program ids
//first check if its already formated correctly
if(SDUtils.isValidShortProgramID(program)){
if(SDUtils.isValidProgramID(program)){
//08-12-2025 jusjoken - convert program to 14 chars needed by SD
program = SDUtils.fromSageTVtoProgram(program);
submit.add(program);
}else if(SDUtils.isValidProgramID(program)){ //valid BUT is not shortend to 10
submit.add(program); //submit as is as SD now handles the 14 character program ids as well
//submit.add(program.substring(0, 10));
}else{
if (Sage.DBG) System.out.println("getProgramImages: INVALID program ID - SKIPPING:" + program);
}
Expand Down Expand Up @@ -1264,7 +1268,7 @@ public SDInProgressSport getInProgressSport(String programId) throws IOException
if (programId == null || programId.length() == 0)
return null;

if (programId.length() == 12)
if (SDUtils.isValidProgramID(programId))
programId = SDUtils.fromSageTVtoProgram(programId);

// A token is now required to perform this lookup.
Expand Down
Loading