├── .gitignore ├── settings.gradle.kts ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── LICENSE ├── src ├── main │ ├── java │ │ └── com │ │ │ └── kvaster │ │ │ ├── iptv │ │ │ ├── xmltv │ │ │ │ ├── XmltvIcon.java │ │ │ │ ├── XmltvRating.java │ │ │ │ ├── XmltvText.java │ │ │ │ ├── XmltvChannel.java │ │ │ │ ├── XmltvDoc.java │ │ │ │ ├── XmltvProgramme.java │ │ │ │ └── XmltvUtils.java │ │ │ ├── RequestCounter.java │ │ │ ├── m3u │ │ │ │ ├── M3uDoc.java │ │ │ │ ├── M3uChannel.java │ │ │ │ └── M3uParser.java │ │ │ ├── App.java │ │ │ ├── config │ │ │ │ ├── IptvConnectionConfig.java │ │ │ │ ├── IptvProxyConfig.java │ │ │ │ └── IptvServerConfig.java │ │ │ ├── ConfigLoader.java │ │ │ ├── HttpUtils.java │ │ │ ├── FileLoader.java │ │ │ ├── BaseUrl.java │ │ │ ├── IptvChannel.java │ │ │ ├── SpeedMeter.java │ │ │ ├── IptvServer.java │ │ │ ├── IptvUser.java │ │ │ ├── AsyncLoader.java │ │ │ ├── IptvStream.java │ │ │ ├── IptvProxyService.java │ │ │ └── IptvServerChannel.java │ │ │ └── utils │ │ │ ├── digest │ │ │ └── Digest.java │ │ │ └── serialize │ │ │ ├── WrappedDeserializer.java │ │ │ └── RelativeFileModule.java │ └── resources │ │ └── logback.xml └── test │ └── java │ └── com │ └── kvaster │ └── iptv │ ├── m3u │ └── TestM3u.java │ └── xmltv │ └── TestXmlTv.java ├── gradlew.bat ├── README.md └── gradlew /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .gradle 3 | build 4 | tmp 5 | out 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "iptv-proxy" 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kvaster/iptv-proxy/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This copy of iptv proxy service is licensed under the 2 | Apache (Software) License, version 2.0 ("the License"). 3 | See the License for details about distribution rights, and the 4 | specific rights regarding derivate works. 5 | 6 | You may obtain a copy of the License at: 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/xmltv/XmltvIcon.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.xmltv; 2 | 3 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; 4 | 5 | public class XmltvIcon { 6 | @JacksonXmlProperty(isAttribute = true) 7 | String src; 8 | 9 | public XmltvIcon() { 10 | } 11 | 12 | public XmltvIcon(String src) { 13 | this.src = src; 14 | } 15 | 16 | public String getSrc() { 17 | return src; 18 | } 19 | 20 | public XmltvIcon setSrc(String src) { 21 | this.src = src; 22 | return this; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/java/com/kvaster/iptv/m3u/TestM3u.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.m3u; 2 | 3 | import java.nio.file.Files; 4 | import java.nio.file.Path; 5 | 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | 9 | public class TestM3u { 10 | private static final Logger LOG = LoggerFactory.getLogger(TestM3u.class); 11 | 12 | public static void main(String[] args) { 13 | try { 14 | String path = "../ilook.m3u8"; 15 | 16 | String content = Files.readString(Path.of(path)); 17 | 18 | M3uDoc doc = M3uParser.parse(content); 19 | } catch (Exception e) { 20 | LOG.error("error", e); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/RequestCounter.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.util.concurrent.atomic.AtomicInteger; 4 | 5 | import org.slf4j.LoggerFactory; 6 | 7 | /** 8 | * Created by kva on 14:02 20.01.2020 9 | */ 10 | public class RequestCounter { 11 | private static final boolean isDebug = LoggerFactory.getLogger(RequestCounter.class).isDebugEnabled(); 12 | 13 | private static final AtomicInteger counter = new AtomicInteger(); 14 | 15 | public static String next() { 16 | if (isDebug) { 17 | int c = counter.incrementAndGet() % 100000; 18 | return String.format("%05d| ", c); 19 | } else { 20 | return ""; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/m3u/M3uDoc.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.m3u; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | 6 | public class M3uDoc { 7 | private final List channels; 8 | 9 | private final Map props; 10 | 11 | public M3uDoc(List channels, Map props) { 12 | this.channels = channels; 13 | this.props = props; 14 | } 15 | 16 | public List getChannels() { 17 | return channels; 18 | } 19 | 20 | public Map getProps() { 21 | return props; 22 | } 23 | 24 | public String getProp(String key, String value) { 25 | return props.get(key); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/xmltv/XmltvRating.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.xmltv; 2 | 3 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; 4 | 5 | public class XmltvRating { 6 | @JacksonXmlProperty(isAttribute = true) 7 | private String system; 8 | 9 | private String value; 10 | 11 | public XmltvRating() { 12 | } 13 | 14 | public XmltvRating(String system, String value) { 15 | this.system = system; 16 | this.value = value; 17 | } 18 | 19 | public String getSystem() { 20 | return system; 21 | } 22 | 23 | public XmltvRating setSystem(String system) { 24 | this.system = system; 25 | return this; 26 | } 27 | 28 | public XmltvRating setValue(String value) { 29 | this.value = value; 30 | return this; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/App.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.io.File; 4 | 5 | import com.kvaster.iptv.config.IptvProxyConfig; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | 9 | public class App { 10 | private static final Logger LOG = LoggerFactory.getLogger(App.class); 11 | 12 | public static void main(String[] args) { 13 | try { 14 | LOG.info("loading config..."); 15 | 16 | File configFile = new File(System.getProperty("config", "config.yml")); 17 | 18 | IptvProxyConfig config = ConfigLoader.loadConfig(configFile, IptvProxyConfig.class); 19 | 20 | IptvProxyService service = new IptvProxyService(config); 21 | 22 | Runtime.getRuntime().addShutdownHook(new Thread(service::stopService)); 23 | service.startService(); 24 | } catch (Exception e) { 25 | LOG.error("fatal error", e); 26 | System.exit(1); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/m3u/M3uChannel.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.m3u; 2 | 3 | import java.util.Map; 4 | import java.util.Set; 5 | 6 | public class M3uChannel { 7 | private final String url; 8 | 9 | private final String name; 10 | 11 | private final Set groups; 12 | 13 | private final Map props; 14 | 15 | public M3uChannel(String url, String name, Set groups, Map props) { 16 | this.url = url; 17 | this.name = name; 18 | this.groups = groups; 19 | this.props = props; 20 | } 21 | 22 | public String getUrl() { 23 | return url; 24 | } 25 | 26 | public String getName() { 27 | return name; 28 | } 29 | 30 | public Set getGroups() { 31 | return groups; 32 | } 33 | 34 | public String getProp(String key) { 35 | return props.get(key); 36 | } 37 | 38 | public Map getProps() { 39 | return props; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/xmltv/XmltvText.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.xmltv; 2 | 3 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; 4 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText; 5 | 6 | public class XmltvText { 7 | @JacksonXmlProperty(isAttribute = true, localName = "lang") 8 | private String language; 9 | 10 | @JacksonXmlText 11 | private String text; 12 | 13 | public XmltvText() { 14 | } 15 | 16 | public XmltvText(String text) { 17 | this(text, null); 18 | } 19 | 20 | public XmltvText(String text, String language) { 21 | this.text = text; 22 | this.language = language; 23 | } 24 | 25 | public String getText() { 26 | return text; 27 | } 28 | 29 | public XmltvText setText(String text) { 30 | this.text = text; 31 | return this; 32 | } 33 | 34 | public String getLanguage() { 35 | return language; 36 | } 37 | 38 | public XmltvText setLanguage(String language) { 39 | this.language = language; 40 | return this; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/test/java/com/kvaster/iptv/xmltv/TestXmlTv.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.xmltv; 2 | 3 | import java.io.File; 4 | 5 | import com.fasterxml.jackson.dataformat.xml.XmlMapper; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | 9 | public class TestXmlTv { 10 | private static final Logger LOG = LoggerFactory.getLogger(TestXmlTv.class); 11 | 12 | public static void main(String[] args) { 13 | try { 14 | XmlMapper xm = XmltvUtils.createMapper(); 15 | 16 | XmltvDoc doc = xm.readValue(new File("/home/kva/projects/kvaster/iptv/epg-cbilling.xml"), XmltvDoc.class); 17 | //doc = xm.readValue(new File("/home/kva/projects/kvaster/iptv/epg-crdru.xml"), XmltvDoc.class); 18 | doc = xm.readValue(new File("/home/kva/projects/kvaster/iptv/epg-ilooktv.xml"), XmltvDoc.class); 19 | //xm.writerWithDefaultPrettyPrinter().writeValue(new File("out.xml"), doc); 20 | xm.writeValue(new File("tmp/out.xml"), doc); 21 | LOG.info("done"); 22 | } catch (Exception e) { 23 | LOG.error("error", e); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/config/IptvConnectionConfig.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.config; 2 | 3 | public class IptvConnectionConfig { 4 | private String url; 5 | private int maxConnections; 6 | private String login; 7 | private String password; 8 | 9 | protected IptvConnectionConfig() { 10 | } 11 | 12 | public String getUrl() { 13 | return url; 14 | } 15 | 16 | public int getMaxConnections() { 17 | return maxConnections; 18 | } 19 | 20 | public String getLogin() { 21 | return login; 22 | } 23 | 24 | public String getPassword() { 25 | return password; 26 | } 27 | 28 | public static class Builder { 29 | private final IptvConnectionConfig c = new IptvConnectionConfig(); 30 | 31 | public IptvConnectionConfig build() { 32 | return c; 33 | } 34 | 35 | public Builder url(String url) { 36 | c.url = url; 37 | return this; 38 | } 39 | 40 | public Builder maxConnections(int maxConnections) { 41 | c.maxConnections = maxConnections; 42 | return this; 43 | } 44 | 45 | public Builder login(String login) { 46 | c.login = login; 47 | return this; 48 | } 49 | 50 | public Builder password(String password) { 51 | c.password = password; 52 | return this; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/xmltv/XmltvChannel.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.xmltv; 2 | 3 | import java.util.Collections; 4 | import java.util.List; 5 | 6 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; 7 | 8 | public class XmltvChannel { 9 | @JacksonXmlProperty(isAttribute = true, localName = "id") 10 | private String id; 11 | 12 | @JacksonXmlProperty(localName = "display-name") 13 | private List displayNames; 14 | 15 | private XmltvIcon icon; 16 | 17 | public XmltvChannel() { 18 | } 19 | 20 | public XmltvChannel(String id, List displayNames, XmltvIcon icon) { 21 | this.id = id; 22 | this.displayNames = displayNames; 23 | this.icon = icon; 24 | } 25 | 26 | public XmltvChannel(String id, XmltvText displayName, XmltvIcon icon) { 27 | this(id, Collections.singletonList(displayName), icon); 28 | } 29 | 30 | public String getId() { 31 | return id; 32 | } 33 | 34 | public XmltvChannel setId(String id) { 35 | this.id = id; 36 | return this; 37 | } 38 | 39 | public List getDisplayNames() { 40 | return displayNames; 41 | } 42 | 43 | public XmltvChannel setDisplayNames(List displayNames) { 44 | this.displayNames = displayNames; 45 | return this; 46 | } 47 | 48 | public XmltvIcon getIcon() { 49 | return icon; 50 | } 51 | 52 | public XmltvChannel setIcon(XmltvIcon icon) { 53 | this.icon = icon; 54 | return this; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/utils/digest/Digest.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.utils.digest; 2 | 3 | import java.security.MessageDigest; 4 | 5 | public class Digest { 6 | private static final char[] HEX = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' }; 7 | 8 | private final MessageDigest md; 9 | 10 | public static Digest sha512() { 11 | return new Digest("SHA-512"); 12 | } 13 | 14 | public static String sha512(String str) { 15 | return sha512().digest(str); 16 | } 17 | 18 | public static Digest sha256() { 19 | return new Digest("SHA-256"); 20 | } 21 | 22 | public static String sha256(String str) { 23 | return sha256().digest(str); 24 | } 25 | 26 | public static Digest md5() { 27 | return new Digest("MD5"); 28 | } 29 | 30 | public static String md5(String str) { 31 | return md5().digest(str); 32 | } 33 | 34 | private Digest(String algorithm) { 35 | try { 36 | md = MessageDigest.getInstance(algorithm); 37 | } catch (Exception e) { 38 | throw new RuntimeException(e); 39 | } 40 | } 41 | 42 | public String digest(String str) { 43 | md.update(str.getBytes()); 44 | byte[] digest = md.digest(); 45 | md.reset(); 46 | return toHex(digest); 47 | } 48 | 49 | private static String toHex(byte[] digest) { 50 | StringBuilder sb = new StringBuilder(digest.length * 2); 51 | for (byte b : digest) { 52 | sb.append(HEX[(b & 0xf0) >> 4]).append(HEX[b & 0x0f]); 53 | } 54 | 55 | return sb.toString(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | shadow = "8.1.1" 3 | versions = "0.51.0" 4 | versionsFilter = "0.1.16" 5 | 6 | slf4j = "2.0.16" 7 | logback = "1.5.12" 8 | janino = "3.1.12" 9 | 10 | undertow = "2.3.18.Final" 11 | snakeYaml = "2.3" 12 | jackson = "2.18.2" 13 | 14 | [plugins] 15 | shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" } 16 | versions = { id = "com.github.ben-manes.versions", version.ref = "versions" } 17 | versionsFilter = { id = "se.ascp.gradle.gradle-versions-filter", version.ref = "versionsFilter" } 18 | 19 | [libraries] 20 | slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } 21 | slf4j-log4j = { module = "org.slf4j:log4j-over-slf4j", version.ref = "slf4j" } 22 | slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } 23 | slf4j-jcl = { module = "org.slf4j:jcl-over-slf4j", version.ref = "slf4j" } 24 | logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } 25 | janino = { module = "org.codehaus.janino:janino", version.ref = "janino" } 26 | 27 | undertow = { module = "io.undertow:undertow-core", version.ref = "undertow" } 28 | snakeyaml = { module = "org.yaml:snakeyaml", version.ref = "snakeYaml" } 29 | jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } 30 | jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } 31 | jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } 32 | jackson-dataformat-xml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-xml", version.ref = "jackson" } 33 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/ConfigLoader.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.io.File; 4 | 5 | import com.fasterxml.jackson.annotation.JsonAutoDetect; 6 | import com.fasterxml.jackson.databind.MapperFeature; 7 | import com.fasterxml.jackson.databind.ObjectMapper; 8 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 9 | import com.fasterxml.jackson.databind.introspect.VisibilityChecker; 10 | import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; 11 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 12 | import com.kvaster.utils.serialize.RelativeFileModule; 13 | 14 | public class ConfigLoader { 15 | public static T loadConfig(File configFile, Class configClass) { 16 | try { 17 | ObjectMapper mapper = YAMLMapper.builder() 18 | .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) 19 | .visibility(new VisibilityChecker.Std(JsonAutoDetect.Visibility.NONE, JsonAutoDetect.Visibility.NONE, JsonAutoDetect.Visibility.NONE, JsonAutoDetect.Visibility.ANY, JsonAutoDetect.Visibility.ANY)) 20 | .configure(MapperFeature.AUTO_DETECT_GETTERS, false) 21 | .configure(MapperFeature.AUTO_DETECT_IS_GETTERS, false).configure(MapperFeature.AUTO_DETECT_SETTERS, false) 22 | .configure(MapperFeature.AUTO_DETECT_SETTERS, false) 23 | .addModules(new RelativeFileModule(configFile.getParentFile()), new JavaTimeModule()) 24 | .build(); 25 | 26 | return mapper.readValue(configFile, configClass); 27 | } catch (Exception e) { 28 | throw new RuntimeException(e); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ${log.dir:-.}/iptv-proxy.log 9 | 10 | ${log.dir:-.}/iptv-proxy.log.%d{yyyy-MM-dd}.gz 11 | 30 12 | 13 | 14 | 15 | %d{dd.MM.yyyy HH:mm:ss} [%-5level] :: %c{0} :: %m%n%ex 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | %d{dd.MM.yyyy HH:mm:ss} [%-5level] :: %c{0} :: %m%n%ex 35 | UTF-8 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/HttpUtils.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.net.HttpURLConnection; 4 | import java.net.http.HttpResponse; 5 | import java.util.concurrent.TimeUnit; 6 | import java.util.concurrent.TimeoutException; 7 | 8 | import io.undertow.server.HttpServerExchange; 9 | import io.undertow.util.HttpString; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | 13 | public class HttpUtils { 14 | private static final Logger LOG = LoggerFactory.getLogger(HttpUtils.class); 15 | 16 | public static HttpString ACCESS_CONTROL = new HttpString("Access-Control-Allow-Origin"); 17 | 18 | public static boolean isOk(HttpResponse resp, Throwable err, String rid, long startNanos) { 19 | return isOk(resp, err, null, rid, startNanos); 20 | } 21 | 22 | public static boolean isOk(HttpResponse resp, Throwable err, HttpServerExchange exchange, String rid, long startNanos) { 23 | if (resp == null) { 24 | String errMsg = (err == null || err instanceof TimeoutException) ? "timeout" : (err.getMessage() == null ? err.toString() : err.getMessage()); 25 | LOG.warn(rid + "io error: {}", errMsg); 26 | if (exchange != null) { 27 | exchange.setStatusCode(HttpURLConnection.HTTP_INTERNAL_ERROR); 28 | exchange.getResponseSender().send("error"); 29 | } 30 | return false; 31 | } else if (resp.statusCode() != HttpURLConnection.HTTP_OK) { 32 | LOG.warn(rid + "bad status code: {}", resp.statusCode()); 33 | if (exchange != null) { 34 | exchange.setStatusCode(resp.statusCode()); 35 | exchange.getResponseSender().send("error"); 36 | } 37 | return false; 38 | } else { 39 | LOG.debug("{}ok ({}ms)", rid, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos)); 40 | } 41 | 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/utils/serialize/WrappedDeserializer.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.utils.serialize; 2 | 3 | import java.io.IOException; 4 | 5 | import com.fasterxml.jackson.core.JsonParser; 6 | import com.fasterxml.jackson.databind.DeserializationContext; 7 | import com.fasterxml.jackson.databind.JsonDeserializer; 8 | import com.fasterxml.jackson.databind.JsonMappingException; 9 | import com.fasterxml.jackson.databind.deser.ResolvableDeserializer; 10 | import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; 11 | 12 | import static java.util.Objects.requireNonNull; 13 | 14 | public class WrappedDeserializer extends JsonDeserializer implements ResolvableDeserializer { 15 | private JsonDeserializer deserializer; 16 | 17 | public WrappedDeserializer(JsonDeserializer deserializer) { 18 | this.deserializer = requireNonNull(deserializer); 19 | } 20 | 21 | @Override 22 | public T deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { 23 | return afterDeserialize(deserializer.deserialize(jp, ctxt)); 24 | } 25 | 26 | @Override 27 | public T deserialize(JsonParser jp, DeserializationContext ctxt, T intoValue) throws IOException { 28 | return afterDeserialize(deserializer.deserialize(jp, ctxt, intoValue)); 29 | } 30 | 31 | @SuppressWarnings("unchecked") 32 | @Override 33 | public Object deserializeWithType( 34 | JsonParser jp, DeserializationContext ctxt, TypeDeserializer typeDeserializer 35 | ) throws IOException { 36 | return afterDeserialize((T) deserializer.deserializeWithType(jp, ctxt, typeDeserializer)); 37 | } 38 | 39 | protected T afterDeserialize(T obj) { 40 | return obj; 41 | } 42 | 43 | @Override 44 | public void resolve(DeserializationContext ctxt) throws JsonMappingException { 45 | if (deserializer instanceof ResolvableDeserializer) { 46 | ((ResolvableDeserializer) deserializer).resolve(ctxt); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/FileLoader.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.io.IOException; 4 | import java.nio.file.Files; 5 | import java.nio.file.Path; 6 | import java.util.concurrent.CompletableFuture; 7 | 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | 11 | public class FileLoader { 12 | private static final Logger LOG = LoggerFactory.getLogger(FileLoader.class); 13 | 14 | private static final String FILE_SCHEME = "file://"; 15 | 16 | public static CompletableFuture tryLoadString(String url) { 17 | try { 18 | return complete(loadString(url)); 19 | } catch (Exception e) { 20 | return completeWithError(e); 21 | } 22 | } 23 | 24 | public static CompletableFuture tryLoadBytes(String url) { 25 | try { 26 | return complete(loadBytes(url)); 27 | } catch (Exception e) { 28 | return completeWithError(e); 29 | } 30 | } 31 | 32 | private static CompletableFuture complete(T value) { 33 | if (value == null) { 34 | return null; 35 | } 36 | 37 | var future = new CompletableFuture(); 38 | future.complete(value); 39 | return future; 40 | } 41 | 42 | private static CompletableFuture completeWithError(Throwable e) { 43 | var future = new CompletableFuture(); 44 | future.completeExceptionally(e); 45 | return future; 46 | } 47 | 48 | private static String loadString(String url) throws IOException { 49 | byte[] data = loadBytes(url); 50 | if (data == null) { 51 | return null; 52 | } 53 | return new String(data); 54 | } 55 | 56 | private static byte[] loadBytes(String url) throws IOException { 57 | if (url.startsWith(FILE_SCHEME)) { 58 | return Files.readAllBytes(Path.of(url.substring(FILE_SCHEME.length()))); 59 | } else { 60 | return null; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/BaseUrl.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import io.undertow.server.HttpServerExchange; 4 | 5 | public class BaseUrl { 6 | private final String baseUrl; 7 | private final String forwardedPass; 8 | private final String path; 9 | 10 | public BaseUrl(String baseUrl, String forwardedPass) { 11 | this(baseUrl, forwardedPass, ""); 12 | } 13 | 14 | private BaseUrl(String baseUrl, String forwardedPass, String path) { 15 | this.baseUrl = baseUrl; 16 | this.forwardedPass = forwardedPass; 17 | this.path = path; 18 | } 19 | 20 | public BaseUrl forPath(String path) { 21 | return new BaseUrl(baseUrl, forwardedPass, this.path + path); 22 | } 23 | 24 | public String getBaseUrl(HttpServerExchange exchange) { 25 | return getBaseUrlWithoutPath(exchange) + path; 26 | } 27 | 28 | private String getBaseUrlWithoutPath(HttpServerExchange exchange) { 29 | if (forwardedPass != null) { 30 | String fwd = exchange.getRequestHeaders().getFirst("Forwarded"); 31 | if (fwd != null) { 32 | String pass = null; 33 | String baseUrl = null; 34 | for (String pair : fwd.split(";")) { 35 | int idx = pair.indexOf('='); 36 | if (idx >= 0) { 37 | String key = pair.substring(0, idx); 38 | String value = pair.substring(idx + 1); 39 | if ("pass".equals(key)) { 40 | pass = value; 41 | } else if ("baseUrl".equals(key)) { 42 | baseUrl = value; 43 | } 44 | } 45 | } 46 | 47 | if (baseUrl != null && forwardedPass.equals(pass)) { 48 | return baseUrl; 49 | } 50 | } 51 | } 52 | 53 | if (baseUrl != null) { 54 | return baseUrl; 55 | } 56 | 57 | return exchange.getRequestScheme() + "://" + exchange.getHostAndPort(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/utils/serialize/RelativeFileModule.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.utils.serialize; 2 | 3 | import java.io.File; 4 | 5 | import com.fasterxml.jackson.core.Version; 6 | import com.fasterxml.jackson.databind.BeanDescription; 7 | import com.fasterxml.jackson.databind.DeserializationConfig; 8 | import com.fasterxml.jackson.databind.JsonDeserializer; 9 | import com.fasterxml.jackson.databind.Module; 10 | import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier; 11 | 12 | public class RelativeFileModule extends Module { 13 | private class RelativeFileDeserializer extends WrappedDeserializer { 14 | public RelativeFileDeserializer(JsonDeserializer deserializer) { 15 | super(deserializer); 16 | } 17 | 18 | @Override 19 | public File afterDeserialize(File file) { 20 | if (file.isAbsolute()) 21 | return file; 22 | 23 | return new File(base, file.getPath()); 24 | } 25 | } 26 | 27 | private class RealtiveFileDeserializerModifier extends BeanDeserializerModifier { 28 | @Override 29 | @SuppressWarnings("unchecked") 30 | public JsonDeserializer modifyDeserializer( 31 | DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer deserializer 32 | ) { 33 | if (beanDesc.getBeanClass().isAssignableFrom(File.class)) { 34 | return new RelativeFileDeserializer((JsonDeserializer)deserializer); 35 | } 36 | 37 | return deserializer; 38 | } 39 | } 40 | 41 | private final File base; 42 | 43 | public RelativeFileModule(File base) { 44 | this.base = base; 45 | } 46 | 47 | @Override 48 | public String getModuleName() 49 | { 50 | return getClass().getSimpleName(); 51 | } 52 | 53 | @Override 54 | public Version version() 55 | { 56 | return Version.unknownVersion(); 57 | } 58 | 59 | @Override 60 | public void setupModule(Module.SetupContext context) { 61 | context.addBeanDeserializerModifier(new RealtiveFileDeserializerModifier()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/IptvChannel.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collection; 5 | import java.util.Collections; 6 | import java.util.List; 7 | import java.util.Random; 8 | import java.util.Set; 9 | import java.util.TreeSet; 10 | 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | 14 | public class IptvChannel { 15 | private static final Logger LOG = LoggerFactory.getLogger(IptvChannel.class); 16 | 17 | private final String id; 18 | private final String name; 19 | 20 | private final String logo; 21 | private final Set groups; 22 | private final String xmltvId; 23 | private final int catchupDays; 24 | 25 | private final Random rand = new Random(); 26 | 27 | private final List serverChannels = new ArrayList<>(); 28 | 29 | public IptvChannel(String id, String name, String logo, Collection groups, String xmltvId, int catchupDays) { 30 | this.id = id; 31 | this.name = name; 32 | 33 | this.logo = logo; 34 | this.groups = Collections.unmodifiableSet(new TreeSet<>(groups)); 35 | this.xmltvId = xmltvId; 36 | this.catchupDays = catchupDays; 37 | } 38 | 39 | public String getId() { 40 | return id; 41 | } 42 | 43 | public String getName() { 44 | return name; 45 | } 46 | 47 | public String getLogo() { 48 | return logo; 49 | } 50 | 51 | public Set getGroups() { 52 | return groups; 53 | } 54 | 55 | public String getXmltvId() { 56 | return xmltvId; 57 | } 58 | 59 | public int getCatchupDays() { 60 | return catchupDays; 61 | } 62 | 63 | public void addServerChannel(IptvServerChannel serverChannel) { 64 | serverChannels.add(serverChannel); 65 | } 66 | 67 | public IptvServerChannel acquire(String userId) { 68 | List scs = new ArrayList<>(serverChannels); 69 | Collections.shuffle(scs, rand); 70 | 71 | for (IptvServerChannel sc : scs) { 72 | if (sc.acquire(userId)) { 73 | return sc; 74 | } 75 | } 76 | 77 | LOG.info("[{}] can't acquire channel: {}", userId, name); 78 | 79 | return null; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/SpeedMeter.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.util.concurrent.TimeUnit; 4 | 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | 8 | /** 9 | * Created by kva on 21:39 27.03.2020 10 | */ 11 | public class SpeedMeter { 12 | private static final Logger LOG = LoggerFactory.getLogger(SpeedMeter.class); 13 | 14 | private static final int KB = 1024; 15 | private static final int MB = 1024 * 1024; 16 | 17 | private final String rid; 18 | 19 | private final long time; 20 | private long bytes; 21 | 22 | private long partTime; 23 | private long partBytes; 24 | 25 | private long reqStartNanos; 26 | 27 | private static long getMonotonicMillis() { 28 | return TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); 29 | } 30 | 31 | public SpeedMeter(String rid, long reqStartNanos) { 32 | this.rid = rid; 33 | this.time = this.partTime = getMonotonicMillis(); 34 | this.reqStartNanos = reqStartNanos; 35 | } 36 | 37 | public void processed(long len) { 38 | if (bytes == 0) { 39 | LOG.debug("{}start", rid); 40 | } 41 | 42 | bytes += len; 43 | partBytes += len; 44 | 45 | long now = getMonotonicMillis(); 46 | if ((now - partTime) > 1000) { 47 | logPart(); 48 | } 49 | } 50 | 51 | public void finish() { 52 | long now = getMonotonicMillis(); 53 | if ((now - partTime) > 1000) { 54 | logPart(); 55 | } 56 | 57 | LOG.debug("{}finished: {}, speed: {}/s, {}ms", 58 | rid, format(bytes), 59 | format(bytes * 1000 / (now - time)), 60 | TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - reqStartNanos)); 61 | } 62 | 63 | private void logPart() { 64 | long now = getMonotonicMillis(); 65 | long delta = Math.max(1, now - partTime); 66 | 67 | LOG.debug("{}progress: {} speed: {}/s", rid, format(partBytes), format(partBytes * 1000 / delta)); 68 | partTime = now; 69 | partBytes = 0; 70 | } 71 | 72 | private String format(long value) { 73 | if (value < KB) { 74 | return String.format("%db", value); 75 | } else if (value < MB) { 76 | return String.format("%.2fKb", (double)value / KB); 77 | } else { 78 | return String.format("%.2fMb", (double)value / MB); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/xmltv/XmltvDoc.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.xmltv; 2 | 3 | import java.util.List; 4 | 5 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; 6 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; 7 | 8 | @JacksonXmlRootElement(localName = "tv") 9 | public class XmltvDoc { 10 | @JacksonXmlProperty(isAttribute = true, localName = "generator-info-name") 11 | private String generatorName; 12 | 13 | @JacksonXmlProperty(isAttribute = true, localName = "generator-info-url") 14 | private String generatorUrl; 15 | 16 | @JacksonXmlProperty(isAttribute = true, localName = "source-info-url") 17 | private String sourceInfoUrl; 18 | 19 | @JacksonXmlProperty(isAttribute = true, localName = "source-info-name") 20 | private String sourceInfoName; 21 | 22 | @JacksonXmlProperty(isAttribute = true, localName = "source-info-logo") 23 | private String sourceInfoLogo; 24 | 25 | @JacksonXmlProperty(localName = "channel") 26 | private List channels; 27 | 28 | @JacksonXmlProperty(localName = "programme") 29 | private List programmes; 30 | 31 | public XmltvDoc() { 32 | } 33 | 34 | public XmltvDoc(List channels, List programmes) { 35 | this.channels = channels; 36 | this.programmes = programmes; 37 | } 38 | 39 | public List getChannels() { 40 | return channels; 41 | } 42 | 43 | public XmltvDoc setChannels(List channels) { 44 | this.channels = channels; 45 | return this; 46 | } 47 | 48 | public List getProgrammes() { 49 | return programmes; 50 | } 51 | 52 | public XmltvDoc setProgrammes(List programmes) { 53 | this.programmes = programmes; 54 | return this; 55 | } 56 | 57 | public String getGeneratorName() { 58 | return generatorName; 59 | } 60 | 61 | public XmltvDoc setGeneratorName(String generatorName) { 62 | this.generatorName = generatorName; 63 | return this; 64 | } 65 | 66 | public String getGeneratorUrl() { 67 | return generatorUrl; 68 | } 69 | 70 | public XmltvDoc setGeneratorUrl(String generatorUrl) { 71 | this.generatorUrl = generatorUrl; 72 | return this; 73 | } 74 | 75 | public String getSourceInfoUrl() { 76 | return sourceInfoUrl; 77 | } 78 | 79 | public XmltvDoc setSourceInfoUrl(String sourceInfoUrl) { 80 | this.sourceInfoUrl = sourceInfoUrl; 81 | return this; 82 | } 83 | 84 | public String getSourceInfoName() { 85 | return sourceInfoName; 86 | } 87 | 88 | public XmltvDoc setSourceInfoName(String sourceInfoName) { 89 | this.sourceInfoName = sourceInfoName; 90 | return this; 91 | } 92 | 93 | public String getSourceInfoLogo() { 94 | return sourceInfoLogo; 95 | } 96 | 97 | public XmltvDoc setSourceInfoLogo(String sourceInfoLogo) { 98 | this.sourceInfoLogo = sourceInfoLogo; 99 | return this; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/IptvServer.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.net.URI; 4 | import java.net.http.HttpClient; 5 | import java.net.http.HttpRequest; 6 | import java.util.Base64; 7 | import java.util.Objects; 8 | 9 | import com.kvaster.iptv.config.IptvConnectionConfig; 10 | import com.kvaster.iptv.config.IptvServerConfig; 11 | 12 | public class IptvServer { 13 | public static final String PROXY_USER_HEADER = "iptv-proxy-user"; 14 | 15 | private final IptvServerConfig sc; 16 | private final IptvConnectionConfig cc; 17 | 18 | private final HttpClient httpClient; 19 | 20 | private int acquired; 21 | 22 | public IptvServer(IptvServerConfig sc, IptvConnectionConfig cc, HttpClient httpClient) { 23 | this.sc = Objects.requireNonNull(sc); 24 | this.cc = Objects.requireNonNull(cc); 25 | this.httpClient = Objects.requireNonNull(httpClient); 26 | } 27 | 28 | public HttpClient getHttpClient() { 29 | return httpClient; 30 | } 31 | 32 | public String getName() { 33 | return sc.getName(); 34 | } 35 | 36 | public String getUrl() { 37 | return cc.getUrl(); 38 | } 39 | 40 | public boolean getSendUser() { 41 | return sc.getSendUser(); 42 | } 43 | 44 | public boolean getProxyStream() { 45 | return sc.getProxyStream(); 46 | } 47 | 48 | public long getChannelFailedMs() { 49 | return sc.getChannelFailedMs(); 50 | } 51 | 52 | public long getInfoTimeoutMs() { 53 | return sc.getInfoTimeoutMs(); 54 | } 55 | 56 | public long getInfoTotalTimeoutMs() { 57 | return sc.getInfoTotalTimeoutMs(); 58 | } 59 | 60 | public long getInfoRetryDelayMs() { 61 | return sc.getInfoRetryDelayMs(); 62 | } 63 | 64 | public long getCatchupTimeoutMs() { 65 | return sc.getCatchupTimeoutMs(); 66 | } 67 | 68 | public long getCatchupTotalTimeoutMs() { 69 | return sc.getCatchupTotalTimeoutMs(); 70 | } 71 | 72 | public long getCatchupRetryDelayMs() { 73 | return sc.getCatchupRetryDelayMs(); 74 | } 75 | 76 | public long getStreamStartTimeoutMs() { 77 | return sc.getStreamStartTimeoutMs(); 78 | } 79 | 80 | public long getStreamReadTimeoutMs() { 81 | return sc.getStreamReadTimeoutMs(); 82 | } 83 | 84 | public synchronized boolean acquire() { 85 | if (acquired >= cc.getMaxConnections()) { 86 | return false; 87 | } 88 | 89 | acquired++; 90 | return true; 91 | } 92 | 93 | public synchronized void release() { 94 | if (acquired > 0) { 95 | acquired--; 96 | } 97 | } 98 | 99 | public HttpRequest.Builder createRequest(String url) { 100 | HttpRequest.Builder builder = HttpRequest.newBuilder() 101 | .uri(URI.create(url)); 102 | 103 | // add basic authentication 104 | if (cc.getLogin() != null && cc.getPassword() != null) { 105 | builder.header("Authorization", "Basic " + Base64.getEncoder().encodeToString((cc.getLogin() + ":" + cc.getPassword()).getBytes())); 106 | } 107 | 108 | return builder; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/xmltv/XmltvProgramme.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.xmltv; 2 | 3 | import java.time.ZonedDateTime; 4 | 5 | import com.fasterxml.jackson.annotation.JsonFormat; 6 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; 7 | 8 | public class XmltvProgramme { 9 | @JacksonXmlProperty(isAttribute = true) 10 | @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyyMMddHHmmss Z") 11 | private ZonedDateTime start; 12 | 13 | @JacksonXmlProperty(isAttribute = true) 14 | @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyyMMddHHmmss Z") 15 | private ZonedDateTime stop; 16 | 17 | @JacksonXmlProperty(isAttribute = true) 18 | private String channel; 19 | 20 | private XmltvText category; 21 | 22 | private XmltvText title; 23 | 24 | private XmltvText desc; 25 | 26 | private XmltvRating rating; 27 | 28 | private XmltvIcon icon; 29 | 30 | public XmltvProgramme() { 31 | } 32 | 33 | public XmltvProgramme(XmltvProgramme p) { 34 | this.start = p.start; 35 | this.stop = p.stop; 36 | this.channel = p.channel; 37 | this.category = p.category; 38 | this.title = p.title; 39 | this.desc = p.desc; 40 | this.rating = p.rating; 41 | this.icon = p.icon; 42 | } 43 | 44 | public XmltvProgramme(String channel, ZonedDateTime start, ZonedDateTime stop) { 45 | this.channel = channel; 46 | this.start = start; 47 | this.stop = stop; 48 | } 49 | 50 | public XmltvProgramme copy() { 51 | return new XmltvProgramme(this); 52 | } 53 | 54 | public String getChannel() { 55 | return channel; 56 | } 57 | 58 | public XmltvProgramme setChannel(String channel) { 59 | this.channel = channel; 60 | return this; 61 | } 62 | 63 | public ZonedDateTime getStart() { 64 | return start; 65 | } 66 | 67 | public XmltvProgramme setStart(ZonedDateTime start) { 68 | this.start = start; 69 | return this; 70 | } 71 | 72 | public ZonedDateTime getStop() { 73 | return stop; 74 | } 75 | 76 | public XmltvProgramme setStop(ZonedDateTime stop) { 77 | this.stop = stop; 78 | return this; 79 | } 80 | 81 | public XmltvText getCategory() { 82 | return category; 83 | } 84 | 85 | public XmltvProgramme setCategory(XmltvText category) { 86 | this.category = category; 87 | return this; 88 | } 89 | 90 | public XmltvText getTitle() { 91 | return title; 92 | } 93 | 94 | public XmltvProgramme setTitle(XmltvText title) { 95 | this.title = title; 96 | return this; 97 | } 98 | 99 | public XmltvText getDesc() { 100 | return desc; 101 | } 102 | 103 | public XmltvProgramme setDesc(XmltvText desc) { 104 | this.desc = desc; 105 | return this; 106 | } 107 | 108 | public XmltvRating getRating() { 109 | return rating; 110 | } 111 | 112 | public XmltvProgramme setRating(XmltvRating rating) { 113 | this.rating = rating; 114 | return this; 115 | } 116 | 117 | public XmltvIcon getIcon() { 118 | return icon; 119 | } 120 | 121 | public XmltvProgramme setIcon(XmltvIcon icon) { 122 | this.icon = icon; 123 | return this; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/xmltv/XmltvUtils.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.xmltv; 2 | 3 | import java.io.BufferedOutputStream; 4 | import java.io.ByteArrayInputStream; 5 | import java.io.ByteArrayOutputStream; 6 | import java.io.IOException; 7 | import java.io.InputStream; 8 | import java.nio.charset.StandardCharsets; 9 | import java.util.zip.GZIPInputStream; 10 | import java.util.zip.GZIPOutputStream; 11 | 12 | import com.fasterxml.jackson.annotation.JsonAutoDetect; 13 | import com.fasterxml.jackson.annotation.JsonInclude; 14 | import com.fasterxml.jackson.databind.DeserializationFeature; 15 | import com.fasterxml.jackson.databind.MapperFeature; 16 | import com.fasterxml.jackson.databind.introspect.VisibilityChecker; 17 | import com.fasterxml.jackson.dataformat.xml.XmlMapper; 18 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | public class XmltvUtils { 23 | private static final Logger LOG = LoggerFactory.getLogger(XmltvUtils.class); 24 | 25 | public static final XmlMapper xmltvMapper = createMapper(); 26 | 27 | public static XmlMapper createMapper() { 28 | return XmlMapper.builder() 29 | .configure(MapperFeature.AUTO_DETECT_GETTERS, false) 30 | .configure(MapperFeature.AUTO_DETECT_IS_GETTERS, false) 31 | .configure(MapperFeature.AUTO_DETECT_SETTERS, false) 32 | .configure(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE, false) 33 | .defaultUseWrapper(false) 34 | .addModule(new JavaTimeModule()) 35 | .visibility(new VisibilityChecker.Std(JsonAutoDetect.Visibility.NONE, JsonAutoDetect.Visibility.NONE, JsonAutoDetect.Visibility.NONE, JsonAutoDetect.Visibility.ANY, JsonAutoDetect.Visibility.ANY)) 36 | .serializationInclusion(JsonInclude.Include.NON_NULL) 37 | .build(); 38 | } 39 | 40 | public static XmltvDoc parseXmltv(byte[] data) { 41 | try { 42 | try (InputStream is = openStream(data)) { 43 | return xmltvMapper.readValue(is, XmltvDoc.class); 44 | } 45 | } catch (IOException e) { 46 | LOG.error("error parsing xmltv data"); 47 | return null; 48 | } 49 | } 50 | 51 | private static InputStream openStream(byte[] data) throws IOException { 52 | InputStream is = new ByteArrayInputStream(data); 53 | if (data.length >= 2 && data[0] == (byte)0x1f && data[1] == (byte)0x8b) { 54 | is = new GZIPInputStream(is); 55 | } 56 | 57 | return is; 58 | } 59 | 60 | public static byte[] writeXmltv(XmltvDoc xmltv) { 61 | try { 62 | ByteArrayOutputStream bos = new ByteArrayOutputStream(); 63 | 64 | try (GZIPOutputStream gos = new GZIPOutputStream(bos); BufferedOutputStream bbos = new BufferedOutputStream(gos)) { 65 | bbos.write("\n".getBytes(StandardCharsets.UTF_8)); 66 | xmltvMapper.writeValue(bbos, xmltv); 67 | //xmltvMapper.writerWithDefaultPrettyPrinter().writeValue(bbos, xmltv); 68 | bbos.flush(); 69 | } 70 | 71 | return bos.toByteArray(); 72 | } catch (IOException e) { 73 | LOG.error("error serializing xmltv data", e); 74 | throw new RuntimeException(e); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/IptvUser.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.util.concurrent.ScheduledExecutorService; 4 | import java.util.concurrent.ScheduledFuture; 5 | import java.util.concurrent.TimeUnit; 6 | import java.util.concurrent.locks.Lock; 7 | import java.util.concurrent.locks.ReentrantLock; 8 | import java.util.function.BiConsumer; 9 | 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | 13 | public class IptvUser { 14 | private static final Logger LOG = LoggerFactory.getLogger(IptvUser.class); 15 | 16 | private final String id; 17 | private final Lock lock = new ReentrantLock(); 18 | 19 | private final ScheduledExecutorService scheduler; 20 | private final BiConsumer unregister; 21 | 22 | private long expireTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(1); 23 | 24 | private long timeoutTime; 25 | private ScheduledFuture timeoutFuture; 26 | 27 | private volatile IptvServerChannel serverChannel; 28 | 29 | public IptvUser(String id, ScheduledExecutorService scheduler, BiConsumer unregister) { 30 | this.id = id; 31 | this.scheduler = scheduler; 32 | this.unregister = unregister; 33 | 34 | LOG.info("[{}] user created", id); 35 | } 36 | 37 | public void lock() { 38 | lock.lock(); 39 | } 40 | 41 | public void unlock() { 42 | // small optimization to have less removes and Future recreates in priority queue 43 | if (timeoutTime == 0 || timeoutTime > expireTime) { 44 | schedule(); 45 | } 46 | 47 | lock.unlock(); 48 | } 49 | 50 | private void schedule() { 51 | if (timeoutFuture != null) { 52 | timeoutFuture.cancel(false); 53 | } 54 | 55 | // 100ms jitter 56 | timeoutFuture = scheduler.schedule(this::removeIfNeed, expireDelay() + 100, TimeUnit.MILLISECONDS); 57 | timeoutTime = expireTime; 58 | } 59 | 60 | private void removeIfNeed() { 61 | lock(); 62 | try { 63 | if (System.currentTimeMillis() < expireTime) { 64 | timeoutTime = 0; 65 | schedule(); 66 | } else { 67 | unregister.accept(id, this); 68 | releaseChannel(); 69 | 70 | LOG.info("[{}] user removed", id); 71 | } 72 | } finally { 73 | unlock(); 74 | } 75 | } 76 | 77 | public String getId() { 78 | return id; 79 | } 80 | 81 | private long expireDelay() { 82 | return Math.max(100, expireTime - System.currentTimeMillis()); 83 | } 84 | 85 | public void setExpireTime(long expireTime) { 86 | this.expireTime = Math.max(this.expireTime, expireTime); 87 | } 88 | 89 | public void releaseChannel() { 90 | if (serverChannel != null) { 91 | serverChannel.release(id); 92 | serverChannel = null; 93 | } 94 | } 95 | 96 | public IptvServerChannel getServerChannel(IptvChannel channel) { 97 | if (serverChannel != null) { 98 | if (serverChannel.getChannelId().equals(channel.getId())) { 99 | return serverChannel; 100 | } 101 | 102 | serverChannel.release(id); 103 | } 104 | 105 | expireTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(1); 106 | 107 | serverChannel = channel.acquire(id); 108 | 109 | return serverChannel; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/AsyncLoader.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.net.URI; 4 | import java.net.http.HttpClient; 5 | import java.net.http.HttpRequest; 6 | import java.net.http.HttpResponse; 7 | import java.util.concurrent.CompletableFuture; 8 | import java.util.concurrent.ScheduledExecutorService; 9 | import java.util.concurrent.TimeUnit; 10 | import java.util.function.Supplier; 11 | 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | public class AsyncLoader { 16 | private static final Logger LOG = LoggerFactory.getLogger(AsyncLoader.class); 17 | 18 | public static AsyncLoader stringLoader(long timeoutSec, long totalTimeoutSec, long retryDelayMs, ScheduledExecutorService scheduler) { 19 | return new AsyncLoader<>(timeoutSec, totalTimeoutSec, retryDelayMs, scheduler, HttpResponse.BodyHandlers::ofString); 20 | } 21 | 22 | public static AsyncLoader bytesLoader(long timeoutSec, long totalTimeoutSec, long retryDelayMs, ScheduledExecutorService scheduler) { 23 | return new AsyncLoader<>(timeoutSec, totalTimeoutSec, retryDelayMs, scheduler, HttpResponse.BodyHandlers::ofByteArray); 24 | } 25 | 26 | private final long timeoutSec; 27 | private final long totalTimeoutSec; 28 | private final long retryDelayMs; 29 | private final ScheduledExecutorService scheduler; 30 | private final Supplier> handlerSupplier; 31 | 32 | public AsyncLoader( 33 | long timeoutSec, long totalTimeoutSec, long retryDelayMs, ScheduledExecutorService scheduler, 34 | Supplier> handlerSupplier 35 | ) { 36 | this.timeoutSec = timeoutSec; 37 | this.totalTimeoutSec = totalTimeoutSec; 38 | this.retryDelayMs = retryDelayMs; 39 | this.scheduler = scheduler; 40 | this.handlerSupplier = handlerSupplier; 41 | } 42 | 43 | public CompletableFuture loadAsync(String msg, String url, HttpClient httpClient) { 44 | return loadAsync(msg, HttpRequest.newBuilder().uri(URI.create(url)).build(), httpClient); 45 | } 46 | 47 | public CompletableFuture loadAsync(String msg, HttpRequest req, HttpClient httpClient) { 48 | final String rid = RequestCounter.next(); 49 | 50 | var future = new CompletableFuture(); 51 | loadAsync(msg, req, 0, System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(totalTimeoutSec), rid, future, httpClient); 52 | return future; 53 | } 54 | 55 | private void loadAsync( 56 | String msg, 57 | HttpRequest req, 58 | int retryNo, 59 | long expireTime, 60 | String rid, 61 | CompletableFuture future, 62 | HttpClient httpClient 63 | ) { 64 | LOG.info("{}loading {}, retry: {}, url: {}", rid, msg, retryNo, req.uri()); 65 | 66 | final long startNanos = System.nanoTime(); 67 | httpClient.sendAsync(req, handlerSupplier.get()) 68 | .orTimeout(timeoutSec, TimeUnit.SECONDS) 69 | .whenComplete((resp, err) -> { 70 | if (HttpUtils.isOk(resp, err, rid, startNanos)) { 71 | future.complete(resp.body()); 72 | } else { 73 | if (System.currentTimeMillis() < expireTime) { 74 | LOG.warn("{}will retry", rid); 75 | 76 | scheduler.schedule( 77 | () -> loadAsync(msg, req, retryNo + 1, expireTime, rid, future, httpClient), 78 | retryDelayMs, 79 | TimeUnit.MILLISECONDS 80 | ); 81 | } else { 82 | LOG.error("{}failed", rid); 83 | future.complete(null); 84 | } 85 | } 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/m3u/M3uParser.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.m3u; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collections; 5 | import java.util.HashMap; 6 | import java.util.HashSet; 7 | import java.util.List; 8 | import java.util.Map; 9 | import java.util.Set; 10 | import java.util.regex.Matcher; 11 | import java.util.regex.Pattern; 12 | 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | public class M3uParser { 17 | private static final Logger LOG = LoggerFactory.getLogger(M3uParser.class); 18 | 19 | private static final Pattern TAG_PAT = Pattern.compile("#(\\w+)(?:[ :](.*))?"); 20 | private static final Pattern PROP_PAT = Pattern.compile(" *([\\w-_]+)=\"([^\"]*)\"(.*)"); 21 | private static final Pattern PROP_NONSTD_PAT = Pattern.compile(" *([\\w-_]+)=([^\"][^ ]*)(.*)"); 22 | private static final Pattern INFO_PAT = Pattern.compile("([-+0-9]+) ?(.*)"); 23 | 24 | public static M3uDoc parse(String content) { 25 | Map m3uProps = Collections.emptyMap();//new HashMap<>(); 26 | List channels = new ArrayList<>(); 27 | 28 | Set groups = new HashSet<>(); 29 | Map props = null; 30 | String name = null; 31 | 32 | for (String line : content.split("\n")) { 33 | line = line.strip(); 34 | 35 | Matcher m; 36 | 37 | if ((m = TAG_PAT.matcher(line)).matches()) { 38 | switch (m.group(1)) { 39 | case "EXTM3U": 40 | String p = m.group(2); 41 | if (p != null) { 42 | String prop = parseProps(m.group(2), m3uProps = new HashMap<>()).strip(); 43 | if (!prop.isEmpty()) { 44 | LOG.warn("malformed property: {}", prop); 45 | } 46 | } 47 | break; 48 | 49 | case "EXTINF": 50 | String infoLine = m.group(2); 51 | m = INFO_PAT.matcher(infoLine); 52 | if (m.matches()) { 53 | name = parseProps(m.group(2), props = new HashMap<>()).strip(); 54 | if (name.startsWith(",")) { 55 | name = name.substring(1).strip(); 56 | } 57 | } else { 58 | LOG.error("malformed channel info: {}", infoLine); 59 | return null; 60 | } 61 | break; 62 | 63 | case "EXTGRP": 64 | for (String group : m.group(2).strip().split(";")) { 65 | groups.add(group.strip()); 66 | } 67 | break; 68 | 69 | default: 70 | LOG.warn("unknown m3u tag: {}", m.group(1)); 71 | } 72 | } else if (!line.isEmpty()) { 73 | if (name == null) { 74 | LOG.warn("url found while no info defined: {}", line); 75 | } else { 76 | String group = props.remove("group-title"); 77 | if (group != null) { 78 | groups.add(group); 79 | } 80 | 81 | channels.add(new M3uChannel(line, name, groups, props)); 82 | 83 | name = null; 84 | groups = new HashSet<>(); 85 | props = null; 86 | } 87 | } 88 | } 89 | 90 | return new M3uDoc(channels, m3uProps); 91 | } 92 | 93 | private static String parseProps(String line, Map props) { 94 | String postfix = ""; 95 | List malformedProps = new ArrayList<>(); 96 | 97 | while (line.length() > 0) { 98 | Matcher m = PROP_PAT.matcher(line); 99 | if (!m.matches()) { 100 | m = PROP_NONSTD_PAT.matcher(line); 101 | } 102 | if (m.matches()) { 103 | props.put(m.group(1), m.group(2)); 104 | line = m.group(3).strip(); 105 | postfix = line; 106 | 107 | if (!malformedProps.isEmpty()) { 108 | malformedProps.forEach(prop -> LOG.warn("malformed property: {}", prop)); 109 | malformedProps.clear(); 110 | } 111 | } else { 112 | // try to continue parsing properties 113 | int idx = line.indexOf(' '); 114 | if (idx < 0) { 115 | idx = line.length(); 116 | } 117 | 118 | malformedProps.add(line.substring(0, idx)); 119 | 120 | line = line.substring(idx).strip(); 121 | } 122 | } 123 | 124 | return postfix; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/config/IptvProxyConfig.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.config; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collection; 5 | import java.util.HashSet; 6 | import java.util.List; 7 | import java.util.Set; 8 | 9 | public class IptvProxyConfig { 10 | private String host = "127.0.0.1"; 11 | private int port = 8080; 12 | private String baseUrl; 13 | private String forwardedPass; 14 | private String tokenSalt; 15 | private List servers; 16 | private boolean allowAnonymous = true; 17 | private Set users = new HashSet<>(); 18 | private long channelsTimeoutSec = 5; 19 | private long channelsTotalTimeoutSec = 60; 20 | private long channelsRetryDelayMs = 1000; 21 | private long xmltvTimeoutSec = 30; 22 | private long xmltvTotalTimeoutSec = 120; 23 | private long xmltvRetryDelayMs = 1000; 24 | private boolean useHttp2 = false; 25 | 26 | protected IptvProxyConfig() { 27 | } 28 | 29 | public String getHost() { 30 | return host; 31 | } 32 | 33 | public int getPort() { 34 | return port; 35 | } 36 | 37 | public String getBaseUrl() { 38 | return baseUrl; 39 | } 40 | 41 | public String getForwardedPass() { 42 | return forwardedPass; 43 | } 44 | 45 | public String getTokenSalt() { 46 | return tokenSalt; 47 | } 48 | 49 | public List getServers() { 50 | return servers; 51 | } 52 | 53 | public boolean getAllowAnonymous() { 54 | return allowAnonymous; 55 | } 56 | 57 | public Set getUsers() { 58 | return users; 59 | } 60 | 61 | public long getChannelsTimeoutSec() { 62 | return channelsTimeoutSec; 63 | } 64 | 65 | public long getChannelsTotalTimeoutSec() { 66 | return channelsTotalTimeoutSec; 67 | } 68 | 69 | public long getChannelsRetryDelayMs() { 70 | return channelsRetryDelayMs; 71 | } 72 | 73 | public long getXmltvTimeoutSec() { 74 | return xmltvTimeoutSec; 75 | } 76 | 77 | public long getXmltvTotalTimeoutSec() { 78 | return xmltvTotalTimeoutSec; 79 | } 80 | 81 | public long getXmltvRetryDelayMs() { 82 | return xmltvRetryDelayMs; 83 | } 84 | 85 | public boolean getUseHttp2() { 86 | return useHttp2; 87 | } 88 | 89 | public static Builder newBuilder() { 90 | return new Builder(); 91 | } 92 | 93 | public static class Builder { 94 | private final IptvProxyConfig c = new IptvProxyConfig(); 95 | 96 | public IptvProxyConfig build() { 97 | return c; 98 | } 99 | 100 | public Builder host(String host) { 101 | c.host = host; 102 | return this; 103 | } 104 | 105 | public Builder port(int port) { 106 | c.port = port; 107 | return this; 108 | } 109 | 110 | public Builder baseUrl(String baseUrl) { 111 | c.baseUrl = baseUrl; 112 | return this; 113 | } 114 | 115 | public Builder forwardedPass(String forwardedPass) { 116 | c.forwardedPass = forwardedPass; 117 | return this; 118 | } 119 | 120 | public Builder tokenSalt(String tokenSalt) { 121 | c.tokenSalt = tokenSalt; 122 | return this; 123 | } 124 | 125 | public Builder servers(Collection servers) { 126 | c.servers = new ArrayList<>(servers); 127 | return this; 128 | } 129 | 130 | public Builder allowAnonymous(boolean allowAnonymous) { 131 | c.allowAnonymous = allowAnonymous; 132 | return this; 133 | } 134 | 135 | public Builder users(Collection users) { 136 | c.users = new HashSet<>(users); 137 | return this; 138 | } 139 | 140 | public Builder channelsTimeoutSec(long channelsTimeoutSec) { 141 | c.channelsTimeoutSec = channelsTimeoutSec; 142 | return this; 143 | } 144 | 145 | public Builder channelsTotalTimeoutSec(long channelsTotalTimeoutSec) { 146 | c.channelsTotalTimeoutSec = channelsTotalTimeoutSec; 147 | return this; 148 | } 149 | 150 | public Builder channelsRetryDelayMs(long channelsRetryDelayMs) { 151 | c.channelsRetryDelayMs = channelsRetryDelayMs; 152 | return this; 153 | } 154 | 155 | public Builder xmltvTimeoutSec(long xmltvTimeoutSec) { 156 | c.xmltvTimeoutSec = xmltvTimeoutSec; 157 | return this; 158 | } 159 | 160 | public Builder xmltvTotalTimeoutSec(long xmltvTotalTimeoutSec) { 161 | c.xmltvTotalTimeoutSec = xmltvTotalTimeoutSec; 162 | return this; 163 | } 164 | 165 | public Builder xmltvRetryDelayMs(long xmltvRetryDelayMs) { 166 | c.xmltvRetryDelayMs = xmltvRetryDelayMs; 167 | return this; 168 | } 169 | 170 | public Builder useHttp2(boolean useHttp2) { 171 | c.useHttp2 = useHttp2; 172 | return this; 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/config/IptvServerConfig.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv.config; 2 | 3 | import java.time.Duration; 4 | import java.util.ArrayList; 5 | import java.util.Collection; 6 | import java.util.Collections; 7 | import java.util.List; 8 | import java.util.regex.Pattern; 9 | 10 | public class IptvServerConfig { 11 | private String name; 12 | private List connections; 13 | private String xmltvUrl; 14 | private Duration xmltvBefore; 15 | private Duration xmltvAfter; 16 | private boolean sendUser; 17 | private boolean proxyStream = true; 18 | private long channelFailedMs; 19 | private long infoTimeoutMs = 1000; 20 | private long infoTotalTimeoutMs = 2000; 21 | private long infoRetryDelayMs = 100; 22 | private long catchupTimeoutMs = 1000; 23 | private long catchupTotalTimeoutMs = 2000; 24 | private long catchupRetryDelayMs = 100; 25 | private long streamStartTimeoutMs = 1000; 26 | private long streamReadTimeoutMs = 1000; 27 | 28 | private List groupFilters = Collections.emptyList(); 29 | 30 | private IptvServerConfig() { 31 | } 32 | 33 | public String getName() { 34 | return name; 35 | } 36 | 37 | public List getConnections() { 38 | return connections; 39 | } 40 | 41 | public String getXmltvUrl() { 42 | return xmltvUrl; 43 | } 44 | 45 | public Duration getXmltvBefore() { 46 | return xmltvBefore; 47 | } 48 | 49 | public Duration getXmltvAfter() { 50 | return xmltvAfter; 51 | } 52 | 53 | public boolean getSendUser() { 54 | return sendUser; 55 | } 56 | 57 | public boolean getProxyStream() { 58 | return proxyStream; 59 | } 60 | 61 | public long getChannelFailedMs() { 62 | return channelFailedMs; 63 | } 64 | 65 | public long getInfoTimeoutMs() { 66 | return infoTimeoutMs; 67 | } 68 | 69 | public long getInfoTotalTimeoutMs() { 70 | return infoTotalTimeoutMs; 71 | } 72 | 73 | public long getInfoRetryDelayMs() { 74 | return infoRetryDelayMs; 75 | } 76 | 77 | public long getCatchupTimeoutMs() { 78 | return catchupTimeoutMs; 79 | } 80 | 81 | public long getCatchupTotalTimeoutMs() { 82 | return catchupTotalTimeoutMs; 83 | } 84 | 85 | public long getCatchupRetryDelayMs() { 86 | return catchupRetryDelayMs; 87 | } 88 | 89 | public long getStreamStartTimeoutMs() { 90 | return streamStartTimeoutMs; 91 | } 92 | 93 | public long getStreamReadTimeoutMs() { 94 | return streamReadTimeoutMs; 95 | } 96 | 97 | public List getGroupFilters() { 98 | return groupFilters; 99 | } 100 | 101 | public static Builder newBuilder() { 102 | return new Builder(); 103 | } 104 | 105 | public static class Builder { 106 | private final IptvServerConfig c = new IptvServerConfig(); 107 | 108 | public IptvServerConfig build() { 109 | return c; 110 | } 111 | 112 | public Builder name(String name) { 113 | c.name = name; 114 | return this; 115 | } 116 | 117 | public Builder connections(Collection connections) { 118 | c.connections = new ArrayList<>(connections); 119 | return this; 120 | } 121 | 122 | public Builder xmltvUrl(String xmltvUrl) { 123 | c.xmltvUrl = xmltvUrl; 124 | return this; 125 | } 126 | 127 | public Builder xmltvBefore(Duration xmltvBefore) { 128 | c.xmltvBefore = xmltvBefore; 129 | return this; 130 | } 131 | 132 | public Builder xmltvAfter(Duration xmltvAfter) { 133 | c.xmltvAfter = xmltvAfter; 134 | return this; 135 | } 136 | 137 | public Builder sendUser(boolean sendUser) { 138 | c.sendUser = sendUser; 139 | return this; 140 | } 141 | 142 | public Builder proxyStream(boolean proxyStream) { 143 | c.proxyStream = proxyStream; 144 | return this; 145 | } 146 | 147 | public Builder channelFailedMs(long channelFailedMs) { 148 | c.channelFailedMs = channelFailedMs; 149 | return this; 150 | } 151 | 152 | public Builder infoTimeoutMs(long infoTimeoutMs) { 153 | c.infoTimeoutMs = infoTimeoutMs; 154 | return this; 155 | } 156 | 157 | public Builder infoTotalTimeoutMs(long infoTotalTimeoutMs) { 158 | c.infoTotalTimeoutMs = infoTotalTimeoutMs; 159 | return this; 160 | } 161 | 162 | public Builder infoRetryDelayMs(long infoRetryDelayMs) { 163 | c.infoRetryDelayMs = infoRetryDelayMs; 164 | return this; 165 | } 166 | 167 | public Builder catchupTimeoutMs(long catchupTimeoutMs) { 168 | c.catchupTimeoutMs = catchupTimeoutMs; 169 | return this; 170 | } 171 | 172 | public Builder catchupTotalTimeoutMs(long catchupTotalTimeoutMs) { 173 | c.catchupTotalTimeoutMs = catchupTotalTimeoutMs; 174 | return this; 175 | } 176 | 177 | public Builder catchupRetryDelayMs(long catchupRetryDelayMs) { 178 | c.catchupRetryDelayMs = catchupRetryDelayMs; 179 | return this; 180 | } 181 | 182 | public Builder streamStartTimeoutMs(long streamStartTimeoutMs) { 183 | c.streamStartTimeoutMs = streamStartTimeoutMs; 184 | return this; 185 | } 186 | 187 | public Builder streamReadTimeoutMs(long streamReadTimeoutMs) { 188 | c.streamReadTimeoutMs = streamReadTimeoutMs; 189 | return this; 190 | } 191 | 192 | public Builder groupFilters(Collection groupFilters) { 193 | c.groupFilters = new ArrayList<>(groupFilters); 194 | return this; 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | This project is a simple iptv restreamer. For now it supports only HLS (m3u8) streams. 4 | Some iptv providers allow to connect only one device per url and this is not really 5 | comfortable when you have 3+ tv. Iptv-proxy allocates such 'urls' dynamically. I.e. your 6 | iptv provider have two urls (playlists) and allows only one connection per url, but 7 | you have 4 tv in your house and you never watch more then 2 tv at the same time. 8 | In this case you can setup two playlists in iptv proxy and they will be dynamically 9 | allocated to active tv. 10 | 11 | Also iptvproxy can combine different iptv providers. It will combine both - playlist and xmltv data (if available). 12 | 13 | ## Configuration 14 | 15 | ```yaml 16 | host: 127.0.0.1 17 | port: 8080 18 | base_url: http://127.0.0.1:8080 19 | forwarded_pass: password 20 | token_salt: 6r8bt67ta5e87tg7afn 21 | channels_timeout_sec: 5 22 | channels_total_timeout_sec: 60 23 | channels_retry_delay_ms: 1000 24 | xmltv_timeout_sec: 30 25 | xmltv_total_timeout_sec: 120 26 | xmltv_retry_delay_ms: 1000 27 | use_http2: false 28 | servers: 29 | - name: someiptv-1 30 | connections: 31 | - url: https://someiptv.com/playlist.m3u 32 | max_connections: 1 33 | - name: someiptv-2 34 | connections: 35 | - url: https://iptv-proxy.example.com/playlist.m3u 36 | max_connections: 4 37 | - url: https://iptv-proxy.example.com/playlist2.m3u 38 | max_connections: 2 39 | login: mylogin 40 | password: mypassword 41 | xmltv_url: https://epg.example.com/epg.xml.gz 42 | xmltv_before: p5d 43 | xmltv_after: p1d 44 | send_user: true 45 | proxy_stream: true 46 | channel_failed_ms: 1000 47 | info_timeout_ms: 1000 48 | info_total_timeout_ms: 2000 49 | info_retry_delay_ms: 100 50 | catchup_timeout_ms: 1000 51 | catchup_total_timeout_ms: 2000 52 | catchup_retry_delay_ms: 100 53 | stream_start_timeout_ms: 1000 54 | stream_read_timeout_ms: 1000 55 | group_filters: 56 | - 'movies' 57 | - 'vid.*' 58 | allow_anonymous: false 59 | users: 60 | - 65182_login1 61 | - 97897_login2 62 | ``` 63 | 64 | * `base_url` - url of your service, you may omit this (see forwarded_pass) 65 | * `forwarded_pass` - password for Forwarded header in case iptvproxy is behind proxy 66 | * `token_salt` - just random chars, they are used to create encrypted tokens 67 | * `channels_timeout_sec` - timeout for single request (default is 5 sec) 68 | * `channels_total_timeout_sec` - total timeout for channels loading (default is 60 sec) 69 | * `channels_retry_delay_ms` - delay between requests (default is 1000 ms) 70 | * `xmltv_timeout_sec` - timeout for single xmltv data request (default is 30 sec) 71 | * `xmltv_total_timeout_sec` - total timeout for loading xmltv data (default is 120 sec) 72 | * `xmltv_retry_delay_ms` - delat between retries (default is 1000 ms) 73 | * `use_http2` - use http2 when available, default is false - where are some strange problems with recent nginx and we really don't need http2 74 | * `max_connections` - max active connections allowed for this playlist 75 | * `login` - login for basic authentication (useful for tvheadend iptv playlists) 76 | * `password` - password for basic authentication (useful for tvheadend iptv playlists) 77 | * `xmltv_url` - url for xmltv data, epg for different servers will be reprocessed and combined to one file for all channels 78 | * `xmltv_after` - filter programmes after specified time (to reduce xmltv size), java duration format (p1d - one day), default - unlimited 79 | * `xmltv_before` - filter programmes before specified time (to reduce xmltv size), java duration format (p5d - five days), default - unlimited 80 | * `send_user` - this is useful only when you're using cascade config - iptv-proxy behind iptv-proxy. 81 | If 'true' then iptv-proxy will send current user name in special http header. 82 | We need this to identify device (endpoint) - this will help us to handle max connections and 83 | channel switching properly. 84 | * `proxy_stream` - true (default) means proxy all data through own server, 85 | false means using direct urls for data 86 | * `channel_failed_ms` - on channel failure (error on downloading current m3u8 info) 87 | it will be marked as 'failed' for some time and will be not used for any subsequent requests. 88 | This feature should be enabled for last iptvproxy in chain (the one which connects to your iptv service) 89 | and should be disabled in other situation 90 | * `info_timeout_ms` - timeout for single request (default is 2000ms) 91 | * `info_total_timeout_ms` - some providers may return 404 http error on m3u8 request. This setting 92 | will trigger automatic request retry. We'll be trying to make additional requests for this period. (default is 2000ms). 93 | * `info_retry_delay_ms` - delay in milliseconds between retries (default is 100ms). 94 | * `catchup_timeout_sec` - same as `info_timeout_ms` but used only with catchup (channel archive, timeshift, default is 1000ms). 95 | * `catchup_total_timeout_ms` - same as `info_total_timeout_ms` but used only with catchup (default is 2000ms). 96 | * `catchup_retry_delay_ms` - same as `info_retry_delay_ms` but used only with catchup (default is 100ms). 97 | * `stream_start_timeout_ms` - timeout for starting actually streaming data (default is 1000ms) 98 | * `stream_read_timeout_ms` - read timeout during streaming - time between any data packets (default is 1000ms) 99 | * `group_filters` - list of regex channel filters 100 | * `allow_anonymous` - allow to connect any device without specific user name. 101 | It is not good idea to use such setup. You really should add name for each device you're using. 102 | 103 | iptv proxy will embed full urls in it's lists - it means we should know url from which service is accessed by user. 104 | Url is calculated in following way: 105 | * if forwarded_pass is enabled url is taken from Forwarded header 106 | (nginx setup: `proxy_set_header Forwarded "pass=PASS;baseUrl=https://$host";`). 107 | Password must match setting in iptvproxy config 108 | * base_url is used in case it defined 109 | * schema host and port from request is used (will not work in case iptvproxy is behind proxy) 110 | 111 | ## Device setup 112 | 113 | On device you should use next url as dynamic playlist: 114 | 115 | `/m3u/` 116 | 117 | or 118 | 119 | `/m3u` 120 | 121 | for anonymous access. 122 | 123 | For xmltv you should use `/epg.xml.gz` 124 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/IptvStream.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.io.IOException; 4 | import java.nio.ByteBuffer; 5 | import java.util.Collections; 6 | import java.util.List; 7 | import java.util.Queue; 8 | import java.util.concurrent.Flow.Subscriber; 9 | import java.util.concurrent.Flow.Subscription; 10 | import java.util.concurrent.LinkedBlockingQueue; 11 | import java.util.concurrent.ScheduledExecutorService; 12 | import java.util.concurrent.ScheduledFuture; 13 | import java.util.concurrent.TimeUnit; 14 | import java.util.concurrent.atomic.AtomicBoolean; 15 | 16 | import io.undertow.io.IoCallback; 17 | import io.undertow.io.Sender; 18 | import io.undertow.server.HttpServerExchange; 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | public class IptvStream implements Subscriber> { 23 | private static final Logger LOG = LoggerFactory.getLogger(IptvStream.class); 24 | 25 | private final HttpServerExchange exchange; 26 | 27 | private final Queue buffers = new LinkedBlockingQueue<>(); 28 | private final AtomicBoolean busy = new AtomicBoolean(); 29 | 30 | private volatile Subscription subscription; 31 | 32 | private final static ByteBuffer END_MARKER = ByteBuffer.allocate(0); 33 | private final static List END_ARRAY_MARKER = Collections.singletonList(END_MARKER); 34 | 35 | private final String rid; 36 | 37 | private final SpeedMeter readMeter; 38 | private final SpeedMeter writeMeter; 39 | 40 | private final IptvUser user; 41 | private final long userTimeout; 42 | 43 | private final ScheduledExecutorService scheduler; 44 | private final long readTimeout; 45 | 46 | private volatile long timeoutTime; 47 | private volatile ScheduledFuture timeoutFuture; 48 | 49 | private final long startNanos; 50 | 51 | public IptvStream( 52 | HttpServerExchange exchange, 53 | String rid, 54 | IptvUser user, 55 | long userTimeout, 56 | long readTimeout, 57 | ScheduledExecutorService scheduler, 58 | long startNanos 59 | ) { 60 | this.exchange = exchange; 61 | this.rid = rid; 62 | 63 | this.user = user; 64 | this.userTimeout = userTimeout; 65 | 66 | this.scheduler = scheduler; 67 | this.readTimeout = readTimeout; 68 | 69 | readMeter = new SpeedMeter(rid + "read: ", startNanos); 70 | writeMeter = new SpeedMeter(rid + "write: ", startNanos); 71 | 72 | updateReadTimeout(); 73 | timeoutFuture = scheduler.schedule(this::onTimeout, readTimeout, TimeUnit.MILLISECONDS); 74 | 75 | this.startNanos = startNanos; 76 | } 77 | 78 | private void updateReadTimeout() { 79 | timeoutTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) + readTimeout; 80 | } 81 | 82 | private void onTimeout() { 83 | long now = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); 84 | if (now >= timeoutTime) { 85 | LOG.warn("{}read timeout on loading stream", rid); 86 | finish(); 87 | } else { 88 | timeoutFuture = scheduler.schedule(this::onTimeout, timeoutTime - now, TimeUnit.MILLISECONDS); 89 | } 90 | } 91 | 92 | private void updateTimeouts() { 93 | user.lock(); 94 | try { 95 | user.setExpireTime(System.currentTimeMillis() + userTimeout); 96 | } finally { 97 | user.unlock(); 98 | } 99 | 100 | updateReadTimeout(); 101 | } 102 | 103 | @Override 104 | public void onSubscribe(Subscription subscription) { 105 | if (this.subscription != null) { 106 | LOG.error("{}already subscribed", rid); 107 | subscription.cancel(); 108 | return; 109 | } 110 | 111 | this.subscription = subscription; 112 | subscription.request(Long.MAX_VALUE); 113 | } 114 | 115 | private void finish() { 116 | // cancel any timeouts 117 | timeoutFuture.cancel(false); 118 | 119 | // subscription can't be null at this place 120 | subscription.cancel(); 121 | 122 | onNext(END_ARRAY_MARKER); 123 | } 124 | 125 | @Override 126 | public void onNext(List item) { 127 | int len = 0; 128 | for (ByteBuffer b : item) { 129 | len += b.remaining(); 130 | } 131 | readMeter.processed(len); 132 | 133 | if (len > 0) { 134 | updateTimeouts(); 135 | } 136 | 137 | buffers.addAll(item); 138 | 139 | if (busy.compareAndSet(false, true)) { 140 | sendNext(); 141 | } 142 | 143 | subscription.request(Long.MAX_VALUE); 144 | } 145 | 146 | private void sendNext() { 147 | ByteBuffer b; 148 | while ((b = buffers.poll()) != null) { 149 | if (!sendNext(b)) { 150 | return; 151 | } 152 | } 153 | 154 | busy.set(false); 155 | } 156 | 157 | private boolean sendNext(ByteBuffer b) { 158 | updateTimeouts(); 159 | 160 | if (b == END_MARKER) { 161 | exchange.endExchange(); 162 | writeMeter.finish(); 163 | return true; 164 | } 165 | 166 | AtomicBoolean completed = new AtomicBoolean(false); 167 | 168 | final int len = b.remaining(); 169 | exchange.getResponseSender().send(b, new IoCallback() { 170 | @Override 171 | public void onComplete(HttpServerExchange exchange, Sender sender) { 172 | writeMeter.processed(len); 173 | 174 | if (!completed.compareAndSet(false, true)) { 175 | sendNext(); 176 | } 177 | } 178 | 179 | @Override 180 | public void onException(HttpServerExchange exchange, Sender sender, IOException exception) { 181 | LOG.warn("{}error on sending stream: {}", rid, exception.getMessage()); 182 | finish(); 183 | } 184 | }); 185 | 186 | return !completed.compareAndSet(false, true); 187 | } 188 | 189 | @Override 190 | public void onError(Throwable throwable) { 191 | LOG.warn("{}error on loading stream: {}", rid, throwable.getMessage()); 192 | finish(); 193 | } 194 | 195 | @Override 196 | public void onComplete() { 197 | readMeter.finish(); 198 | finish(); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/IptvProxyService.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.net.HttpURLConnection; 4 | import java.net.http.HttpClient; 5 | import java.nio.ByteBuffer; 6 | import java.time.Duration; 7 | import java.time.ZonedDateTime; 8 | import java.util.ArrayDeque; 9 | import java.util.ArrayList; 10 | import java.util.Comparator; 11 | import java.util.HashMap; 12 | import java.util.List; 13 | import java.util.Map; 14 | import java.util.Set; 15 | import java.util.concurrent.CompletableFuture; 16 | import java.util.concurrent.ConcurrentHashMap; 17 | import java.util.concurrent.ExecutionException; 18 | import java.util.concurrent.ScheduledExecutorService; 19 | import java.util.concurrent.ScheduledThreadPoolExecutor; 20 | import java.util.concurrent.TimeUnit; 21 | import java.util.concurrent.atomic.AtomicLong; 22 | import java.util.regex.Pattern; 23 | 24 | import com.kvaster.iptv.config.IptvProxyConfig; 25 | import com.kvaster.iptv.m3u.M3uDoc; 26 | import com.kvaster.iptv.m3u.M3uParser; 27 | import com.kvaster.iptv.xmltv.XmltvChannel; 28 | import com.kvaster.iptv.xmltv.XmltvDoc; 29 | import com.kvaster.iptv.xmltv.XmltvUtils; 30 | import com.kvaster.utils.digest.Digest; 31 | import io.undertow.Undertow; 32 | import io.undertow.server.HttpHandler; 33 | import io.undertow.server.HttpServerExchange; 34 | import io.undertow.util.Headers; 35 | import org.slf4j.Logger; 36 | import org.slf4j.LoggerFactory; 37 | 38 | public class IptvProxyService implements HttpHandler { 39 | private static final Logger LOG = LoggerFactory.getLogger(IptvProxyService.class); 40 | 41 | private static class IptvServerGroup { 42 | final String name; 43 | final List servers = new ArrayList<>(); 44 | 45 | final String xmltvUrl; 46 | final Duration xmltvBefore; 47 | final Duration xmltvAfter; 48 | final List groupFilters; 49 | 50 | byte[] xmltvCache; 51 | 52 | IptvServerGroup(String name, String xmltvUrl, Duration xmltvBefore, Duration xmltvAfter, List groupFilters) { 53 | this.name = name; 54 | this.xmltvUrl = xmltvUrl; 55 | this.xmltvBefore = xmltvBefore; 56 | this.xmltvAfter = xmltvAfter; 57 | this.groupFilters = groupFilters; 58 | } 59 | } 60 | 61 | private static final String TOKEN_TAG = "t"; 62 | 63 | private final Undertow undertow; 64 | 65 | private static final int SCHEDULER_THREADS = 2; 66 | private final ScheduledExecutorService scheduler = createScheduler(); 67 | 68 | private final BaseUrl baseUrl; 69 | private final String tokenSalt; 70 | 71 | private final AtomicLong idCounter = new AtomicLong(System.currentTimeMillis()); 72 | 73 | private final List serverGroups = new ArrayList<>(); 74 | private volatile Map channels = new HashMap<>(); 75 | private Map serverChannelsByUrl = new HashMap<>(); 76 | 77 | private final Map users = new ConcurrentHashMap<>(); 78 | 79 | private final boolean allowAnonymous; 80 | private final Set allowedUsers; 81 | 82 | private final AsyncLoader channelsLoader; 83 | private final AsyncLoader xmltvLoader; 84 | private volatile byte[] xmltvData = null; 85 | 86 | private final HttpClient defaultHttpClient; 87 | 88 | private static ScheduledExecutorService createScheduler() { 89 | ScheduledThreadPoolExecutor s = new ScheduledThreadPoolExecutor(SCHEDULER_THREADS, (r, e) -> LOG.error("execution rejected")); 90 | s.setRemoveOnCancelPolicy(true); 91 | s.setMaximumPoolSize(SCHEDULER_THREADS); 92 | 93 | return s; 94 | } 95 | 96 | public IptvProxyService(IptvProxyConfig config) { 97 | defaultHttpClient = HttpClient.newBuilder() 98 | .version(config.getUseHttp2() ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1) 99 | .followRedirects(HttpClient.Redirect.ALWAYS) 100 | .build(); 101 | 102 | baseUrl = new BaseUrl(config.getBaseUrl(), config.getForwardedPass()); 103 | 104 | this.tokenSalt = config.getTokenSalt(); 105 | 106 | this.allowAnonymous = config.getAllowAnonymous(); 107 | this.allowedUsers = config.getUsers(); 108 | 109 | channelsLoader = AsyncLoader.stringLoader(config.getChannelsTimeoutSec(), config.getChannelsTotalTimeoutSec(), config.getChannelsRetryDelayMs(), scheduler); 110 | xmltvLoader = AsyncLoader.bytesLoader(config.getXmltvTimeoutSec(), config.getXmltvTotalTimeoutSec(), config.getXmltvRetryDelayMs(), scheduler); 111 | 112 | undertow = Undertow.builder() 113 | .addHttpListener(config.getPort(), config.getHost()) 114 | .setHandler(this) 115 | .build(); 116 | 117 | config.getServers().forEach((sc) -> { 118 | IptvServerGroup sg = new IptvServerGroup(sc.getName(), sc.getXmltvUrl(), sc.getXmltvBefore(), sc.getXmltvAfter(), sc.getGroupFilters()); 119 | serverGroups.add(sg); 120 | sc.getConnections().forEach((cc) -> sg.servers.add(new IptvServer(sc, cc, defaultHttpClient))); 121 | }); 122 | } 123 | 124 | public void startService() { 125 | LOG.info("starting"); 126 | 127 | updateChannels(); 128 | 129 | undertow.start(); 130 | 131 | LOG.info("started"); 132 | } 133 | 134 | public void stopService() { 135 | LOG.info("stopping"); 136 | 137 | try { 138 | scheduler.shutdownNow(); 139 | if (!scheduler.awaitTermination(60, TimeUnit.SECONDS)) { 140 | LOG.warn("scheduler is still running..."); 141 | scheduler.shutdownNow(); 142 | } 143 | } catch (InterruptedException e) { 144 | LOG.error("interrupted while stopping scheduler"); 145 | } 146 | 147 | undertow.stop(); 148 | 149 | LOG.info("stopped"); 150 | } 151 | 152 | private void scheduleChannelsUpdate(long delayMins) { 153 | scheduler.schedule(() -> new Thread(this::updateChannels).start(), delayMins, TimeUnit.MINUTES); 154 | } 155 | 156 | private void updateChannels() { 157 | if (updateChannelsImpl()) { 158 | scheduleChannelsUpdate(240); 159 | } else { 160 | scheduleChannelsUpdate(1); 161 | } 162 | } 163 | 164 | private boolean updateChannelsImpl() { 165 | LOG.info("updating channels"); 166 | 167 | Map chs = new HashMap<>(); 168 | Map byUrl = new HashMap<>(); 169 | 170 | Digest digest = Digest.sha256(); 171 | Digest md5 = Digest.md5(); 172 | 173 | Map> loads = new HashMap<>(); 174 | Map> xmltvLoads = new HashMap<>(); 175 | serverGroups.forEach((sg) -> { 176 | if (sg.xmltvUrl != null) { 177 | xmltvLoads.put(sg, loadXmltv(sg)); 178 | } 179 | sg.servers.forEach(s -> loads.put(s, loadChannels(s))); 180 | }); 181 | 182 | XmltvDoc newXmltv = new XmltvDoc() 183 | .setChannels(new ArrayList<>()) 184 | .setProgrammes(new ArrayList<>()) 185 | .setGeneratorName("iptvproxy"); 186 | 187 | for (IptvServerGroup sg : serverGroups) { 188 | XmltvDoc xmltv = null; 189 | if (sg.xmltvUrl != null) { 190 | LOG.info("waiting for xmltv data to be downloaded"); 191 | 192 | byte[] data = null; 193 | 194 | try { 195 | data = xmltvLoads.get(sg).get(); 196 | } catch (InterruptedException | ExecutionException e) { 197 | LOG.warn("error loading xmltv data"); 198 | } 199 | 200 | if (data != null) { 201 | LOG.info("parsing xmltv data"); 202 | 203 | xmltv = XmltvUtils.parseXmltv(data); 204 | if (xmltv != null) { 205 | sg.xmltvCache = data; 206 | } 207 | } 208 | 209 | if (xmltv == null && sg.xmltvCache != null) { 210 | xmltv = XmltvUtils.parseXmltv(sg.xmltvCache); 211 | } 212 | } 213 | 214 | Map xmltvById = new HashMap<>(); 215 | Map xmltvByName = new HashMap<>(); 216 | 217 | if (xmltv != null) { 218 | xmltv.getChannels().forEach(ch -> { 219 | xmltvById.put(ch.getId(), ch); 220 | if (ch.getDisplayNames() != null) { 221 | ch.getDisplayNames().forEach(n -> xmltvByName.put(n.getText(), ch)); 222 | } 223 | }); 224 | } 225 | 226 | Map xmltvIds = new HashMap<>(); 227 | 228 | for (IptvServer server : sg.servers) { 229 | LOG.info("parsing playlist: {}, url: {}", sg.name, server.getUrl()); 230 | 231 | String channels = null; 232 | 233 | try { 234 | channels = loads.get(server).get(); 235 | } catch (InterruptedException | ExecutionException e) { 236 | LOG.error("error waiting for channels load", e); 237 | } 238 | 239 | if (channels == null) { 240 | return false; 241 | } 242 | 243 | M3uDoc m3u = M3uParser.parse(channels); 244 | if (m3u == null) { 245 | LOG.error("error parsing m3u, update skipped"); 246 | return false; 247 | } 248 | 249 | m3u.getChannels().forEach((c) -> { 250 | // Unique ID will be formed from server name and channel name. 251 | // It seems that there will be no any other suitable way to identify channel. 252 | final String id = digest.digest(sg.name + "||" + c.getName()); 253 | final String url = c.getUrl(); 254 | 255 | IptvChannel channel = chs.get(id); 256 | if (channel == null) { 257 | String tvgId = c.getProp("tvg-id"); 258 | String tvgName = c.getProp("tvg-name"); 259 | 260 | if (!sg.groupFilters.isEmpty()) { 261 | if (c.getGroups().stream().noneMatch((g) -> sg.groupFilters.stream().anyMatch((f) -> f.matcher(g).find()))) { 262 | // skip channel - filtered by group filter 263 | return; 264 | } 265 | } 266 | 267 | XmltvChannel xmltvCh = null; 268 | if (tvgId != null) { 269 | xmltvCh = xmltvById.get(tvgId); 270 | } 271 | if (xmltvCh == null && tvgName != null) { 272 | xmltvCh = xmltvByName.get(tvgName); 273 | if (xmltvCh == null) { 274 | xmltvCh = xmltvByName.get(tvgName.replace(' ', '_')); 275 | } 276 | } 277 | if (xmltvCh == null) { 278 | xmltvCh = xmltvByName.get(c.getName()); 279 | } 280 | 281 | String logo = c.getProp("tvg-logo"); 282 | if (logo == null && xmltvCh != null && xmltvCh.getIcon() != null && xmltvCh.getIcon().getSrc() != null) { 283 | logo = xmltvCh.getIcon().getSrc(); 284 | } 285 | 286 | int days = 0; 287 | String daysStr = c.getProp("tvg-rec"); 288 | if (daysStr == null) { 289 | daysStr = c.getProp("catchup-days"); 290 | } 291 | if (daysStr != null) { 292 | try { 293 | days = Integer.parseInt(daysStr); 294 | } catch (NumberFormatException e) { 295 | LOG.warn("error parsing catchup days: {}, channel: {}", daysStr, c.getName()); 296 | } 297 | } 298 | 299 | String xmltvId = xmltvCh == null ? null : xmltvCh.getId(); 300 | if (xmltvId != null) { 301 | String newId = md5.digest(sg.name + '-' + xmltvId); 302 | if (xmltvIds.putIfAbsent(xmltvId, newId) == null) { 303 | newXmltv.getChannels().add(new XmltvChannel().setId(newId)); 304 | } 305 | xmltvId = newId; 306 | } 307 | 308 | channel = new IptvChannel(id, c.getName(), logo, c.getGroups(), xmltvId, days); 309 | chs.put(id, channel); 310 | } 311 | 312 | IptvServerChannel serverChannel = serverChannelsByUrl.get(url); 313 | if (serverChannel == null) { 314 | serverChannel = new IptvServerChannel(server, url, baseUrl.forPath('/' + id), id, c.getName(), scheduler); 315 | } 316 | 317 | channel.addServerChannel(serverChannel); 318 | 319 | chs.put(id, channel); 320 | byUrl.put(url, serverChannel); 321 | }); 322 | } 323 | 324 | ZonedDateTime endOf = sg.xmltvAfter == null ? null : ZonedDateTime.now().plus(sg.xmltvAfter); 325 | ZonedDateTime startOf = sg.xmltvBefore == null ? null : ZonedDateTime.now().minus(sg.xmltvBefore); 326 | 327 | if (xmltv != null) { 328 | xmltv.getProgrammes().forEach(p -> { 329 | if ((endOf == null || p.getStart().compareTo(endOf) < 0) && (startOf == null || p.getStop().compareTo(startOf) > 0)) { 330 | String newId = xmltvIds.get(p.getChannel()); 331 | if (newId != null) { 332 | newXmltv.getProgrammes().add(p.copy().setChannel(newId)); 333 | } 334 | } 335 | }); 336 | } 337 | } 338 | 339 | xmltvData = XmltvUtils.writeXmltv(newXmltv); 340 | channels = chs; 341 | serverChannelsByUrl = byUrl; 342 | 343 | LOG.info("channels updated"); 344 | 345 | return true; 346 | } 347 | 348 | private CompletableFuture loadXmltv(IptvServerGroup sg) { 349 | var f = FileLoader.tryLoadBytes(sg.xmltvUrl); 350 | return f != null ? f : xmltvLoader.loadAsync("xmltv: " + sg.name, sg.xmltvUrl, defaultHttpClient); 351 | } 352 | 353 | private CompletableFuture loadChannels(IptvServer s) { 354 | var f = FileLoader.tryLoadString(s.getUrl()); 355 | return f != null ? f : channelsLoader.loadAsync("playlist: " + s.getName(), s.createRequest(s.getUrl()).build(), s.getHttpClient()); 356 | } 357 | 358 | @Override 359 | public void handleRequest(HttpServerExchange exchange) { 360 | if (!handleInternal(exchange)) { 361 | exchange.setStatusCode(HttpURLConnection.HTTP_NOT_FOUND); 362 | exchange.getResponseSender().send("N/A"); 363 | } 364 | } 365 | 366 | private boolean handleInternal(HttpServerExchange exchange) { 367 | String path = exchange.getRequestPath(); 368 | 369 | if (path.startsWith("/")) { 370 | path = path.substring(1); 371 | } 372 | 373 | if (path.startsWith("m3u")) { 374 | return handleM3u(exchange, path); 375 | } 376 | 377 | if (path.startsWith("epg.xml.gz")) { 378 | return handleEpg(exchange); 379 | } 380 | 381 | // channels 382 | int idx = path.indexOf('/'); 383 | if (idx < 0) { 384 | LOG.warn("wrong request: {}", exchange.getRequestPath()); 385 | return false; 386 | } 387 | 388 | String ch = path.substring(0, idx); 389 | path = path.substring(idx + 1); 390 | 391 | IptvChannel channel = channels.get(ch); 392 | if (channel == null) { 393 | LOG.warn("channel not found: {}, for request: {}", ch, exchange.getRequestPath()); 394 | return false; 395 | } 396 | 397 | // we need user if this is not m3u request 398 | String token = exchange.getQueryParameters().getOrDefault(TOKEN_TAG, new ArrayDeque<>()).peek(); 399 | String user = getUserFromToken(token); 400 | 401 | // pass user name from another iptv-proxy 402 | String proxyUser = exchange.getRequestHeaders().getFirst(IptvServer.PROXY_USER_HEADER); 403 | 404 | // no token, or user is not verified 405 | if (user == null) { 406 | LOG.warn("invalid user token: {}, proxyUser: {}", token, proxyUser); 407 | return false; 408 | } 409 | 410 | if (proxyUser != null) { 411 | user = user + ':' + proxyUser; 412 | } 413 | 414 | IptvUser iu = users.computeIfAbsent(user, (u) -> new IptvUser(u, scheduler, users::remove)); 415 | iu.lock(); 416 | try { 417 | IptvServerChannel serverChannel = iu.getServerChannel(channel); 418 | if (serverChannel == null) { 419 | return false; 420 | } 421 | 422 | return serverChannel.handle(exchange, path, iu, token); 423 | } finally { 424 | iu.unlock(); 425 | } 426 | } 427 | 428 | private String getUserFromToken(String token) { 429 | if (token == null) { 430 | return null; 431 | } 432 | 433 | int idx = token.lastIndexOf('-'); 434 | if (idx < 0) { 435 | return null; 436 | } 437 | 438 | String digest = token.substring(idx + 1); 439 | String user = token.substring(0, idx); 440 | 441 | if (digest.equals(Digest.md5(user + tokenSalt))) { 442 | return user; 443 | } 444 | 445 | return null; 446 | } 447 | 448 | private String generateUser() { 449 | return String.valueOf(idCounter.incrementAndGet()); 450 | } 451 | 452 | private String generateToken(String user) { 453 | return user + '-' + Digest.md5(user + tokenSalt); 454 | } 455 | 456 | private boolean handleM3u(HttpServerExchange exchange, String path) { 457 | String user = null; 458 | 459 | int idx = path.indexOf('/'); 460 | if (idx >= 0) { 461 | user = path.substring(idx + 1); 462 | if (!allowedUsers.contains(user)) { 463 | user = null; 464 | } 465 | } 466 | 467 | if (user == null && allowAnonymous) { 468 | user = generateUser(); 469 | } 470 | 471 | if (user == null) { 472 | LOG.warn("user not defined for request: {}", exchange.getRequestPath()); 473 | return false; 474 | } 475 | 476 | String token = generateToken(user); 477 | 478 | exchange.getResponseHeaders() 479 | .add(Headers.CONTENT_TYPE, "audio/mpegurl") 480 | .add(Headers.CONTENT_DISPOSITION, "attachment; filename=playlist.m3u") 481 | .add(HttpUtils.ACCESS_CONTROL, "*"); 482 | 483 | List chs = new ArrayList<>(channels.values()); 484 | chs.sort(Comparator.comparing(IptvChannel::getName)); 485 | 486 | StringBuilder sb = new StringBuilder(); 487 | sb.append("#EXTM3U\n"); 488 | 489 | chs.forEach(ch -> { 490 | sb.append("#EXTINF:0"); 491 | 492 | if (ch.getXmltvId() != null) { 493 | sb.append(" tvg-id=\"").append(ch.getXmltvId()).append('"'); 494 | } 495 | 496 | if (ch.getLogo() != null) { 497 | sb.append(" tvg-logo=\"").append(ch.getLogo()).append('"'); 498 | } 499 | 500 | if (ch.getCatchupDays() != 0) { 501 | sb.append(" catchup=\"shift\" catchup-days=\"").append(ch.getCatchupDays()).append('"'); 502 | } 503 | 504 | sb.append(',').append(ch.getName()).append("\n"); 505 | 506 | if (ch.getGroups().size() > 0) { 507 | sb.append("#EXTGRP:").append(String.join(";", ch.getGroups())).append("\n"); 508 | } 509 | 510 | sb.append(baseUrl.getBaseUrl(exchange)) 511 | .append('/') 512 | .append(ch.getId()) 513 | .append("/channel.m3u8?") 514 | .append(TOKEN_TAG) 515 | .append("=") 516 | .append(token) 517 | .append("\n"); 518 | }); 519 | 520 | exchange.getResponseSender().send(sb.toString()); 521 | 522 | return true; 523 | } 524 | 525 | private boolean handleEpg(HttpServerExchange exchange) { 526 | byte[] epg = xmltvData; 527 | if (epg == null) { 528 | return false; 529 | } 530 | 531 | exchange.getResponseHeaders() 532 | .add(Headers.CONTENT_TYPE, "application/octet-stream") 533 | .add(Headers.CONTENT_DISPOSITION, "attachment; filename=epg.xml.gz") 534 | .add(Headers.CONTENT_LENGTH, Integer.toString(epg.length)); 535 | 536 | exchange.getResponseSender().send(ByteBuffer.wrap(epg)); 537 | 538 | return true; 539 | } 540 | } 541 | -------------------------------------------------------------------------------- /src/main/java/com/kvaster/iptv/IptvServerChannel.java: -------------------------------------------------------------------------------- 1 | package com.kvaster.iptv; 2 | 3 | import java.math.BigDecimal; 4 | import java.net.HttpURLConnection; 5 | import java.net.URI; 6 | import java.net.URISyntaxException; 7 | import java.net.URLDecoder; 8 | import java.net.URLEncoder; 9 | import java.net.http.HttpClient; 10 | import java.net.http.HttpRequest; 11 | import java.net.http.HttpResponse; 12 | import java.nio.charset.StandardCharsets; 13 | import java.util.ArrayList; 14 | import java.util.Arrays; 15 | import java.util.HashMap; 16 | import java.util.HashSet; 17 | import java.util.List; 18 | import java.util.Map; 19 | import java.util.Set; 20 | import java.util.TreeMap; 21 | import java.util.concurrent.ConcurrentHashMap; 22 | import java.util.concurrent.ScheduledExecutorService; 23 | import java.util.concurrent.TimeUnit; 24 | 25 | import com.kvaster.utils.digest.Digest; 26 | import io.undertow.server.HttpServerExchange; 27 | import io.undertow.util.Headers; 28 | import io.undertow.util.HttpString; 29 | import io.undertow.util.SameThreadExecutor; 30 | import io.undertow.util.StatusCodes; 31 | import org.slf4j.Logger; 32 | import org.slf4j.LoggerFactory; 33 | 34 | public class IptvServerChannel { 35 | private static final Logger LOG = LoggerFactory.getLogger(IptvServerChannel.class); 36 | 37 | private static final String TAG_EXTINF = "#EXTINF:"; 38 | private static final String TAG_TARGET_DURATION = "#EXT-X-TARGETDURATION:"; 39 | 40 | private final IptvServer server; 41 | private final String channelUrl; 42 | private final BaseUrl baseUrl; 43 | private final String channelId; 44 | private final String channelName; 45 | 46 | private final HttpClient httpClient; 47 | 48 | private final ScheduledExecutorService scheduler; 49 | 50 | private volatile long failedUntil; 51 | 52 | private final long defaultInfoTimeout; 53 | private final long defaultCatchupTimeout; 54 | 55 | private final boolean isHls; 56 | 57 | private static class Stream { 58 | String path; 59 | String url; 60 | String header; 61 | long durationMillis; 62 | 63 | Stream(String path, String url, String header, long durationMillis) { 64 | this.path = path; 65 | this.url = url; 66 | this.header = header; 67 | this.durationMillis = durationMillis; 68 | } 69 | 70 | @Override 71 | public String toString() { 72 | return "[path: " + path + ", url: " + url + ", duration: " + (durationMillis / 1000f) + "s]"; 73 | } 74 | } 75 | 76 | private static class Streams { 77 | List streams = new ArrayList<>(); 78 | long maxDuration = 0; 79 | } 80 | 81 | private interface StreamsConsumer { 82 | void onInfo(Streams streams, int statusCode, int retryNo); 83 | } 84 | 85 | private static class UserStreams { 86 | List consumers = new ArrayList<>(); 87 | Map streamMap = new HashMap<>(); 88 | long maxDuration; // corresponds to current streamMap 89 | long infoTimeout; 90 | String channelUrl; 91 | boolean isCatchup; 92 | 93 | UserStreams(long infoTimeout, String channelUrl) { 94 | this.infoTimeout = infoTimeout; 95 | this.channelUrl = channelUrl; 96 | } 97 | 98 | List getAndClearConsumers() { 99 | var c = consumers; 100 | consumers = new ArrayList<>(); 101 | return c; 102 | } 103 | } 104 | 105 | private final Map userStreams = new ConcurrentHashMap<>(); 106 | 107 | private static final Set HEADERS = new HashSet<>(Arrays.asList( 108 | "content-type", 109 | "content-length", 110 | "connection", 111 | "date", 112 | //"access-control-allow-origin", 113 | "access-control-allow-headers", 114 | "access-control-allow-methods", 115 | "access-control-expose-headers", 116 | "x-memory", 117 | "x-route-time", 118 | "x-run-time" 119 | )); 120 | 121 | public IptvServerChannel( 122 | IptvServer server, String channelUrl, BaseUrl baseUrl, 123 | String channelId, String channelName, ScheduledExecutorService scheduler 124 | ) { 125 | this.server = server; 126 | this.channelUrl = channelUrl; 127 | this.baseUrl = baseUrl; 128 | this.channelId = channelId; 129 | this.channelName = channelName; 130 | 131 | this.httpClient = server.getHttpClient(); 132 | 133 | this.scheduler = scheduler; 134 | 135 | defaultInfoTimeout = Math.max(server.getInfoTotalTimeoutMs(), server.getInfoTimeoutMs()) + TimeUnit.SECONDS.toMillis(1); 136 | defaultCatchupTimeout = Math.max(server.getCatchupTotalTimeoutMs(), server.getCatchupTimeoutMs()) + TimeUnit.SECONDS.toMillis(1); 137 | 138 | try { 139 | URI uri = new URI(channelUrl); 140 | isHls = uri.getPath().endsWith(".m3u8") || uri.getPath().endsWith(".m3u"); 141 | } catch (URISyntaxException e) { 142 | throw new RuntimeException(e); 143 | } 144 | } 145 | 146 | @Override 147 | public String toString() { 148 | return "[name: " + channelName + ", server: " + server.getName() + "]"; 149 | } 150 | 151 | public String getChannelId() { 152 | return channelId; 153 | } 154 | 155 | public boolean acquire(String userId) { 156 | if (System.currentTimeMillis() < failedUntil) { 157 | return false; 158 | } 159 | 160 | if (server.acquire()) { 161 | LOG.info("[{}] channel acquired: {} / {}", userId, channelName, server.getName()); 162 | return true; 163 | } 164 | 165 | return false; 166 | } 167 | 168 | public void release(String userId) { 169 | LOG.info("[{}] channel released: {} / {}", userId, channelName, server.getName()); 170 | server.release(); 171 | 172 | userStreams.remove(userId); 173 | } 174 | 175 | private HttpRequest createRequest(String url, IptvUser user) { 176 | HttpRequest.Builder builder = server.createRequest(url); 177 | 178 | // send user id to next iptv-proxy 179 | if (user != null && server.getSendUser()) { 180 | builder.header(IptvServer.PROXY_USER_HEADER, user.getId()); 181 | } 182 | 183 | return builder.build(); 184 | } 185 | 186 | private long calculateTimeout(long duration) { 187 | // usually we expect that player will try not to decrease buffer size 188 | // so we may expect that player will try to buffer more segments with durationMillis delay 189 | // kodi is downloading two or three buffers at same time 190 | // use 10 seconds for segment duration if unknown (5 or 7 seconds are usual values) 191 | return (duration == 0 ? TimeUnit.SECONDS.toMillis(10) : duration) * 3 + TimeUnit.SECONDS.toMillis(1); 192 | } 193 | 194 | public boolean handle(HttpServerExchange exchange, String path, IptvUser user, String token) { 195 | if ("channel.m3u8".equals(path)) { 196 | if (!isHls) { 197 | String url = exchange.getRequestURL().replace("channel.m3u8", ""); 198 | String q = exchange.getQueryString(); 199 | if (q != null && !q.isBlank()) { 200 | url += '?' + q; 201 | } 202 | 203 | exchange.setStatusCode(StatusCodes.FOUND); 204 | exchange.getResponseHeaders().add(Headers.LOCATION, url); 205 | exchange.endExchange(); 206 | return true; 207 | } 208 | 209 | handleInfo(exchange, user, token); 210 | return true; 211 | } else if ("".equals(path)) { 212 | final String rid = RequestCounter.next(); 213 | LOG.info("{}[{}] stream: {}", rid, user.getId(), channelUrl); 214 | 215 | runStream(rid, exchange, user, channelUrl, TimeUnit.SECONDS.toMillis(1)); 216 | 217 | return true; 218 | } else { 219 | // iptv user is synchronized (locked) at this place 220 | UserStreams us = userStreams.get(user.getId()); 221 | if (us == null) { 222 | LOG.warn("[{}] no streams set up: {}", user.getId(), exchange.getRequestPath()); 223 | return false; 224 | } 225 | 226 | Stream stream = us.streamMap.get(path); 227 | 228 | if (stream == null) { 229 | LOG.warn("[{}] stream not found: {}", user.getId(), exchange.getRequestPath()); 230 | return false; 231 | } else { 232 | final String rid = RequestCounter.next(); 233 | LOG.info("{}[{}] stream: {}", rid, user.getId(), stream); 234 | 235 | long timeout = calculateTimeout(us.maxDuration); 236 | user.setExpireTime(System.currentTimeMillis() + timeout); 237 | 238 | runStream(rid, exchange, user, stream.url, timeout); 239 | 240 | return true; 241 | } 242 | } 243 | } 244 | 245 | private void runStream(String rid, HttpServerExchange exchange, IptvUser user, String url, long timeout) { 246 | if (!server.getProxyStream()) { 247 | LOG.info("{}redirecting stream to direct url", rid); 248 | exchange.setStatusCode(StatusCodes.FOUND); 249 | exchange.getResponseHeaders().add(Headers.LOCATION, url); 250 | exchange.endExchange(); 251 | return; 252 | } 253 | 254 | // be sure we have time to start stream 255 | user.setExpireTime(System.currentTimeMillis() + server.getStreamStartTimeoutMs() + 100); 256 | 257 | exchange.dispatch(SameThreadExecutor.INSTANCE, () -> { 258 | long startNanos = System.nanoTime(); 259 | 260 | // configure buffering according to undertow buffers settings for best performance 261 | httpClient.sendAsync(createRequest(url, user), HttpResponse.BodyHandlers.ofPublisher()) 262 | .orTimeout(server.getStreamStartTimeoutMs(), TimeUnit.MILLISECONDS) 263 | .whenComplete((resp, err) -> { 264 | if (HttpUtils.isOk(resp, err, exchange, rid, startNanos)) { 265 | resp.headers().map().forEach((name, values) -> { 266 | if (HEADERS.contains(name.toLowerCase())) { 267 | exchange.getResponseHeaders().addAll(new HttpString(name), values); 268 | } 269 | }); 270 | 271 | exchange.getResponseHeaders().add(HttpUtils.ACCESS_CONTROL, "*"); 272 | 273 | long readTimeoutMs = server.getStreamReadTimeoutMs(); 274 | resp.body().subscribe(new IptvStream(exchange, rid, user, Math.max(timeout, readTimeoutMs), readTimeoutMs, scheduler, startNanos)); 275 | } 276 | }); 277 | }); 278 | } 279 | 280 | private void handleInfo(HttpServerExchange exchange, IptvUser user, String token) { 281 | UserStreams us = createUserStreams(exchange, user); 282 | 283 | // we'll wait maximum one second for stream download start after loading info 284 | user.setExpireTime(System.currentTimeMillis() + us.infoTimeout); 285 | 286 | exchange.dispatch(SameThreadExecutor.INSTANCE, () -> { 287 | String rid = RequestCounter.next(); 288 | LOG.info("{}[{}] channel: {}, url: {}", rid, user.getId(), channelName, us.channelUrl); 289 | long startNanos = System.nanoTime(); 290 | loadCachedInfo((streams, statusCode, retryNo) -> { 291 | if (streams == null) { 292 | LOG.warn("{}[{}] error loading streams info: {}, retries: {}", rid, user.getId(), statusCode, retryNo); 293 | 294 | exchange.setStatusCode(statusCode); 295 | exchange.getResponseSender().send("error"); 296 | } else { 297 | long duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); 298 | if (duration > 500 || retryNo > 0) { 299 | LOG.warn("{}[{}] channel success: {}ms, retries: {}", rid, user.getId(), duration, retryNo); 300 | } else { 301 | LOG.info("{}[{}] channel success: {}ms, retries: {}", rid, user.getId(), duration, retryNo); 302 | } 303 | 304 | StringBuilder sb = new StringBuilder(); 305 | 306 | streams.streams.forEach(s -> sb 307 | .append(s.header) 308 | .append(baseUrl.getBaseUrl(exchange)) 309 | .append('/').append(s.path).append("?t=").append(token).append("\n") 310 | ); 311 | 312 | exchange.setStatusCode(HttpURLConnection.HTTP_OK); 313 | exchange.getResponseHeaders() 314 | .add(Headers.CONTENT_TYPE, "application/x-mpegUrl") 315 | .add(HttpUtils.ACCESS_CONTROL, "*"); 316 | exchange.getResponseSender().send(sb.toString()); 317 | exchange.endExchange(); 318 | } 319 | }, user, us); 320 | }); 321 | } 322 | 323 | private void loadCachedInfo(StreamsConsumer consumer, IptvUser user, UserStreams us) { 324 | boolean startReq; 325 | 326 | user.lock(); 327 | try { 328 | startReq = us.consumers.size() == 0; 329 | us.consumers.add(consumer); 330 | } finally { 331 | user.unlock(); 332 | } 333 | 334 | if (startReq) { 335 | loadInfo( 336 | RequestCounter.next(), 337 | 0, 338 | System.currentTimeMillis() + (us.isCatchup ? server.getCatchupTotalTimeoutMs() : server.getInfoTotalTimeoutMs()), 339 | user, 340 | us 341 | ); 342 | } 343 | } 344 | 345 | private void loadInfo(String rid, int retryNo, long expireTime, IptvUser user, UserStreams us) { 346 | LOG.info("{}[{}] loading channel: {}, url: {}, retry: {}", rid, user.getId(), channelName, us.channelUrl, retryNo); 347 | 348 | long timeout = us.isCatchup ? server.getCatchupTimeoutMs() : server.getInfoTimeoutMs(); 349 | timeout = Math.min(Math.max(100, expireTime - System.currentTimeMillis()), timeout); 350 | 351 | final long startNanos = System.nanoTime(); 352 | httpClient.sendAsync(createRequest(us.channelUrl, user), HttpResponse.BodyHandlers.ofString()) 353 | .orTimeout(timeout, TimeUnit.MILLISECONDS) 354 | .whenComplete((resp, err) -> { 355 | if (HttpUtils.isOk(resp, err, rid, startNanos)) { 356 | String[] info = resp.body().split("\n"); 357 | 358 | Digest digest = Digest.sha256(); 359 | StringBuilder sb = new StringBuilder(); 360 | 361 | Map streamMap = new HashMap<>(); 362 | Streams streams = new Streams(); 363 | 364 | long durationMillis = 0; 365 | 366 | for (String l : info) { 367 | l = l.trim(); 368 | 369 | if (l.startsWith("#")) { 370 | if (l.startsWith(TAG_EXTINF)) { 371 | String v = l.substring(TAG_EXTINF.length()); 372 | int idx = v.indexOf(','); 373 | if (idx >= 0) { 374 | v = v.substring(0, idx); 375 | } 376 | 377 | try { 378 | durationMillis = new BigDecimal(v).multiply(new BigDecimal(1000)).longValue(); 379 | streams.maxDuration = Math.max(streams.maxDuration, durationMillis); 380 | } catch (NumberFormatException e) { 381 | // do nothing 382 | } 383 | } else if (l.startsWith(TAG_TARGET_DURATION)) { 384 | try { 385 | long targetDuration = new BigDecimal(l.substring(TAG_TARGET_DURATION.length())).multiply(new BigDecimal(1000)).longValue(); 386 | streams.maxDuration = Math.max(streams.maxDuration, targetDuration); 387 | } catch (NumberFormatException e) { 388 | // do nothing 389 | } 390 | } 391 | 392 | sb.append(l).append("\n"); 393 | } else { 394 | // transform url 395 | if (!l.startsWith("http://") && !l.startsWith("https://")) { 396 | int idx = channelUrl.lastIndexOf('/'); 397 | if (idx >= 0) { 398 | l = channelUrl.substring(0, idx + 1) + l; 399 | } 400 | } 401 | 402 | try { 403 | URI streamUri = new URI(l); 404 | // we need to redownload m3u8 if m3u8 is found insteadof .ts streams 405 | if (streamUri.getPath().endsWith(".m3u8") || streamUri.getPath().endsWith(".m3u")) { 406 | URI baseUri = new URI(us.channelUrl); 407 | us.channelUrl = baseUri.resolve(streamUri).toString(); 408 | loadInfo(rid, retryNo, expireTime, user, us); 409 | return; 410 | } 411 | } catch (URISyntaxException e) { 412 | // probably we need to just skip this ? 413 | LOG.trace("error parsing stream url", e); 414 | } 415 | 416 | 417 | String path = digest.digest(l) + ".ts"; 418 | Stream s = new Stream(path, l, sb.toString(), durationMillis); 419 | streamMap.put(path, s); 420 | streams.streams.add(s); 421 | 422 | sb = new StringBuilder(); 423 | 424 | durationMillis = 0; 425 | } 426 | } 427 | 428 | List cs; 429 | 430 | user.lock(); 431 | try { 432 | us.streamMap = streamMap; 433 | us.maxDuration = streams.maxDuration; 434 | 435 | us.infoTimeout = calculateTimeout(us.maxDuration); 436 | user.setExpireTime(System.currentTimeMillis() + us.infoTimeout); 437 | 438 | cs = us.getAndClearConsumers(); 439 | } finally { 440 | user.unlock(); 441 | } 442 | 443 | cs.forEach(c -> c.onInfo(streams, -1, retryNo)); 444 | } else { 445 | if (System.currentTimeMillis() < expireTime) { 446 | LOG.info("{}[{}] will retry", rid, user.getId()); 447 | 448 | scheduler.schedule( 449 | () -> loadInfo(rid, retryNo + 1, expireTime, user, us), 450 | us.isCatchup ? server.getCatchupRetryDelayMs() : server.getInfoRetryDelayMs(), 451 | TimeUnit.MILLISECONDS 452 | ); 453 | } else { 454 | if (server.getChannelFailedMs() > 0) { 455 | user.lock(); 456 | try { 457 | LOG.warn("{}[{}] channel failed", rid, user.getId()); 458 | failedUntil = System.currentTimeMillis() + server.getChannelFailedMs(); 459 | user.releaseChannel(); 460 | } finally { 461 | user.unlock(); 462 | } 463 | } else { 464 | LOG.warn("{}[{}] streams failed", rid, user.getId()); 465 | } 466 | 467 | int statusCode = resp == null ? HttpURLConnection.HTTP_INTERNAL_ERROR : resp.statusCode(); 468 | us.getAndClearConsumers().forEach(c -> c.onInfo(null, statusCode, retryNo)); 469 | } 470 | } 471 | }); 472 | } 473 | 474 | private UserStreams createUserStreams(HttpServerExchange exchange, IptvUser user) { 475 | String url = createChannelUrl(exchange); 476 | 477 | // user is locked here 478 | UserStreams us = userStreams.get(user.getId()); 479 | if (us == null || !us.channelUrl.equals(url)) { 480 | boolean isCatchup = exchange.getQueryParameters().containsKey("utc") || 481 | exchange.getQueryParameters().containsKey("lutc"); 482 | us = new UserStreams(isCatchup ? defaultCatchupTimeout : defaultInfoTimeout, url); 483 | us.isCatchup = isCatchup; 484 | userStreams.put(user.getId(), us); 485 | } 486 | 487 | return us; 488 | } 489 | 490 | private String createChannelUrl(HttpServerExchange exchange) { 491 | Map qp = new TreeMap<>(); 492 | exchange.getQueryParameters().forEach((k, v) -> { 493 | // skip our token tag 494 | if (!"t".equals(k)) { 495 | if (v.size() > 0) { 496 | qp.put(k, v.getFirst()); 497 | } 498 | } 499 | }); 500 | 501 | if (qp.isEmpty()) { 502 | return channelUrl; 503 | } 504 | 505 | URI uri; 506 | 507 | try { 508 | uri = new URI(channelUrl); 509 | } catch (URISyntaxException se) { 510 | throw new RuntimeException(se); 511 | } 512 | 513 | if (uri.getRawQuery() != null && !uri.getRawQuery().isBlank()) { 514 | for (String pair : uri.getRawQuery().split("&")) { 515 | int idx = pair.indexOf('='); 516 | String key = URLDecoder.decode(idx >= 0 ? pair.substring(0, idx) : pair, StandardCharsets.UTF_8); 517 | String value = idx < 0 ? null : URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8); 518 | qp.putIfAbsent(key, value); 519 | } 520 | } 521 | 522 | StringBuilder q = new StringBuilder(); 523 | qp.forEach((k, v) -> { 524 | if (q.length() > 0) { 525 | q.append('&'); 526 | } 527 | 528 | q.append(URLEncoder.encode(k, StandardCharsets.UTF_8)); 529 | if (v != null) { 530 | q.append('=').append(URLEncoder.encode(v, StandardCharsets.UTF_8)); 531 | } 532 | }); 533 | 534 | try { 535 | return new URI( 536 | uri.getScheme(), uri.getUserInfo(), uri.getHost(), 537 | uri.getPort(), uri.getPath(), q.toString(), uri.getFragment() 538 | ).toString(); 539 | } catch (URISyntaxException e) { 540 | throw new RuntimeException(e); 541 | } 542 | } 543 | } 544 | --------------------------------------------------------------------------------