├── .gitignore ├── LICENSE.TXT ├── README.md ├── application.properties ├── build.gradle ├── local.properties.sample └── src └── main └── uk └── co └── la1tv └── dvrBridgeService ├── Application.java ├── config └── AppConfig.java ├── controllers └── MainController.java ├── filters └── ApiSecretAuthFilter.java ├── handlers ├── GetUrlRequestHandler.java ├── IRequestHandler.java ├── PingHandler.java ├── RemoveRequestHandler.java ├── RequestHandlers.java ├── StartRequestHandler.java └── StopRequestHandler.java ├── helpers ├── FileHelper.java └── M3u8ParserHelper.java ├── hlsRecorder ├── DownloadManager.java ├── HlsPlaylist.java ├── HlsPlaylistCapture.java ├── HlsPlaylistCaptureState.java ├── HlsSegment.java ├── HlsSegmentFile.java ├── HlsSegmentFileProxy.java ├── HlsSegmentFileState.java ├── HlsSegmentFileStore.java ├── HlsVariantPlaylist.java ├── ICaptureStateChangeListener.java ├── IHlsSegmentFileDownloadCallback.java ├── IHlsSegmentFileStateChangeListener.java ├── IPlaylistUpdatedListener.java ├── SegmentTimeRoundMode.java └── exceptions │ ├── IncompletePlaylistException.java │ └── PlaylistRequestException.java ├── httpExceptions ├── InternalServerErrorException.java └── ResourceNotFoundException.java ├── servableFiles ├── ServableFile.java └── ServableFileGenerator.java └── streamManager ├── ISiteStream.java ├── ISiteStreamCaptureRemovedListener.java ├── MasterStreamManager.java ├── SiteStream.java ├── StreamManager.java ├── VariantSiteStream.java └── VariantStreamManager.java /.gitignore: -------------------------------------------------------------------------------- 1 | local.properties 2 | public 3 | 4 | # Rest created by https://www.gitignore.io 5 | ### Gradle ### 6 | .gradle 7 | build/ 8 | 9 | # Ignore Gradle GUI config 10 | gradle-app.setting 11 | 12 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 13 | !gradle-wrapper.jar 14 | 15 | 16 | ### Java ### 17 | *.class 18 | 19 | # Mobile Tools for Java (J2ME) 20 | .mtj.tmp/ 21 | 22 | # Package Files # 23 | *.jar 24 | *.war 25 | *.ear 26 | 27 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 28 | hs_err_pid* 29 | /bin/ 30 | 31 | ### Eclipse ### 32 | *.pydevproject 33 | .metadata 34 | .gradle 35 | bin/ 36 | tmp/ 37 | *.tmp 38 | *.bak 39 | *.swp 40 | *~.nib 41 | local.properties 42 | .settings/ 43 | .loadpath 44 | 45 | # Eclipse Core 46 | .project 47 | 48 | # External tool builders 49 | .externalToolBuilders/ 50 | 51 | # Locally stored "Eclipse launch configurations" 52 | *.launch 53 | 54 | # CDT-specific 55 | .cproject 56 | 57 | # JDT-specific (Eclipse Java Development Tools) 58 | .classpath 59 | 60 | # PDT-specific 61 | .buildpath 62 | 63 | # sbteclipse plugin 64 | .target 65 | 66 | # TeXlipse plugin 67 | .texlipse 68 | 69 | 70 | ### Intellij ### 71 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 72 | 73 | *.iml 74 | 75 | ## Directory-based project format: 76 | .idea/ 77 | # if you remove the above rule, at least ignore the following: 78 | 79 | # User-specific stuff: 80 | # .idea/workspace.xml 81 | # .idea/tasks.xml 82 | # .idea/dictionaries 83 | 84 | # Sensitive or high-churn files: 85 | # .idea/dataSources.ids 86 | # .idea/dataSources.xml 87 | # .idea/sqlDataSources.xml 88 | # .idea/dynamic.xml 89 | # .idea/uiDesigner.xml 90 | 91 | # Gradle: 92 | # .idea/gradle.xml 93 | # .idea/libraries 94 | 95 | # Mongo Explorer plugin: 96 | # .idea/mongoSettings.xml 97 | 98 | ## File-based project format: 99 | *.ipr 100 | *.iws 101 | 102 | ## Plugin-specific files: 103 | 104 | # IntelliJ 105 | out/ 106 | 107 | # mpeltonen/sbt-idea plugin 108 | .idea_modules/ 109 | 110 | # JIRA plugin 111 | atlassian-ide-plugin.xml 112 | 113 | # Crashlytics plugin (for Android Studio and IntelliJ) 114 | com_crashlytics_export_strings.xml 115 | crashlytics.properties 116 | crashlytics-build.properties 117 | 118 | 119 | ### NetBeans ### 120 | nbproject/private/ 121 | build/ 122 | nbbuild/ 123 | dist/ 124 | nbdist/ 125 | nbactions.xml 126 | nb-configuration.xml 127 | .nb-gradle/ 128 | 129 | -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution-ShareAlike 4.0 International Public License 2 | http://creativecommons.org/licenses/by-sa/4.0/ 3 | 4 | By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. 5 | 6 | Section 1 � Definitions. 7 | 8 | Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. 9 | Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. 10 | BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License. 11 | Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. 12 | Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. 13 | Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. 14 | License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike. 15 | Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. 16 | Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. 17 | Licensor means the individual(s) or entity(ies) granting rights under this Public License. 18 | Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. 19 | Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. 20 | You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. 21 | Section 2 � Scope. 22 | 23 | License grant. 24 | Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: 25 | reproduce and Share the Licensed Material, in whole or in part; and 26 | produce, reproduce, and Share Adapted Material. 27 | Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 28 | Term. The term of this Public License is specified in Section 6(a). 29 | Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. 30 | Downstream recipients. 31 | Offer from the Licensor � Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. 32 | Additional offer from the Licensor � Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter�s License You apply. 33 | No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 34 | No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). 35 | Other rights. 36 | 37 | Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 38 | Patent and trademark rights are not licensed under this Public License. 39 | To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. 40 | Section 3 � License Conditions. 41 | 42 | Your exercise of the Licensed Rights is expressly made subject to the following conditions. 43 | 44 | Attribution. 45 | 46 | If You Share the Licensed Material (including in modified form), You must: 47 | 48 | retain the following if it is supplied by the Licensor with the Licensed Material: 49 | identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); 50 | a copyright notice; 51 | a notice that refers to this Public License; 52 | a notice that refers to the disclaimer of warranties; 53 | a URI or hyperlink to the Licensed Material to the extent reasonably practicable; 54 | indicate if You modified the Licensed Material and retain an indication of any previous modifications; and 55 | indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 56 | You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 57 | If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 58 | ShareAlike. 59 | In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. 60 | 61 | The Adapter�s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License. 62 | You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. 63 | You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. 64 | Section 4 � Sui Generis Database Rights. 65 | 66 | Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: 67 | 68 | for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; 69 | if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and 70 | You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. 71 | For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. 72 | Section 5 � Disclaimer of Warranties and Limitation of Liability. 73 | 74 | Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. 75 | To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. 76 | The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. 77 | Section 6 � Term and Termination. 78 | 79 | This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. 80 | Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 81 | 82 | automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 83 | upon express reinstatement by the Licensor. 84 | For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 85 | For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 86 | Sections 1, 5, 6, 7, and 8 survive termination of this Public License. 87 | Section 7 � Other Terms and Conditions. 88 | 89 | The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. 90 | Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. 91 | Section 8 � Interpretation. 92 | 93 | For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. 94 | To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. 95 | No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. 96 | Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DVR-Bridge-Service 2 | A service which records one or more hls streams simultaneously and re-serves them as a different hls streams. It is controlled via http requests to various endpoints which handle starting the recording, stopping the recording and removing the recording from the server when it is no longer needed. The source stream playlist url is provided in the START request and the url to the generated playlist can be retrieved with the GET_URL request. 3 | 4 | It supports both [variant](https://developer.apple.com/library/ios/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-BASIC_VARIANT_PLAYLIST) and standard playlists, and generates "[EVENT](https://developer.apple.com/library/ios/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-EVENT_PLAYLIST)" type playlists. 5 | 6 | It uses https://github.com/LA1TV/M3U8-Parser to parse the remote hls playlist files. 7 | 8 | Requests 9 | --- 10 | The following shows the requests that are made, when they are made, and the action the service performs as a result of the requests. It also shows what would cause each request from our website. 11 | 12 | Each request will be to the provided url and the data will be provided as simple post data. This makes it possible for extra data to be included and passed to the bridge service implementation in the url with parameters. For this bridge service implementation the url needs to contain a "hlsPlaylistUrl" parameter, which is the source playlist url, and a "secret" parameter, where the secret is configured in the config and forms a basic form of authentication. An example url would be: https://stream1.la1tv.co.uk:3456/dvrBridgeService?secret=super_secret_string&hlsPlaylistUrl=http://www.nasa.gov/multimedia/nasatv/NTV-Public-IPS.m3u8 13 | 14 | | Cause Of Request | Request Frequency | Data | Task | Response (JSON) 15 | --------------------|--------------------------------|-----------|-----------|--------------------------| 16 | Media Item Marked Live | Once Whenever This Happens | | Start recording and generate url to playlist that can be retrieved with "GET_URL" request type. If this is occurs more than once this will work as if the "REMOVE" action has happened just before it. | `{url: <>}` 17 | Media Item Marked As Over | Once Whenever This Happens | | Stop recording. Url will still be available from request with "GET_URL" type. Multiple calls will be no-ops. | `<>` 18 | Media Item Live/Over And No VOD | Repeated Whilst This Is The Case | | Responses with a 200 status code if everything is good and the recording is still available, otherwise return an error status code. If not received a ping after a duration longer than the ping interval (configured in config), then the recording will be terminated and removed. | `<>` 19 | Media Item VOD Uploaded (and accessible), or gone back to "not live" state | Once Whenever This Happens |
  • type=REMOVE
  • id=[a unique id which represents the stream (<= 100 characters)]
| Removes the recording from the server, and hence the stream url. Multiple calls are no-ops. | `<>` 20 | Url is needed to send to player in browser | Whenever needed. |
  • type=GET_URL
  • id=[a unique id which represents the stream (<= 100 characters)]
| Returns the url to the generated hls playlist. | `{url: <>}` 21 | 22 | All successful requests will get the http status code 200 in the response. If something goes wrong or something was unsuccessful an error code will be returned instead. 23 | -------------------------------------------------------------------------------- /application.properties: -------------------------------------------------------------------------------- 1 | # interval in milliseconds between making requests for remote playlist to check for changes 2 | app.playlistUpdateInterval=900 3 | # the maximum amount of time to wait (in seconds) before timing out the a segment download 4 | app.downloadTimeout=40 5 | # number of times to retry a download before failing it 6 | app.downloadRetryCount=3 7 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | } 5 | dependencies { 6 | classpath("org.springframework.boot:spring-boot-gradle-plugin:1.2.3.RELEASE") 7 | } 8 | } 9 | 10 | apply plugin: 'java' 11 | apply plugin: 'eclipse' 12 | apply plugin: 'idea' 13 | apply plugin: 'spring-boot' 14 | 15 | 16 | sourceSets.main.java.srcDirs = ['src'] 17 | jar { 18 | baseName = 'la1tv-dvr-bridge-service' 19 | version = '0.1.0' 20 | manifest { 21 | attributes 'Main-Class': 'uk.co.la1tv.dvrBridgeService.Application' 22 | } 23 | } 24 | 25 | repositories { 26 | mavenCentral() 27 | } 28 | 29 | sourceCompatibility = 1.7 30 | targetCompatibility = 1.7 31 | 32 | dependencies { 33 | // tag::jetty[] 34 | compile("org.springframework.boot:spring-boot-starter-web") { 35 | exclude module: "spring-boot-starter-tomcat" 36 | } 37 | compile("org.springframework.boot:spring-boot-starter-jetty") 38 | // end::jetty[] 39 | // tag::actuator[] 40 | compile("org.springframework.boot:spring-boot-starter-actuator") 41 | // end::actuator[] 42 | compile("com.googlecode.json-simple:json-simple:1.1.1") 43 | compile("commons-logging:commons-logging:1.1.1") 44 | compile("org.apache.commons:commons-exec:1.3") 45 | testCompile("junit:junit") 46 | } 47 | 48 | task wrapper(type: Wrapper) { 49 | gradleVersion = '2.3' 50 | } -------------------------------------------------------------------------------- /local.properties.sample: -------------------------------------------------------------------------------- 1 | # the value that must be provided in a secret url param on each request 2 | auth.secret=test 3 | # the path to the node executable 4 | m3u8Parser.nodePath=C:\\Program Files\\nodejs\\node 5 | # the path to the m3u8 parser node application file to be executed by node 6 | m3u8Parser.applicationJsPath=C:\\\M3U8-Parser\\application.js 7 | 8 | # the directory that is served by a web server. 9 | app.webDirectory=public 10 | # the url that maps to the above directory 11 | app.webDirectoryBaseUrl=http://127.0.0.1/hls/ 12 | # a unique id for this server 13 | # this will become a subdirectory in the web directory. 14 | app.serverId=1 15 | # number of seconds after which if a ping request has not been received it is assumed the web server has died 16 | # a time of 0 disables the time limit 17 | app.inactivityTimeLimit=90 18 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/Application.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService; 2 | 3 | 4 | import javax.annotation.PostConstruct; 5 | 6 | import org.apache.log4j.Logger; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.SpringApplication; 9 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 10 | import org.springframework.context.ApplicationContext; 11 | import org.springframework.context.annotation.ComponentScan; 12 | 13 | @ComponentScan 14 | @EnableAutoConfiguration 15 | public class Application { 16 | private static Logger logger = Logger.getLogger(Application.class); 17 | 18 | public static void main(String[] args) { 19 | SpringApplication.run(Application.class, args); 20 | } 21 | 22 | 23 | @Autowired 24 | private ApplicationContext context; 25 | 26 | @PostConstruct 27 | public void onPostConstruct() { 28 | logger.info("Application loaded!"); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/config/AppConfig.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.context.annotation.PropertySource; 5 | 6 | @Configuration 7 | @PropertySource({ 8 | "file:application.properties", 9 | "file:local.properties" // this should be excluded from version control 10 | }) 11 | public class AppConfig { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/controllers/MainController.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.controllers; 2 | 3 | import java.util.Map; 4 | 5 | import javax.servlet.http.HttpServletRequest; 6 | 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RequestMethod; 10 | import org.springframework.web.bind.annotation.RequestParam; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | import uk.co.la1tv.dvrBridgeService.handlers.IRequestHandler; 14 | import uk.co.la1tv.dvrBridgeService.handlers.RequestHandlers; 15 | import uk.co.la1tv.dvrBridgeService.httpExceptions.InternalServerErrorException; 16 | 17 | @RestController 18 | public class MainController { 19 | 20 | private final RequestHandlers requestHandlers; 21 | 22 | @Autowired 23 | public MainController(RequestHandlers requestHandlers) { 24 | this.requestHandlers = requestHandlers; 25 | } 26 | 27 | @RequestMapping(value = "/dvrBridgeService", method = RequestMethod.POST) 28 | public Object handlePost(HttpServletRequest request, @RequestParam("type") String type, @RequestParam("id") String streamId) { 29 | 30 | IRequestHandler handler = requestHandlers.getRequestHandlerForType(type); 31 | if (handler == null) { 32 | throw(new InternalServerErrorException("Unknown type.")); 33 | } 34 | 35 | if (streamId == null || streamId.length() > 100) { 36 | throw(new InternalServerErrorException("Invalid id.")); 37 | } 38 | 39 | Map requestParameters = request.getParameterMap(); 40 | return handler.handle(streamId, requestParameters); 41 | } 42 | 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/filters/ApiSecretAuthFilter.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.filters; 2 | 3 | import java.io.IOException; 4 | 5 | import javax.servlet.FilterChain; 6 | import javax.servlet.ServletException; 7 | import javax.servlet.http.HttpServletRequest; 8 | import javax.servlet.http.HttpServletResponse; 9 | 10 | import org.springframework.beans.factory.annotation.Value; 11 | import org.springframework.core.annotation.Order; 12 | import org.springframework.stereotype.Component; 13 | import org.springframework.web.filter.OncePerRequestFilter; 14 | 15 | @Component 16 | @Order(1) 17 | public class ApiSecretAuthFilter extends OncePerRequestFilter { 18 | 19 | @Value("${auth.secret}") 20 | private String secret; 21 | 22 | @Override 23 | protected void doFilterInternal(HttpServletRequest request, 24 | HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { 25 | 26 | String providedSecret = request.getParameter("secret"); 27 | if (providedSecret == null || !providedSecret.equals(secret)) { 28 | response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid secret."); 29 | return; 30 | } 31 | 32 | filterChain.doFilter(request, response); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/handlers/GetUrlRequestHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.handlers; 2 | 3 | import java.net.URL; 4 | import java.util.HashMap; 5 | import java.util.Map; 6 | 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Component; 9 | 10 | import uk.co.la1tv.dvrBridgeService.httpExceptions.InternalServerErrorException; 11 | import uk.co.la1tv.dvrBridgeService.streamManager.ISiteStream; 12 | import uk.co.la1tv.dvrBridgeService.streamManager.MasterStreamManager; 13 | 14 | @Component 15 | public class GetUrlRequestHandler implements IRequestHandler { 16 | 17 | @Autowired 18 | private MasterStreamManager streamManager; 19 | 20 | @Override 21 | public String getType() { 22 | return "GET_URL"; 23 | } 24 | 25 | @Override 26 | public Object handle(String streamId, Map requestParameters) { 27 | ISiteStream stream = streamManager.getStream(streamId); 28 | if (stream == null) { 29 | throw(new InternalServerErrorException("Unable to retrieve url for some reason.")); 30 | } 31 | URL url = stream.getPlaylistUrl(); 32 | if (url == null) { 33 | throw(new InternalServerErrorException("Unable to retrieve url for some reason.")); 34 | } 35 | HashMap response = new HashMap<>(); 36 | response.put("url", url.toExternalForm()); 37 | return response; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/handlers/IRequestHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.handlers; 2 | 3 | import java.util.Map; 4 | 5 | public interface IRequestHandler { 6 | 7 | // get the string that represents the type of request that this handler handles 8 | public String getType(); 9 | 10 | // handle the request and return what should be returned to the user 11 | // request parameters is the data that has been provided in the query string (and post data) 12 | public Object handle(String streamId, Map requestParameters); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/handlers/PingHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.handlers; 2 | 3 | import java.util.Map; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.stereotype.Component; 7 | 8 | import uk.co.la1tv.dvrBridgeService.httpExceptions.InternalServerErrorException; 9 | import uk.co.la1tv.dvrBridgeService.streamManager.ISiteStream; 10 | import uk.co.la1tv.dvrBridgeService.streamManager.MasterStreamManager; 11 | 12 | @Component 13 | public class PingHandler implements IRequestHandler { 14 | 15 | @Autowired 16 | private MasterStreamManager streamManager; 17 | 18 | @Override 19 | public String getType() { 20 | return "PING"; 21 | } 22 | 23 | @Override 24 | public Object handle(String streamId, Map requestParameters) { 25 | ISiteStream stream = streamManager.getStream(streamId); 26 | if (stream == null || !stream.hasCapture()) { 27 | throw(new InternalServerErrorException("Unable find stream or stream doesn't have capture.")); 28 | } 29 | stream.registerActivity(); 30 | return null; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/handlers/RemoveRequestHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.handlers; 2 | 3 | import java.util.Map; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.stereotype.Component; 7 | 8 | import uk.co.la1tv.dvrBridgeService.httpExceptions.InternalServerErrorException; 9 | import uk.co.la1tv.dvrBridgeService.streamManager.ISiteStream; 10 | import uk.co.la1tv.dvrBridgeService.streamManager.MasterStreamManager; 11 | 12 | @Component 13 | public class RemoveRequestHandler implements IRequestHandler { 14 | 15 | @Autowired 16 | private MasterStreamManager streamManager; 17 | 18 | @Override 19 | public String getType() { 20 | return "REMOVE"; 21 | } 22 | 23 | @Override 24 | public Object handle(String streamId, Map requestParameters) { 25 | ISiteStream stream = streamManager.getStream(streamId); 26 | if (stream == null || !stream.removeCapture()) { 27 | throw(new InternalServerErrorException("Unable to remove the capture for some reason.")); 28 | } 29 | return null; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/handlers/RequestHandlers.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.handlers; 2 | 3 | import java.util.Set; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.stereotype.Service; 7 | 8 | @Service 9 | public class RequestHandlers { 10 | 11 | private final Set requestHandlers; 12 | 13 | @Autowired 14 | // tasks is automatically populated with all beans that are of type RequestHandler by spring 15 | public RequestHandlers(Set requestHandlers) { 16 | this.requestHandlers = requestHandlers; 17 | } 18 | 19 | public IRequestHandler getRequestHandlerForType(String requestType) { 20 | synchronized(requestHandlers) { 21 | for(IRequestHandler handler : requestHandlers) { 22 | if (handler.getType().equals(requestType)) { 23 | return handler; 24 | } 25 | } 26 | } 27 | return null; 28 | } 29 | 30 | public IRequestHandler[] getHandlers() { 31 | return requestHandlers.toArray(new IRequestHandler[requestHandlers.size()]); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/handlers/StartRequestHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.handlers; 2 | 3 | import java.net.MalformedURLException; 4 | import java.net.URL; 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.stereotype.Component; 10 | 11 | import uk.co.la1tv.dvrBridgeService.httpExceptions.InternalServerErrorException; 12 | import uk.co.la1tv.dvrBridgeService.streamManager.ISiteStream; 13 | import uk.co.la1tv.dvrBridgeService.streamManager.MasterStreamManager; 14 | 15 | @Component 16 | public class StartRequestHandler implements IRequestHandler { 17 | 18 | @Autowired 19 | private MasterStreamManager streamManager; 20 | 21 | @Override 22 | public String getType() { 23 | return "START"; 24 | } 25 | 26 | @Override 27 | public Object handle(String streamId, Map requestParameters) { 28 | String[] tmp = requestParameters.get("hlsPlaylistUrl"); 29 | if (tmp == null) { 30 | throw(new InternalServerErrorException("\"hlsPlaylistUrl\" parameter is missing from the request url and is required.")); 31 | } 32 | 33 | // the url of the remote playlist 34 | String hlsPlaylistUrlStr = requestParameters.get("hlsPlaylistUrl")[0]; 35 | URL hlsPlaylistUrl; 36 | try { 37 | hlsPlaylistUrl = new URL(hlsPlaylistUrlStr); 38 | } catch (MalformedURLException e) { 39 | throw(new InternalServerErrorException("The provided hls playlist url is invalid.")); 40 | } 41 | ISiteStream stream = streamManager.createStream(streamId, hlsPlaylistUrl); 42 | if (stream == null) { 43 | throw(new InternalServerErrorException("Unable to start capture for some reason.")); 44 | } 45 | URL url = stream.getPlaylistUrl(); 46 | HashMap response = new HashMap<>(); 47 | response.put("url", url.toExternalForm()); 48 | return response; 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/handlers/StopRequestHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.handlers; 2 | 3 | import java.util.Map; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.stereotype.Component; 7 | 8 | import uk.co.la1tv.dvrBridgeService.httpExceptions.InternalServerErrorException; 9 | import uk.co.la1tv.dvrBridgeService.streamManager.ISiteStream; 10 | import uk.co.la1tv.dvrBridgeService.streamManager.MasterStreamManager; 11 | 12 | @Component 13 | public class StopRequestHandler implements IRequestHandler { 14 | 15 | @Autowired 16 | private MasterStreamManager streamManager; 17 | 18 | @Override 19 | public String getType() { 20 | return "STOP"; 21 | } 22 | 23 | @Override 24 | public Object handle(String streamId, Map requestParameters) { 25 | ISiteStream stream = streamManager.getStream(streamId); 26 | if (stream == null || !stream.stopCapture()) { 27 | throw(new InternalServerErrorException("Unable to stop the capture for some reason.")); 28 | } 29 | return null; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/helpers/FileHelper.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.helpers; 2 | 3 | import java.io.File; 4 | import java.util.Arrays; 5 | 6 | public class FileHelper { 7 | 8 | /** 9 | * Formats the path passed in so that it is correct for the filesystem it's running on. 10 | * @param path 11 | * @return Formatted path 12 | */ 13 | public static String format(String path) { 14 | char sep = System.getProperty("file.separator").charAt(0); 15 | return path.replace(sep == '\\' ? '/' : '\\', sep); 16 | } 17 | 18 | /** 19 | * Return the extension from the file name if there is one or null otherwise. 20 | * @param filename 21 | * @return 22 | */ 23 | public static String getExtension(String filename) { 24 | String[] parts = filename.split("\\."); 25 | if (parts.length == 0) { 26 | return null; 27 | } 28 | return parts[parts.length-1]; 29 | } 30 | 31 | public static void purgeDirectory(File dir, String[] extensions) { 32 | for (File file: dir.listFiles()) { 33 | if (file.isDirectory()) purgeDirectory(file, extensions); 34 | if (Arrays.asList(extensions).contains(getExtension(file.getName()))) { 35 | file.delete(); 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/helpers/M3u8ParserHelper.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.helpers; 2 | 3 | import java.io.ByteArrayOutputStream; 4 | import java.io.IOException; 5 | import java.net.URL; 6 | 7 | import org.apache.commons.exec.CommandLine; 8 | import org.apache.commons.exec.DefaultExecutor; 9 | import org.apache.commons.exec.PumpStreamHandler; 10 | import org.apache.log4j.Logger; 11 | import org.json.simple.JSONArray; 12 | import org.json.simple.JSONObject; 13 | import org.json.simple.JSONValue; 14 | import org.json.simple.parser.ParseException; 15 | import org.springframework.beans.factory.annotation.Value; 16 | import org.springframework.stereotype.Service; 17 | 18 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.exceptions.PlaylistRequestException; 19 | 20 | @Service 21 | public class M3u8ParserHelper { 22 | 23 | private static Logger logger = Logger.getLogger(M3u8ParserHelper.class); 24 | 25 | @Value("${m3u8Parser.nodePath}") 26 | private String nodePath; 27 | 28 | @Value("${m3u8Parser.applicationJsPath}") 29 | private String m3u8ParserApplicationPath; 30 | 31 | /** 32 | * Make request to get variant playlist, parse it, and return info. 33 | * @return 34 | * @throws PlaylistRequestException 35 | */ 36 | public JSONObject getPlaylistInfo(URL playlistUrl) throws PlaylistRequestException { 37 | String playlistUrlString = playlistUrl.toExternalForm(); 38 | 39 | ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 40 | CommandLine commandLine = new CommandLine(FileHelper.format(nodePath)); 41 | commandLine.addArgument(FileHelper.format(m3u8ParserApplicationPath)); 42 | commandLine.addArgument(playlistUrlString); 43 | DefaultExecutor exec = new DefaultExecutor(); 44 | // handle the stdout stream, ignore error stream 45 | PumpStreamHandler streamHandler = new PumpStreamHandler(outputStream, null); 46 | exec.setStreamHandler(streamHandler); 47 | int exitVal; 48 | try { 49 | exitVal = exec.execute(commandLine); 50 | } catch (IOException e1) { 51 | e1.printStackTrace(); 52 | logger.warn("Error trying to retrieve playlist information."); 53 | throw(new PlaylistRequestException()); 54 | } 55 | if (exitVal != 0) { 56 | logger.warn("Error trying to retrieve playlist information."); 57 | throw(new PlaylistRequestException()); 58 | } 59 | String playlistInfoJsonString = outputStream.toString(); 60 | JSONObject playlistInfo = null; 61 | try { 62 | playlistInfo = (JSONObject) JSONValue.parseWithException(playlistInfoJsonString); 63 | } catch (ParseException e) { 64 | e.printStackTrace(); 65 | logger.warn("Error trying to retrieve playlist information."); 66 | throw(new PlaylistRequestException()); 67 | } 68 | return playlistInfo; 69 | } 70 | 71 | public boolean isVariantPlaylist(URL playlistUrl) throws PlaylistRequestException { 72 | JSONObject info = getPlaylistInfo(playlistUrl); 73 | try { 74 | return !((JSONArray)((JSONObject) info.get("items")).get("StreamItem")).isEmpty(); 75 | } 76 | catch(Exception e) { 77 | return false; 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/hlsRecorder/DownloadManager.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.hlsRecorder; 2 | 3 | import java.io.File; 4 | import java.io.FileOutputStream; 5 | import java.net.URL; 6 | import java.nio.channels.Channels; 7 | import java.nio.channels.ReadableByteChannel; 8 | import java.util.concurrent.ExecutorService; 9 | import java.util.concurrent.Executors; 10 | 11 | import javax.annotation.PostConstruct; 12 | 13 | import org.apache.log4j.Logger; 14 | import org.springframework.beans.factory.annotation.Value; 15 | import org.springframework.stereotype.Service; 16 | 17 | /** 18 | * Handles all file downloads. 19 | */ 20 | @Service 21 | public class DownloadManager { 22 | 23 | private static Logger logger = Logger.getLogger(DownloadManager.class); 24 | 25 | @Value("${app.downloadTimeout}") 26 | private int downloadTimeout; 27 | @Value("${app.downloadRetryCount}") 28 | private int downloadRetryCount; 29 | 30 | private ExecutorService executor = null; 31 | 32 | @PostConstruct 33 | private void onPostConstruct() { 34 | executor = Executors.newCachedThreadPool(); 35 | } 36 | 37 | /** 38 | * Queue a download. The completionCallback will be informed when the download has completed. 39 | * @param source 40 | * @param destination 41 | * @param completionCalback 42 | */ 43 | public void queueDownload(URL source, File destination, IHlsSegmentFileDownloadCallback completionCalback) { 44 | executor.execute(new Downloader(source, destination, completionCalback)); 45 | } 46 | 47 | private class Downloader implements Runnable { 48 | 49 | private final URL source; 50 | private final File destination; 51 | private final IHlsSegmentFileDownloadCallback callback; 52 | private boolean downloadSucceeded = false; 53 | 54 | public Downloader(URL source, File destination, IHlsSegmentFileDownloadCallback callback) { 55 | this.source = source; 56 | this.destination = destination; 57 | this.callback = callback; 58 | } 59 | 60 | @Override 61 | public void run() { 62 | 63 | if (callback != null) { 64 | callback.onDownloadStart(); 65 | } 66 | 67 | 68 | for(int i=0; i segments = new ArrayList<>(); 47 | // the index of the last segment in the generated playlist 48 | private Integer lastSegmentIndexInGeneratedPlaylist = null; 49 | private boolean addedStartToGeneratedPlaylist = false; 50 | private boolean addedEndListToGeneratedPlaylist = false; 51 | // the maximum length that a segment can be (milliseconds) 52 | // retrieved from the playlist 53 | private Float segmentTargetDuration = null; 54 | private final Timer updateTimer = new Timer(); 55 | private IPlaylistUpdatedListener playlistUpdatedListener = null; 56 | private ICaptureStateChangeListener captureStateChangeListener = null; 57 | private String generatedPlaylistContent = ""; 58 | // the unix time when the next chunk is expected by 59 | private Long nextChunkExpectedTime = null; 60 | 61 | /** 62 | * Create a new object which represents a capture file for a playlist. 63 | * @param playlist The playlist to generate a capture from. 64 | */ 65 | public HlsPlaylistCapture(HlsPlaylist playlist) { 66 | this.playlist = playlist; 67 | } 68 | 69 | /** 70 | * Register a listener to be informed when the generated playlist changes. 71 | * This callback will be in a new thread. (More info in the interface) 72 | * @param playlistUpdatedListener 73 | */ 74 | public void setPlaylistUpdatedListener(IPlaylistUpdatedListener playlistUpdatedListener) { 75 | this.playlistUpdatedListener = playlistUpdatedListener; 76 | } 77 | 78 | /** 79 | * Register a listener to be informed when the capture state changes. 80 | * This callback will be in a new thread. (More info in the interface) 81 | * @param stateChangeListener 82 | */ 83 | public void setStateChangeListener(ICaptureStateChangeListener stateChangeListener) { 84 | this.captureStateChangeListener = stateChangeListener; 85 | } 86 | 87 | private void updateCaptureState(HlsPlaylistCaptureState newState) { 88 | synchronized(lock) { 89 | captureState = newState; 90 | callCaptureStateChangedCallback(newState); 91 | } 92 | } 93 | 94 | /** 95 | * Start capturing. This operation can only be performed once. 96 | * False is returned if the capture could not be started for some reason. 97 | */ 98 | public boolean startCapture() { 99 | synchronized(lock) { 100 | if (captureState != HlsPlaylistCaptureState.NOT_STARTED) { 101 | throw(new RuntimeException("Invalid capture state.")); 102 | } 103 | try { 104 | retrievePlaylistMetadata(); 105 | } catch (PlaylistRequestException e) { 106 | logger.warn("An error occurred retrieving the playlist so capture could not be started."); 107 | return false; 108 | } 109 | updateCaptureState(HlsPlaylistCaptureState.CAPTURING); 110 | captureStartTime = System.currentTimeMillis(); 111 | updateTimer.schedule(new UpdateTimerTask(), 0, playlistUpdateInterval); 112 | generatePlaylistContent(); 113 | return true; 114 | } 115 | } 116 | 117 | /** 118 | * Stop capturing. This operation can only be performed once. 119 | */ 120 | public void stopCapture() { 121 | synchronized(lock) { 122 | if (captureState != HlsPlaylistCaptureState.CAPTURING) { 123 | throw(new RuntimeException("Invalid capture state.")); 124 | } 125 | updateTimer.cancel(); 126 | updateTimer.purge(); 127 | updateCaptureState(HlsPlaylistCaptureState.STOPPED); 128 | generatePlaylistContent(); 129 | } 130 | } 131 | 132 | /** 133 | * Delete the capture. This operation can only be performed once, 134 | * and must be after the capture has stopped. 135 | */ 136 | public void deleteCapture() { 137 | synchronized(lock) { 138 | if (captureState != HlsPlaylistCaptureState.STOPPED) { 139 | throw(new RuntimeException("Invalid capture state.")); 140 | } 141 | 142 | for (HlsSegment segment : segments) { 143 | // release all the files. This allows the HlsSegmentFileStore to delete them 144 | HlsSegmentFileProxy segmentFile = segment.getSegmentFile(); 145 | if (!segmentFile.isReleased()) { 146 | // could be possible for 2 items in the remote playlist to be the same file 147 | // e.g an advert segment repeated several times 148 | segmentFile.release(); 149 | } 150 | } 151 | updateCaptureState(HlsPlaylistCaptureState.DELETED); 152 | } 153 | } 154 | 155 | /** 156 | * Get the unix timestamp (seconds) when the capture started. 157 | * @return the unix timestamp (seconds) 158 | */ 159 | public long getCaptureStartTime() { 160 | synchronized(lock) { 161 | if (captureState == HlsPlaylistCaptureState.NOT_STARTED) { 162 | throw(new RuntimeException("Capture not started yet.")); 163 | } 164 | return captureStartTime; 165 | } 166 | } 167 | 168 | /** 169 | * Get the current capture state. 170 | * @return 171 | */ 172 | public HlsPlaylistCaptureState getCaptureState() { 173 | return captureState; 174 | } 175 | 176 | 177 | /** 178 | * Get the contents of the playlist file that represents this capture 179 | */ 180 | public String getPlaylistContent() { 181 | if (captureState == HlsPlaylistCaptureState.NOT_STARTED) { 182 | throw(new RuntimeException("Capture not started yet.")); 183 | } 184 | else if (captureState == HlsPlaylistCaptureState.DELETED) { 185 | throw(new RuntimeException("This capture has been deleted.")); 186 | } 187 | return generatedPlaylistContent; 188 | } 189 | 190 | 191 | /** 192 | * Generate the playlist content that can be retrieved with getPlaylistContent 193 | */ 194 | private void generatePlaylistContent() { 195 | synchronized(playlistGenerationLock) { 196 | String contents = generatedPlaylistContent; 197 | if (!addedStartToGeneratedPlaylist) { 198 | contents += "#EXTM3U\n"; 199 | contents += "#EXT-X-VERSION:3\n"; 200 | contents += "#EXT-X-ALLOW-CACHE:NO\n"; 201 | contents += "#EXT-X-PLAYLIST-TYPE:EVENT\n"; 202 | // for some reason segmentTargetDuration needs to appear as an int 203 | contents += "#EXT-X-TARGETDURATION:"+Math.round(segmentTargetDuration)+"\n"; 204 | contents += "#EXT-X-MEDIA-SEQUENCE:0\n"; 205 | addedStartToGeneratedPlaylist = true; 206 | } 207 | 208 | // segments might be in the array that haven't actually downloaded yet (or where their download has failed) 209 | boolean allSegmentsDownloaded = true; 210 | synchronized(lock) { 211 | int startSegment = lastSegmentIndexInGeneratedPlaylist == null ? 0 : lastSegmentIndexInGeneratedPlaylist+1; 212 | for(int i=startSegment; i 0 ? segments.get(numSegments-1).getSequenceNumber() : null; 329 | // the next sequence number will always be one more than the last one as per the specification 330 | // if we don't have any segments yet then we will set this to null which will mean just the newest chunk 331 | // will be retrieved 332 | Integer nextSequenceNumber = lastSequenceNumber != null ? lastSequenceNumber+1 : null; 333 | JSONObject playlistInfo = null; 334 | try { 335 | playlistInfo = m3u8ParserHelper.getPlaylistInfo(playlist.getUrl()); 336 | } catch (PlaylistRequestException e) { 337 | logger.warn("Error retrieving playlist so stopping capture."); 338 | stopCapture(); 339 | return; 340 | } 341 | 342 | JSONObject properties = (JSONObject) playlistInfo.get("properties"); 343 | int firstSequenceNumber = Integer.valueOf(String.valueOf(properties.get("mediaSequence"))); 344 | 345 | JSONArray items = extractPlaylistItems(playlistInfo); 346 | if (!items.isEmpty()) { 347 | if (nextSequenceNumber != null) { 348 | if (firstSequenceNumber > nextSequenceNumber) { 349 | // the next chunk we want has left the playlist already 350 | // stop the capture 351 | logger.warn("Next chunk has already left remote playlist so stopping capture."); 352 | stopCapture(); 353 | } 354 | else { 355 | int seqNum = firstSequenceNumber; 356 | for(int i=0; i= nextSequenceNumber) { 358 | // this is a new item 359 | addNewSegment((JSONObject) items.get(i), seqNum); 360 | } 361 | seqNum++; 362 | } 363 | } 364 | } 365 | else { 366 | // just add the newest segment 367 | addNewSegment((JSONObject) items.get(items.size()-1), firstSequenceNumber+items.size()-1); 368 | } 369 | // calculate the time when we should have the next chunk by 370 | nextChunkExpectedTime = System.currentTimeMillis() + Math.round(segments.get(segments.size()-1).getDuration()); 371 | } 372 | } 373 | } 374 | } 375 | 376 | private void addNewSegment(JSONObject item, int seqNum) { 377 | JSONObject itemProperties = (JSONObject) item.get("properties"); 378 | float duration = Float.parseFloat(String.valueOf(itemProperties.get("duration"))); 379 | boolean discontinuityFlag = (boolean) itemProperties.get("discontinuity"); 380 | URL segmentUrl = null; 381 | try { 382 | segmentUrl = new URL(playlist.getUrl(), String.valueOf(itemProperties.get("uri"))); 383 | } catch (MalformedURLException e) { 384 | throw(new IncompletePlaylistException()); 385 | } 386 | HlsSegmentFileProxy hlsSegmentFile = hlsSegmentFileStore.getSegment(segmentUrl); 387 | hlsSegmentFile.registerStateChangeCallback(new HlsSegmentFileStateChangeHandler(hlsSegmentFile)); 388 | synchronized(lock) { 389 | segments.add(new HlsSegment(hlsSegmentFile, seqNum, duration, discontinuityFlag)); 390 | } 391 | } 392 | 393 | private class HlsSegmentFileStateChangeHandler implements IHlsSegmentFileStateChangeListener { 394 | 395 | // the HlsSegmentFile that this handler has been set up for 396 | private final HlsSegmentFileProxy hlsSegmentFile; 397 | private boolean handled = false; 398 | 399 | public HlsSegmentFileStateChangeHandler(HlsSegmentFileProxy hlsSegmentFile) { 400 | this.hlsSegmentFile = hlsSegmentFile; 401 | handleStateChange(hlsSegmentFile.getState()); 402 | } 403 | 404 | private synchronized void handleStateChange(HlsSegmentFileState state) { 405 | if (handled) { 406 | // this can happen if the event is fired just as this is already being 407 | // handled from the constructor 408 | return; 409 | } 410 | 411 | synchronized(lock) { 412 | if (captureState == HlsPlaylistCaptureState.DELETED) { 413 | // if the capture has gone into the deleted state then the 414 | // file will have already been released. 415 | return; 416 | } 417 | 418 | if (state == HlsSegmentFileState.DOWNLOADED) { 419 | // regenerate the playlist content. 420 | generatePlaylistContent(); 421 | } 422 | else if (state == HlsSegmentFileState.DOWNLOAD_FAILED) { 423 | logger.warn("Error downloading playlist chunk so stopping capture."); 424 | if (captureState != HlsPlaylistCaptureState.STOPPED) { 425 | // capture hasn't already been stopped by an earlier failure 426 | stopCapture(); 427 | } 428 | } 429 | else { 430 | return; 431 | } 432 | 433 | // don't care about any more state changes so remove handler 434 | // so can be garbage collected 435 | hlsSegmentFile.unregisterStateChangeCallback(this); 436 | handled = true; 437 | } 438 | } 439 | 440 | @Override 441 | public void onStateChange(HlsSegmentFileState state) { 442 | handleStateChange(state); 443 | } 444 | 445 | } 446 | 447 | } 448 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/hlsRecorder/HlsPlaylistCaptureState.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.hlsRecorder; 2 | 3 | public enum HlsPlaylistCaptureState { 4 | NOT_STARTED, CAPTURING, STOPPED, DELETED; 5 | } 6 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/hlsRecorder/HlsSegment.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.hlsRecorder; 2 | 3 | /** 4 | * Represents a hls segment. 5 | */ 6 | public class HlsSegment { 7 | 8 | private final HlsSegmentFileProxy segmentFile; 9 | private final int sequenceNumber; 10 | private final float duration; // duration of segment 11 | private final boolean discontinuityFlag; 12 | 13 | public HlsSegment(HlsSegmentFileProxy segmentFile, int sequenceNumber, float duration, boolean discontinuityFlag) { 14 | this.segmentFile = segmentFile; 15 | this.sequenceNumber = sequenceNumber; 16 | this.duration = duration; 17 | this.discontinuityFlag = discontinuityFlag; 18 | } 19 | 20 | /** 21 | * Get the reference to the segment file. 22 | * @return 23 | */ 24 | public HlsSegmentFileProxy getSegmentFile() { 25 | return segmentFile; 26 | } 27 | 28 | /** 29 | * Get this segment's sequence number. 30 | * This is the sequence number it was assigned in the remote playlist, NOT the one it 31 | * might get in the generated one. 32 | * @return 33 | */ 34 | public int getSequenceNumber() { 35 | return sequenceNumber; 36 | } 37 | 38 | /** 39 | * Get the duration of the segment in milliseconds. 40 | * @return 41 | */ 42 | public float getDuration() { 43 | return duration; 44 | } 45 | 46 | /** 47 | * Determine if the segment that preceded this is of a different format meaning the 48 | * discontinuity line should be appended before this in the playlist. 49 | * @return 50 | */ 51 | public boolean getDiscontinuityFlag() { 52 | return discontinuityFlag; 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/hlsRecorder/HlsSegmentFile.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.hlsRecorder; 2 | 3 | import java.io.File; 4 | import java.net.URL; 5 | import java.util.HashSet; 6 | 7 | import javax.annotation.PostConstruct; 8 | 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.context.annotation.Scope; 11 | import org.springframework.stereotype.Component; 12 | 13 | import uk.co.la1tv.dvrBridgeService.servableFiles.ServableFile; 14 | 15 | /** 16 | * Represents a hls segment file. 17 | * Also handles downloading the file. 18 | */ 19 | @Component 20 | @Scope("prototype") 21 | public class HlsSegmentFile { 22 | 23 | @Autowired 24 | private DownloadManager downloadManager; 25 | 26 | private final Object lock = new Object(); 27 | 28 | private final URL remoteUrl; // the url that this segment was located at 29 | private final ServableFile localFile; // the local location 30 | private HlsSegmentFileState state = HlsSegmentFileState.DOWNLOAD_PENDING; 31 | private HashSet stateChangeCallbacks = new HashSet<>(); 32 | 33 | // the number of proxies that are wanting to access to the file 34 | private int numProxiesAccessingFile = 0; 35 | 36 | /** 37 | * @param remoteUrl The url where the segment should be downloaded from. 38 | * @param localFile The file where the segment should be downloaded to. 39 | */ 40 | public HlsSegmentFile(URL remoteUrl, ServableFile localFile) { 41 | this.remoteUrl = remoteUrl; 42 | this.localFile = localFile; 43 | } 44 | 45 | @PostConstruct 46 | private void onPostConstruct() { 47 | downloadFile(); 48 | } 49 | 50 | public URL getRemoteUrl() { 51 | return remoteUrl; 52 | } 53 | 54 | public HlsSegmentFileState getState() { 55 | synchronized(lock) { 56 | return state; 57 | } 58 | } 59 | 60 | public File getFile() { 61 | synchronized(lock) { 62 | if (state != HlsSegmentFileState.DOWNLOADED) { 63 | throw(new RuntimeException("This file is not available.")); 64 | } 65 | return localFile; 66 | } 67 | } 68 | 69 | public URL getFileUrl() { 70 | synchronized(lock) { 71 | if (state != HlsSegmentFileState.DOWNLOADED) { 72 | throw(new RuntimeException("This file is not available.")); 73 | } 74 | return localFile.getUrl(); 75 | } 76 | } 77 | 78 | public boolean registerStateChangeCallback(IHlsSegmentFileStateChangeListener callback) { 79 | synchronized(stateChangeCallbacks) { 80 | return stateChangeCallbacks.add(callback); 81 | } 82 | } 83 | 84 | public boolean unregisterStateChangeCallback(IHlsSegmentFileStateChangeListener callback) { 85 | synchronized(stateChangeCallbacks) { 86 | return stateChangeCallbacks.remove(callback); 87 | } 88 | } 89 | 90 | /** 91 | * Delete the file. 92 | * This should only be called from the HlsSegmentFileStore 93 | */ 94 | public boolean deleteFile() { 95 | synchronized(lock) { 96 | if (state != HlsSegmentFileState.DOWNLOADED) { 97 | throw(new RuntimeException("Must be in the DOWNLOADED state in order to be deleted.")); 98 | } 99 | if (numProxiesAccessingFile > 0) { 100 | throw(new RuntimeException("There are currently proxies that haven't called release() yet.")); 101 | } 102 | return localFile.delete(); 103 | } 104 | } 105 | 106 | 107 | /** 108 | * Called by a proxy when it is created for this file. 109 | */ 110 | public void onProxyCreated() { 111 | numProxiesAccessingFile++; 112 | } 113 | 114 | /** 115 | * Called by a proxy when the release method is called on it. 116 | */ 117 | public void onProxyReleased() { 118 | numProxiesAccessingFile--; 119 | } 120 | 121 | /** 122 | * get the number of proxies that are expecting access to this file at the moment. 123 | * @return 124 | */ 125 | public int getNumProxiesAccessingFile() { 126 | return numProxiesAccessingFile; 127 | } 128 | 129 | private void callStateChangeCallbacks(HlsSegmentFileState newState) { 130 | HashSet clone = null; 131 | synchronized(stateChangeCallbacks) { 132 | // need to iterate over a clone because something in the callback might call the unregister method 133 | // and this would cause a concurrent modification exception 134 | clone = new HashSet<>(stateChangeCallbacks); 135 | } 136 | for(IHlsSegmentFileStateChangeListener callback : clone) { 137 | callback.onStateChange(newState); 138 | } 139 | } 140 | 141 | private void updateState(HlsSegmentFileState state) { 142 | synchronized(lock) { 143 | this.state = state; 144 | } 145 | callStateChangeCallbacks(state); 146 | } 147 | 148 | /** 149 | * Download the file and make it available. 150 | */ 151 | private void downloadFile() { 152 | downloadManager.queueDownload(remoteUrl, localFile, new FileDownloadCallback()); 153 | } 154 | 155 | private class FileDownloadCallback implements IHlsSegmentFileDownloadCallback { 156 | 157 | @Override 158 | public void onDownloadStart() { 159 | updateState(HlsSegmentFileState.DOWNLOADING); 160 | } 161 | 162 | @Override 163 | public void onCompletion(boolean success) { 164 | updateState(success ? HlsSegmentFileState.DOWNLOADED : HlsSegmentFileState.DOWNLOAD_FAILED); 165 | } 166 | 167 | } 168 | } -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/hlsRecorder/HlsSegmentFileProxy.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.hlsRecorder; 2 | 3 | import java.io.File; 4 | import java.net.URL; 5 | import java.util.HashSet; 6 | 7 | /** 8 | * Represents a hls segment file. 9 | * Also handles downloading the file. 10 | * 11 | * This is a proxy to the actual segment file object so that it can 12 | * provide a delete method. E.g. several proxies may point to the same 13 | * hls segment file object, and both must have called delete before the 14 | * file can actually be deleted. 15 | */ 16 | public class HlsSegmentFileProxy { 17 | 18 | private Object lock = new Object(); 19 | 20 | private final HlsSegmentFile hlsSegmentFile; 21 | private boolean released = false; 22 | 23 | private HashSet callbacks = new HashSet<>(); 24 | 25 | public HlsSegmentFileProxy(HlsSegmentFile hlsSegmentFile) { 26 | hlsSegmentFile.onProxyCreated(); 27 | this.hlsSegmentFile = hlsSegmentFile; 28 | } 29 | 30 | public URL getRemoteUrl() { 31 | synchronized(lock) { 32 | checkReleased(); 33 | return hlsSegmentFile.getRemoteUrl(); 34 | } 35 | } 36 | 37 | public HlsSegmentFileState getState() { 38 | synchronized(lock) { 39 | checkReleased(); 40 | return hlsSegmentFile.getState(); 41 | } 42 | } 43 | 44 | public File getFile() { 45 | synchronized(lock) { 46 | checkReleased(); 47 | return hlsSegmentFile.getFile(); 48 | } 49 | } 50 | 51 | public URL getFileUrl() { 52 | synchronized(lock) { 53 | checkReleased(); 54 | return hlsSegmentFile.getFileUrl(); 55 | } 56 | } 57 | 58 | public boolean registerStateChangeCallback(IHlsSegmentFileStateChangeListener callback) { 59 | synchronized(lock) { 60 | checkReleased(); 61 | callbacks.add(callback); 62 | return hlsSegmentFile.registerStateChangeCallback(callback); 63 | } 64 | } 65 | 66 | public boolean unregisterStateChangeCallback(IHlsSegmentFileStateChangeListener callback) { 67 | synchronized(lock) { 68 | checkReleased(); 69 | callbacks.remove(callback); 70 | return hlsSegmentFile.unregisterStateChangeCallback(callback); 71 | } 72 | } 73 | 74 | /** 75 | * Call when you no longer need access to this file. 76 | */ 77 | public void release() { 78 | synchronized(lock) { 79 | checkReleased(); 80 | // unregister any callbacks that have been registered 81 | for (IHlsSegmentFileStateChangeListener callback : callbacks) { 82 | hlsSegmentFile.unregisterStateChangeCallback(callback); 83 | } 84 | callbacks.clear(); 85 | released = true; 86 | hlsSegmentFile.onProxyReleased(); 87 | } 88 | } 89 | 90 | public boolean isReleased() { 91 | synchronized(lock) { 92 | return released; 93 | } 94 | } 95 | 96 | private void checkReleased() { 97 | synchronized(lock) { 98 | if (released) { 99 | throw(new RuntimeException("release() has been called.")); 100 | } 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/hlsRecorder/HlsSegmentFileState.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.hlsRecorder; 2 | 3 | public enum HlsSegmentFileState { 4 | DOWNLOAD_PENDING, DOWNLOADING, DOWNLOADED, DOWNLOAD_FAILED 5 | } -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/hlsRecorder/HlsSegmentFileStore.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.hlsRecorder; 2 | 3 | import java.net.URL; 4 | import java.util.HashMap; 5 | import java.util.Timer; 6 | import java.util.TimerTask; 7 | 8 | import javax.annotation.PostConstruct; 9 | import javax.annotation.PreDestroy; 10 | 11 | import org.apache.log4j.Logger; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.context.ApplicationContext; 14 | import org.springframework.stereotype.Service; 15 | 16 | import uk.co.la1tv.dvrBridgeService.helpers.FileHelper; 17 | import uk.co.la1tv.dvrBridgeService.servableFiles.ServableFile; 18 | import uk.co.la1tv.dvrBridgeService.servableFiles.ServableFileGenerator; 19 | 20 | /** 21 | * Holds references to all of the segment files that have been downloaded 22 | * to the server. 23 | */ 24 | @Service 25 | public class HlsSegmentFileStore { 26 | 27 | private static Logger logger = Logger.getLogger(HlsSegmentFileStore.class); 28 | 29 | @Autowired 30 | private ApplicationContext context; 31 | 32 | @Autowired 33 | private ServableFileGenerator hlsFileGenerator; 34 | 35 | // Key is the url object 36 | private final HashMap segments = new HashMap<>(); 37 | 38 | private final Timer cleanupTimer = new Timer(); 39 | 40 | @PostConstruct 41 | private void onPostConstruct() { 42 | cleanupTimer.schedule(new CleanupTask(), 10000, 10000); 43 | } 44 | 45 | @PreDestroy 46 | private void onPreDestroy() { 47 | cleanupTimer.cancel(); 48 | cleanupTimer.purge(); 49 | } 50 | 51 | /** 52 | * Get a segment that is/was located at the specified url. 53 | * If the segment does not already exist locally it will 54 | * be downloaded. 55 | * @param remoteUrl 56 | * @return 57 | */ 58 | public HlsSegmentFileProxy getSegment(URL remoteUrl) { 59 | synchronized(segments) { 60 | if (segments.containsKey(remoteUrl)) { 61 | return createProxy(segments.get(remoteUrl)); 62 | } 63 | ServableFile localFile = hlsFileGenerator.generateServableFile(FileHelper.getExtension(remoteUrl.getFile())); 64 | HlsSegmentFile newSegment = context.getBean(HlsSegmentFile.class, remoteUrl, localFile); 65 | segments.put(remoteUrl, newSegment); 66 | HlsSegmentFileProxy newSegmentProxy = createProxy(newSegment); 67 | return newSegmentProxy; 68 | } 69 | } 70 | 71 | private HlsSegmentFileProxy createProxy(HlsSegmentFile segmentFile) { 72 | return new HlsSegmentFileProxy(segmentFile); 73 | } 74 | 75 | private class CleanupTask extends TimerTask { 76 | 77 | @Override 78 | public void run() { 79 | synchronized(segments) { 80 | // check for any files that no longer have any proxies pointing at 81 | // them and delete the source files and remove them from the array 82 | HashMap clone = new HashMap<>(segments); 83 | for (URL key : clone.keySet()) { 84 | HlsSegmentFile segmentFile = segments.get(key); 85 | if (segmentFile.getNumProxiesAccessingFile() == 0) { 86 | // no one is accessing this file so remove it if possible 87 | HlsSegmentFileState state = segmentFile.getState(); 88 | if (state != HlsSegmentFileState.DOWNLOADED && state != HlsSegmentFileState.DOWNLOAD_FAILED) { 89 | // file must be in one of these 2 states before it can be removed 90 | // from the segments array 91 | continue; 92 | } 93 | 94 | if (state == HlsSegmentFileState.DOWNLOADED) { 95 | if (!segmentFile.deleteFile()) { 96 | // file failed to delete for some reason 97 | logger.warn("Failed to delete file "+segmentFile.getFile().getAbsolutePath()); 98 | continue; 99 | } 100 | logger.debug("Deleted file "+segmentFile.getFile().getAbsolutePath()); 101 | } 102 | 103 | // can now safely be removed from the segments array and will be garbage collected 104 | segments.remove(key); 105 | } 106 | } 107 | } 108 | } 109 | 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/hlsRecorder/HlsVariantPlaylist.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.hlsRecorder; 2 | 3 | import java.awt.Dimension; 4 | import java.net.URL; 5 | import java.util.ArrayList; 6 | 7 | import javax.annotation.PostConstruct; 8 | 9 | import org.apache.log4j.Logger; 10 | import org.json.simple.JSONArray; 11 | import org.json.simple.JSONObject; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.context.ApplicationContext; 14 | import org.springframework.context.annotation.Scope; 15 | import org.springframework.stereotype.Component; 16 | 17 | import uk.co.la1tv.dvrBridgeService.helpers.M3u8ParserHelper; 18 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.exceptions.PlaylistRequestException; 19 | 20 | /** 21 | * Represents a variant playlist file. 22 | */ 23 | @Component("HlsVariantPlaylist") 24 | @Scope("prototype") 25 | public class HlsVariantPlaylist extends HlsPlaylist { 26 | 27 | private static Logger logger = Logger.getLogger(HlsVariantPlaylist.class); 28 | 29 | @Autowired 30 | private M3u8ParserHelper m3u8ParserHelper; 31 | 32 | @Autowired 33 | private ApplicationContext context; 34 | private HlsPlaylist[] hlsPlaylists = null; 35 | private final URL variantPlaylistUrl; 36 | 37 | public HlsVariantPlaylist(URL variantPlaylistUrl) { 38 | super(variantPlaylistUrl); 39 | this.variantPlaylistUrl = variantPlaylistUrl; 40 | } 41 | 42 | @PostConstruct 43 | private void onPostConstruct() { 44 | // make request to get list of playlists this contains and create them 45 | JSONObject playlistInfo; 46 | try { 47 | playlistInfo = m3u8ParserHelper.getPlaylistInfo(variantPlaylistUrl); 48 | } catch (PlaylistRequestException e) { 49 | e.printStackTrace(); 50 | return; 51 | } 52 | 53 | ArrayList playlists = new ArrayList<>(); 54 | try { 55 | JSONObject items = (JSONObject) playlistInfo.get("items"); 56 | JSONArray streamItems = (JSONArray) items.get("StreamItem"); 57 | for (int i=0; i>> 4]; 84 | hexChars[j * 2 + 1] = hexArray[v & 0x0F]; 85 | } 86 | return new String(hexChars); 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/streamManager/ISiteStream.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.streamManager; 2 | 3 | import java.net.URL; 4 | 5 | public interface ISiteStream { 6 | public void setCaptureRemovedListener(ISiteStreamCaptureRemovedListener captureRemovedListener); 7 | public boolean hasCapture(); 8 | public boolean captureDeleted(); 9 | public void registerActivity(); 10 | public String getSiteStreamId(); 11 | public boolean startCapture(); 12 | public boolean stopCapture(); 13 | public boolean removeCapture(); 14 | public URL getPlaylistUrl(); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/streamManager/ISiteStreamCaptureRemovedListener.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.streamManager; 2 | 3 | public interface ISiteStreamCaptureRemovedListener { 4 | void onCaptureRemoved(); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/streamManager/MasterStreamManager.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.streamManager; 2 | 3 | import java.net.URL; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.stereotype.Service; 7 | 8 | import uk.co.la1tv.dvrBridgeService.helpers.M3u8ParserHelper; 9 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.exceptions.PlaylistRequestException; 10 | 11 | /** 12 | * Provides methods to manage variant and standard hls streams. 13 | * Communicates with the StreamManager and VariantStreamManager 14 | */ 15 | @Service 16 | public class MasterStreamManager { 17 | 18 | @Autowired 19 | private VariantStreamManager variantStreamManager; 20 | @Autowired 21 | private StreamManager streamManager; 22 | @Autowired 23 | private M3u8ParserHelper m3u8ParserHelper; 24 | 25 | /** 26 | * Creates a stream and returns a reference to it. The capture will be started. 27 | * If the stream already exists the capture will be restarted. 28 | * This determines where the remote playlist is a variant playlist or standard hls 29 | * playlist and creates either a VariantSiteStream or SiteStream accordingly. 30 | * Returns null if there was an error. 31 | * @param id 32 | * @param remoteHlsPlaylistUrl 33 | * @return 34 | */ 35 | public ISiteStream createStream(final String id, URL remoteHlsPlaylistUrl) { 36 | try { 37 | if (m3u8ParserHelper.isVariantPlaylist(remoteHlsPlaylistUrl)) { 38 | return variantStreamManager.createStream(id, remoteHlsPlaylistUrl); 39 | } 40 | else { 41 | return streamManager.createStream(id, remoteHlsPlaylistUrl); 42 | } 43 | } catch (PlaylistRequestException e) { 44 | e.printStackTrace(); 45 | return null; 46 | } 47 | } 48 | 49 | 50 | /** 51 | * Get a reference to the stream with the corresponding id. 52 | * If the stream doesn't exist null is returned. 53 | * This may be a VariantSiteStream or SiteStream. 54 | * @param id 55 | * @return 56 | */ 57 | public ISiteStream getStream(String id) { 58 | ISiteStream siteStream = variantStreamManager.getStream(id); 59 | if (siteStream != null) { 60 | return siteStream; 61 | } 62 | return streamManager.getStream(id); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/streamManager/SiteStream.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.streamManager; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.net.URL; 6 | import java.nio.file.Files; 7 | import java.nio.file.Paths; 8 | import java.nio.file.StandardOpenOption; 9 | import java.util.Timer; 10 | import java.util.TimerTask; 11 | 12 | import javax.annotation.PostConstruct; 13 | 14 | import org.apache.log4j.Logger; 15 | import org.springframework.beans.factory.annotation.Autowired; 16 | import org.springframework.beans.factory.annotation.Value; 17 | import org.springframework.context.ApplicationContext; 18 | import org.springframework.context.annotation.Scope; 19 | import org.springframework.stereotype.Component; 20 | 21 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.HlsPlaylist; 22 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.HlsPlaylistCapture; 23 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.HlsPlaylistCaptureState; 24 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.ICaptureStateChangeListener; 25 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.IPlaylistUpdatedListener; 26 | import uk.co.la1tv.dvrBridgeService.servableFiles.ServableFile; 27 | import uk.co.la1tv.dvrBridgeService.servableFiles.ServableFileGenerator; 28 | 29 | 30 | /** 31 | * Represents a stream on the site. 32 | * The capture can be start, stopped and removed from here. 33 | * Operations must occur in that order and can only occur once. 34 | */ 35 | @Component 36 | @Scope("prototype") 37 | public class SiteStream implements ISiteStream { 38 | 39 | private static Logger logger = Logger.getLogger(SiteStream.class); 40 | 41 | @Autowired 42 | private ApplicationContext context; 43 | 44 | @Autowired 45 | private ServableFileGenerator fileGenerator; 46 | 47 | @Value("${app.inactivityTimeLimit}") 48 | // time (seconds) that registerActivity calls must be received in 49 | private int inactivityTimeLimit; 50 | 51 | private final Object lock = new Object(); 52 | 53 | // unique id for this stream provided by site. This may be the id of the parent variant stream, 54 | // in which case there may be other playlists which belong to that variant playlist with the same id 55 | private final String siteStreamId; 56 | private final URL sourcePlaylistUrl; 57 | private HlsPlaylist hlsPlaylist = null; 58 | private HlsPlaylistCapture capture = null; 59 | private ServableFile generatedPlaylistFile = null; 60 | private ISiteStreamCaptureRemovedListener captureRemovedListener = null; 61 | private boolean requestedStop = false; 62 | private long lastActivity = System.currentTimeMillis(); 63 | private Timer inactivityTimer = null; 64 | 65 | public SiteStream(String id, URL sourcePlaylistUrl) { 66 | this.siteStreamId = id; 67 | this.sourcePlaylistUrl = sourcePlaylistUrl; 68 | } 69 | 70 | @PostConstruct 71 | private void onPostConstruct() { 72 | hlsPlaylist = (HlsPlaylist) context.getBean("HlsPlaylist", sourcePlaylistUrl); 73 | } 74 | 75 | /** 76 | * Set the listener to be informed when the capture is deleted. 77 | * @param captureRemovedListener 78 | */ 79 | public void setCaptureRemovedListener(ISiteStreamCaptureRemovedListener captureRemovedListener) { 80 | this.captureRemovedListener = captureRemovedListener; 81 | } 82 | 83 | /** 84 | * Determine if this stream has a capture. 85 | * This may be a capture in progress or a finished capture. 86 | * @return 87 | */ 88 | public boolean hasCapture() { 89 | synchronized(lock) { 90 | HlsPlaylistCaptureState state = capture.getCaptureState(); 91 | return capture != null && (state == HlsPlaylistCaptureState.CAPTURING || state == HlsPlaylistCaptureState.STOPPED); 92 | } 93 | } 94 | 95 | /** 96 | * Determine if the capture has been deleted. 97 | * @return 98 | */ 99 | public boolean captureDeleted() { 100 | synchronized(lock) { 101 | return capture != null && capture.getCaptureState() == HlsPlaylistCaptureState.DELETED; 102 | } 103 | } 104 | 105 | /** 106 | * Register activity. If the configurable timeout passes inbetween calls to this then 107 | * the capture will be deleted. 108 | */ 109 | public void registerActivity() { 110 | lastActivity = System.currentTimeMillis(); 111 | } 112 | 113 | /** 114 | * Generate a new capture. 115 | */ 116 | private void generateHlsPlaylistCapture() { 117 | synchronized(lock) { 118 | if (capture != null) { 119 | throw(new RuntimeException("HlsPlaylistCapture object already exists.")); 120 | } 121 | ServableFile file = fileGenerator.generateServableFile("m3u8"); 122 | generatedPlaylistFile = file; 123 | PlaylistFileGenerator playlistFileGenerator = new PlaylistFileGenerator(file); 124 | capture = context.getBean(HlsPlaylistCapture.class, hlsPlaylist); 125 | capture.setStateChangeListener(new ICaptureStateChangeListener() { 126 | 127 | @Override 128 | public void onStateChange(HlsPlaylistCaptureState newState) { 129 | if (newState == HlsPlaylistCaptureState.DELETED) { 130 | inactivityTimer.cancel(); 131 | inactivityTimer.purge(); 132 | // delete the generated playlist file and call the capture removed callback 133 | generatedPlaylistFile.delete(); 134 | if (captureRemovedListener != null) { 135 | captureRemovedListener.onCaptureRemoved(); 136 | } 137 | removeHlsPlaylistCapture(); 138 | } 139 | else if (newState == HlsPlaylistCaptureState.STOPPED) { 140 | if (!requestedStop) { 141 | // this is a stop due to an error. 142 | // delete the capture 143 | try { 144 | capture.deleteCapture(); 145 | } 146 | catch(Exception e) { 147 | e.printStackTrace(); 148 | logger.warn("Error trying to delete capture after it was stopped unexpectedly."); 149 | } 150 | } 151 | } 152 | } 153 | 154 | }); 155 | capture.setPlaylistUpdatedListener(playlistFileGenerator); 156 | } 157 | } 158 | 159 | /** 160 | * Remove the HlsPlaylistCapture object and remove listeners so it can be garbage collected. 161 | */ 162 | private void removeHlsPlaylistCapture() { 163 | synchronized(lock) { 164 | if (capture == null) { 165 | throw(new RuntimeException("HlsPlaylistCapture object does not exist.")); 166 | } 167 | capture.setStateChangeListener(null); 168 | capture.setPlaylistUpdatedListener(null); 169 | capture = null; 170 | } 171 | } 172 | 173 | /** 174 | * Get the id that the site has assigned to this stream. 175 | * @return 176 | */ 177 | public String getSiteStreamId() { 178 | return siteStreamId; 179 | } 180 | 181 | /** 182 | * Start a capture for this item. 183 | * Returns true on success or false on a failure. 184 | */ 185 | public boolean startCapture() { 186 | boolean success = false; 187 | synchronized(lock) { 188 | generateHlsPlaylistCapture(); 189 | try { 190 | success = capture.startCapture(); 191 | } 192 | catch(Exception e) { 193 | e.printStackTrace(); 194 | } 195 | if (!success) { 196 | removeHlsPlaylistCapture(); 197 | } 198 | else { 199 | inactivityTimer = new Timer(); 200 | lastActivity = System.currentTimeMillis(); 201 | inactivityTimer.schedule(new InactivityCheckerTask(), 1000, 1000); 202 | } 203 | } 204 | return success; 205 | } 206 | 207 | /** 208 | * Stop the capture for this item. 209 | * Returns true on success or false on a failure. 210 | */ 211 | public boolean stopCapture() { 212 | return stopCaptureImpl(); 213 | } 214 | 215 | private synchronized boolean stopCaptureImpl() { 216 | if (capture == null) { 217 | return false; 218 | } 219 | try { 220 | requestedStop = true; 221 | capture.stopCapture(); 222 | return true; 223 | } 224 | catch(Exception e) { 225 | e.printStackTrace(); 226 | requestedStop = false; 227 | return false; 228 | } 229 | } 230 | 231 | /** 232 | * Remove the capture for this item from the server, 233 | * and stop the recording if one is taking place. 234 | * Returns true if the capture should not exist after this call has completed. 235 | */ 236 | public boolean removeCapture() { 237 | try { 238 | if (capture == null || capture.getCaptureState() == HlsPlaylistCaptureState.DELETED) { 239 | return true; 240 | } 241 | 242 | if (capture.getCaptureState() == HlsPlaylistCaptureState.CAPTURING) { 243 | // currently capturing. 244 | // stop capture first 245 | if (!stopCaptureImpl()) { 246 | return false; 247 | } 248 | } 249 | capture.deleteCapture(); 250 | return true; 251 | } 252 | catch(Exception e) { 253 | e.printStackTrace(); 254 | return false; 255 | } 256 | } 257 | 258 | /** 259 | * Get the url to the playlist file for this capture. 260 | * Returns null if the playlist file is not available. 261 | * @return 262 | */ 263 | public URL getPlaylistUrl() { 264 | HlsPlaylistCaptureState state = capture.getCaptureState(); 265 | if (state != HlsPlaylistCaptureState.CAPTURING && state != HlsPlaylistCaptureState.STOPPED) { 266 | return null; 267 | } 268 | return generatedPlaylistFile.getUrl(); 269 | } 270 | 271 | private class PlaylistFileGenerator implements IPlaylistUpdatedListener { 272 | 273 | private final File file; 274 | 275 | public PlaylistFileGenerator(File file) { 276 | this.file = file; 277 | } 278 | 279 | @Override 280 | public void onPlaylistUpdated(String playlistContent) { 281 | try { 282 | Files.write(Paths.get(file.getAbsolutePath()), playlistContent.getBytes(), StandardOpenOption.CREATE); 283 | } catch (IOException e) { 284 | e.printStackTrace(); 285 | logger.error("Error when trying to write generated playlist file."); 286 | } 287 | } 288 | 289 | } 290 | 291 | private class InactivityCheckerTask extends TimerTask { 292 | 293 | @Override 294 | public void run() { 295 | 296 | if (inactivityTimeLimit == 0) { 297 | // disabled 298 | return; 299 | } 300 | else if (lastActivity + (inactivityTimeLimit*1000) < System.currentTimeMillis()) { 301 | // not had a registerActivity call recent enough. stop the capture. 302 | logger.warn("Removing capture because activity hasn't been registered in too long."); 303 | removeCapture(); 304 | } 305 | } 306 | 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/streamManager/StreamManager.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.streamManager; 2 | 3 | import java.net.URL; 4 | import java.util.HashMap; 5 | 6 | import org.apache.log4j.Logger; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.context.ApplicationContext; 9 | import org.springframework.stereotype.Service; 10 | 11 | /** 12 | * Manages the streams that are controlled from the web server 13 | */ 14 | @Service 15 | public class StreamManager { 16 | 17 | private static Logger logger = Logger.getLogger(StreamManager.class); 18 | 19 | @Autowired 20 | private ApplicationContext context; 21 | 22 | private final HashMap siteStreams = new HashMap<>(); 23 | 24 | /** 25 | * Creates a stream and returns a reference to it. The capture will be started. 26 | * If the stream already exists the capture will be restarted. 27 | * Returns null if there was an error. 28 | * @param id 29 | * @param remoteHlsPlaylistUrl 30 | * @return 31 | */ 32 | public SiteStream createStream(final String id, URL remoteHlsPlaylistUrl) { 33 | synchronized(siteStreams) { 34 | SiteStream siteStream = siteStreams.get(id); 35 | if (siteStream != null) { 36 | // already exists. remove the capture and then remove from hashmap 37 | // when onCaptureRemoved() called it will have no effect 38 | if (!siteStream.removeCapture()) { 39 | logger.error("A capture that already existed could not be deleted for some reason."); 40 | return null; 41 | } 42 | // make sure it can be garbage collected 43 | siteStream.setCaptureRemovedListener(null); 44 | siteStreams.remove(id); 45 | } 46 | 47 | final SiteStream newSiteStream = context.getBean(SiteStream.class, id, remoteHlsPlaylistUrl); 48 | if (!newSiteStream.startCapture()) { 49 | logger.warn("An error occurred when trying to start a stream capture."); 50 | return null; 51 | } 52 | newSiteStream.setCaptureRemovedListener(new ISiteStreamCaptureRemovedListener() { 53 | @Override 54 | public void onCaptureRemoved() { 55 | synchronized(siteStreams) { 56 | // remove stream when it's capture is removed 57 | siteStreams.remove(id, newSiteStream); 58 | } 59 | } 60 | }); 61 | siteStreams.put(id, newSiteStream); 62 | return newSiteStream; 63 | } 64 | } 65 | 66 | /** 67 | * Get a reference to the stream with the corresponding id. 68 | * If the stream doesn't exist null is returned. 69 | * @param id 70 | * @return 71 | */ 72 | public SiteStream getStream(String id) { 73 | synchronized(siteStreams) { 74 | return siteStreams.get(id); 75 | } 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/streamManager/VariantSiteStream.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.streamManager; 2 | 3 | import java.awt.Dimension; 4 | import java.io.IOException; 5 | import java.net.URL; 6 | import java.nio.file.Files; 7 | import java.nio.file.Paths; 8 | import java.nio.file.StandardOpenOption; 9 | import java.util.HashMap; 10 | 11 | import javax.annotation.PostConstruct; 12 | 13 | import org.apache.log4j.Logger; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.context.ApplicationContext; 16 | import org.springframework.context.annotation.Scope; 17 | import org.springframework.stereotype.Component; 18 | 19 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.HlsPlaylist; 20 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.HlsPlaylistCaptureState; 21 | import uk.co.la1tv.dvrBridgeService.hlsRecorder.HlsVariantPlaylist; 22 | import uk.co.la1tv.dvrBridgeService.servableFiles.ServableFile; 23 | import uk.co.la1tv.dvrBridgeService.servableFiles.ServableFileGenerator; 24 | 25 | /** 26 | * Represents a variant stream on the site. 27 | * A variant stream is essentially a group of streams. 28 | * The capture can be start, stopped and removed from here. 29 | * Operations must occur in that order and can only occur once. 30 | * Each operation is applied to each of the streams in the variant playlist. 31 | */ 32 | @Component 33 | @Scope("prototype") 34 | public class VariantSiteStream implements ISiteStream { 35 | 36 | private static Logger logger = Logger.getLogger(VariantSiteStream.class); 37 | 38 | @Autowired 39 | private ApplicationContext context; 40 | 41 | @Autowired 42 | private ServableFileGenerator fileGenerator; 43 | 44 | 45 | private final Object lock = new Object(); 46 | 47 | // unique id for this stream provided by site 48 | private final String siteVariantStreamId; 49 | private final URL sourceVariantPlaylistUrl; 50 | private HlsVariantPlaylist sourceVariantPlaylist; 51 | private HashMap siteStreams = null; 52 | private ServableFile generatedVariantPlaylistFile = null; 53 | private boolean requestedRemove = false; 54 | private ISiteStreamCaptureRemovedListener captureRemovedListener = null; 55 | private HlsPlaylistCaptureState captureState = HlsPlaylistCaptureState.NOT_STARTED; 56 | 57 | public VariantSiteStream(String id, URL sourcePlaylistUrl) { 58 | this.siteVariantStreamId = id; 59 | this.sourceVariantPlaylistUrl = sourcePlaylistUrl; 60 | } 61 | 62 | 63 | @PostConstruct 64 | private void onPostConstruct() { 65 | sourceVariantPlaylist = (HlsVariantPlaylist) context.getBean("HlsVariantPlaylist", sourceVariantPlaylistUrl); 66 | createSiteStreams(); 67 | } 68 | 69 | private boolean createSiteStreams() { 70 | HlsPlaylist[] playlists = sourceVariantPlaylist.getPlaylists(); 71 | if (playlists == null) { 72 | // can be null if there was an error requesting the playlists from the server 73 | return false; 74 | } 75 | siteStreams = new HashMap<>(); 76 | synchronized(siteStreams) { 77 | for(HlsPlaylist playlist : playlists) { 78 | siteStreams.put(playlist, context.getBean(SiteStream.class, siteVariantStreamId, playlist.getUrl())); 79 | } 80 | } 81 | return true; 82 | } 83 | 84 | 85 | /** 86 | * Set the listener to be informed when the capture is deleted. 87 | * @param captureRemovedListener 88 | */ 89 | public void setCaptureRemovedListener(ISiteStreamCaptureRemovedListener captureRemovedListener) { 90 | this.captureRemovedListener = captureRemovedListener; 91 | } 92 | 93 | /** 94 | * Determine if this stream has a capture. 95 | * This may be a capture in progress or a finished capture. 96 | * @return 97 | */ 98 | public boolean hasCapture() { 99 | synchronized(lock) { 100 | return captureState == HlsPlaylistCaptureState.CAPTURING || captureState == HlsPlaylistCaptureState.STOPPED; 101 | } 102 | } 103 | 104 | /** 105 | * Determine if the capture has been deleted. 106 | * @return 107 | */ 108 | public boolean captureDeleted() { 109 | synchronized(lock) { 110 | return captureState == HlsPlaylistCaptureState.DELETED; 111 | } 112 | } 113 | 114 | /** 115 | * Register activity. If the configurable timeout passes inbetween calls to this then 116 | * the capture will be deleted. 117 | */ 118 | public void registerActivity() { 119 | synchronized(siteStreams) { 120 | if (siteStreams == null) { 121 | return; 122 | } 123 | for(SiteStream siteStream : siteStreams.values()) { 124 | siteStream.registerActivity(); 125 | } 126 | } 127 | } 128 | 129 | private void addSiteStreamRemoveListeners() { 130 | synchronized(siteStreams) { 131 | for (SiteStream siteStream : siteStreams.values()) { 132 | siteStream.setCaptureRemovedListener(new ISiteStreamCaptureRemovedListener() { 133 | 134 | @Override 135 | public void onCaptureRemoved() { 136 | if (!requestedRemove) { 137 | // this is a remove due to an error. 138 | // delete the capture 139 | if (!removeCapture()) { 140 | logger.warn("Error trying to delete variant playlist capture after one of it's playlists stopped unexpectedly."); 141 | } 142 | } 143 | } 144 | }); 145 | } 146 | } 147 | } 148 | 149 | private void removeSiteStreamRemoveListeners() { 150 | synchronized(siteStreams) { 151 | for (SiteStream siteStream : siteStreams.values()) { 152 | siteStream.setCaptureRemovedListener(null); 153 | } 154 | } 155 | } 156 | 157 | private boolean writeVariantPlaylistFile(ServableFile file) throws IOException { 158 | HlsPlaylist[] playlists = sourceVariantPlaylist.getPlaylists(); 159 | if (playlists == null) { 160 | // null if there was an error getting the information from the server 161 | throw(new RuntimeException("The variant playlist is returning null for it's playlists.")); 162 | } 163 | String contents = ""; 164 | contents += "#EXTM3U\n"; 165 | contents += "#EXT-X-VERSION:3\n"; 166 | for (HlsPlaylist playlist : playlists) { 167 | Dimension resolution = playlist.getResolution(); 168 | contents += "#EXT-X-STREAM-INF:BANDWIDTH="+playlist.getBandwidth()+",CODECS=\""+playlist.getCodecs()+"\",RESOLUTION="+Math.round(resolution.getWidth())+"x"+Math.round(resolution.getHeight())+"\n"; 169 | URL generatedPlaylistUrl = siteStreams.get(playlist).getPlaylistUrl(); 170 | if (generatedPlaylistUrl == null) { 171 | logger.error("Unable to retrieve generated playlist url so can't generate variant playlist."); 172 | return false; 173 | } 174 | contents += generatedPlaylistUrl.toExternalForm()+"\n"; 175 | } 176 | Files.write(Paths.get(file.getAbsolutePath()), contents.getBytes(), StandardOpenOption.CREATE); 177 | return true; 178 | } 179 | 180 | 181 | /** 182 | * Get the id that the site has assigned to this stream. 183 | * @return 184 | */ 185 | public String getSiteStreamId() { 186 | return siteVariantStreamId; 187 | } 188 | 189 | /** 190 | * Start a capture for this item. 191 | * Returns true on success or false on a failure. 192 | */ 193 | public boolean startCapture() { 194 | synchronized(lock) { 195 | 196 | if (captureState != HlsPlaylistCaptureState.NOT_STARTED) { 197 | return false; 198 | } 199 | 200 | if (siteStreams == null) { 201 | return false; 202 | } 203 | 204 | addSiteStreamRemoveListeners(); 205 | 206 | // attempt to start all captures 207 | SiteStream siteStreamWithError = null; 208 | synchronized(siteStreams) { 209 | for (SiteStream siteStream : siteStreams.values()) { 210 | if (!siteStream.startCapture()) { 211 | siteStreamWithError = siteStream; 212 | break; 213 | } 214 | } 215 | } 216 | 217 | if (siteStreamWithError != null) { 218 | // there was an error starting a capture. 219 | // attempt to remove all the ones that started 220 | synchronized(siteStreams) { 221 | for (SiteStream siteStream : siteStreams.values()) { 222 | if (siteStream == siteStreamWithError) { 223 | break; 224 | } 225 | if (!siteStream.removeCapture()) { 226 | logger.error("There was an removing a capture that was being removed because a different capture failed to start."); 227 | } 228 | } 229 | } 230 | removeSiteStreamRemoveListeners(); 231 | return false; 232 | } 233 | 234 | ServableFile file = fileGenerator.generateServableFile("m3u8"); 235 | try { 236 | if (writeVariantPlaylistFile(file)) { 237 | generatedVariantPlaylistFile = file; 238 | } 239 | } catch (IOException e) { 240 | e.printStackTrace(); 241 | logger.error("Error when trying to write generated variant playlist file."); 242 | } 243 | if (generatedVariantPlaylistFile == null) { 244 | return false; 245 | } 246 | captureState = HlsPlaylistCaptureState.CAPTURING; 247 | return true; 248 | } 249 | } 250 | 251 | /** 252 | * Stop the capture for this item. 253 | * Returns true on success or false on a failure. 254 | */ 255 | public boolean stopCapture() { 256 | synchronized(lock) { 257 | if (captureState != HlsPlaylistCaptureState.CAPTURING) { 258 | return false; 259 | } 260 | return stopCaptureImpl(); 261 | } 262 | } 263 | 264 | private synchronized boolean stopCaptureImpl() { 265 | try { 266 | boolean errorOccurredStoppingACapture = false; 267 | synchronized(siteStreams) { 268 | for (SiteStream siteStream : siteStreams.values()) { 269 | if (!siteStream.stopCapture()) { 270 | logger.warn("An error occurred when trying to stop a capture belonging to a variant playlist."); 271 | errorOccurredStoppingACapture = true; 272 | break; 273 | } 274 | } 275 | 276 | if (errorOccurredStoppingACapture) { 277 | removeCaptureImpl(); 278 | return false; 279 | } 280 | } 281 | captureState = HlsPlaylistCaptureState.STOPPED; 282 | return true; 283 | } 284 | catch(Exception e) { 285 | e.printStackTrace(); 286 | return false; 287 | } 288 | } 289 | 290 | /** 291 | * Remove the captures for this item from the server, 292 | * and stop the recording if one is taking place. 293 | * Returns true if the captures should not exist after this call completes. 294 | */ 295 | public boolean removeCapture() { 296 | synchronized(lock) { 297 | if (captureState == HlsPlaylistCaptureState.DELETED) { 298 | return true; 299 | } 300 | 301 | if (captureState != HlsPlaylistCaptureState.CAPTURING && captureState != HlsPlaylistCaptureState.STOPPED) { 302 | return false; 303 | } 304 | try { 305 | if (captureState == HlsPlaylistCaptureState.CAPTURING) { 306 | // currently capturing. 307 | // stop capture first 308 | if (!stopCaptureImpl()) { 309 | return false; 310 | } 311 | } 312 | removeCaptureImpl(); 313 | captureState = HlsPlaylistCaptureState.DELETED; 314 | return true; 315 | } 316 | catch(Exception e) { 317 | e.printStackTrace(); 318 | return false; 319 | } 320 | } 321 | } 322 | 323 | private void removeCaptureImpl() { 324 | // attempt to remove all captures 325 | synchronized(siteStreams) { 326 | requestedRemove = true; 327 | try { 328 | for (SiteStream siteStream : siteStreams.values()) { 329 | if (!siteStream.removeCapture()) { 330 | logger.error("There was an error removing a capture that belongs to a variant playlist."); 331 | } 332 | } 333 | } 334 | finally { 335 | requestedRemove = false; 336 | } 337 | } 338 | removeSiteStreamRemoveListeners(); 339 | generatedVariantPlaylistFile.delete(); 340 | captureState = HlsPlaylistCaptureState.DELETED; 341 | if (captureRemovedListener != null) { 342 | captureRemovedListener.onCaptureRemoved(); 343 | } 344 | } 345 | 346 | /** 347 | * Get the url to the variant playlist file for this capture. 348 | * Returns null if the playlist file is not available. 349 | * @return 350 | */ 351 | public URL getPlaylistUrl() { 352 | synchronized(lock) { 353 | if (captureState != HlsPlaylistCaptureState.CAPTURING && captureState != HlsPlaylistCaptureState.STOPPED) { 354 | return null; 355 | } 356 | return generatedVariantPlaylistFile.getUrl(); 357 | } 358 | } 359 | 360 | 361 | } 362 | -------------------------------------------------------------------------------- /src/main/uk/co/la1tv/dvrBridgeService/streamManager/VariantStreamManager.java: -------------------------------------------------------------------------------- 1 | package uk.co.la1tv.dvrBridgeService.streamManager; 2 | 3 | import java.net.URL; 4 | import java.util.HashMap; 5 | 6 | import org.apache.log4j.Logger; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.context.ApplicationContext; 9 | import org.springframework.stereotype.Service; 10 | 11 | /** 12 | * Manages the variant streams which are essentially groups of streams. 13 | * Generates the variant playlist and handles each of the streams 14 | */ 15 | @Service 16 | public class VariantStreamManager { 17 | 18 | private static Logger logger = Logger.getLogger(VariantStreamManager.class); 19 | 20 | @Autowired 21 | private ApplicationContext context; 22 | 23 | // key is the id of the variant stream, and value is the array of SiteStream's it contains 24 | private final HashMap variantSiteStreams = new HashMap<>(); 25 | 26 | /** 27 | * Creates each of the streams in the variant playlist and returns a reference to the object 28 | * which represents the variant stream. The captures will be started. 29 | * If the stream already exists the captures will be restarted. 30 | * Returns null if there was an error. 31 | * @param id 32 | * @param remoteHlsVariantPlaylistUrl 33 | * @return 34 | */ 35 | public VariantSiteStream createStream(final String id, URL remoteHlsVariantPlaylistUrl) { 36 | synchronized(variantSiteStreams) { 37 | VariantSiteStream variantSiteStream = variantSiteStreams.get(id); 38 | if (variantSiteStream != null) { 39 | // already exists. remove the capture and then remove from hashmap 40 | // when onCaptureRemoved() called it will have no effect 41 | if (!variantSiteStream.removeCapture()) { 42 | logger.error("A capture that already existed could not be deleted for some reason."); 43 | return null; 44 | } 45 | // make sure it can be garbage collected 46 | variantSiteStream.setCaptureRemovedListener(null); 47 | variantSiteStreams.remove(id); 48 | } 49 | 50 | final VariantSiteStream newVariantSiteStream = context.getBean(VariantSiteStream.class, id, remoteHlsVariantPlaylistUrl); 51 | if (!newVariantSiteStream.startCapture()) { 52 | logger.warn("An error occurred when trying to start a variant stream capture."); 53 | return null; 54 | } 55 | newVariantSiteStream.setCaptureRemovedListener(new ISiteStreamCaptureRemovedListener() { 56 | @Override 57 | public void onCaptureRemoved() { 58 | synchronized(variantSiteStreams) { 59 | // remove variant stream when it's capture is removed 60 | variantSiteStreams.remove(id, newVariantSiteStream); 61 | } 62 | } 63 | }); 64 | variantSiteStreams.put(id, newVariantSiteStream); 65 | return newVariantSiteStream; 66 | } 67 | } 68 | 69 | /** 70 | * Get a reference to the stream with the corresponding id. 71 | * If the stream doesn't exist null is returned. 72 | * @param id 73 | * @return 74 | */ 75 | public VariantSiteStream getStream(String id) { 76 | synchronized(variantSiteStreams) { 77 | return variantSiteStreams.get(id); 78 | } 79 | } 80 | } 81 | --------------------------------------------------------------------------------