├── .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 |
- type=START
- id=[a unique id which represents the stream (<= 100 characters)]
| 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 | - type=STOP
- id=[a unique id which represents the stream (<= 100 characters)]
| 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 | - type=PING
- id=[a unique id which represents the stream (<= 100 characters)]
| 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 |
--------------------------------------------------------------------------------