Initial commit
This commit is contained in:
commit
a54603299b
65
README.md
Normal file
65
README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# pseudotv-plex
|
||||
|
||||
Create Live TV/DVR channels from playlists in Plex.
|
||||
|
||||

|
||||
|
||||
### How it works
|
||||
|
||||
1. psuedotv-plex will scan Plex for playlists. Playlists with a **summary** starting with **psuedotv** will be fetched.
|
||||
2. XMLTV and M3U files are generated.
|
||||
3. Add the psuedotv (spoofed HDHomeRun) tuner into Plex, use the XMLTV file for guide information.
|
||||
4. When tuning to a channel, a VLC session will be **spawned on demand**, hosting the channel's video stream.
|
||||
5. Whenever a playlist change is detected, an updated XMLTV file will be written
|
||||
|
||||
### Features
|
||||
|
||||
- Supports any video playlist in Plex, including Smart Playlists
|
||||
- VLC sessions are spawned on demand. There will only ever be one VLC session per channel, no matter the number of viewers.
|
||||
- VLC will **Direct Stream** if media is tagged **"optimizedForStreaming"** by Plex, otherwise VLC will transcode to h264/aac.
|
||||
- EPG/Channels update automatically
|
||||
|
||||
## Install
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Configure
|
||||
### You must provide your Plex server details and the location of VLC
|
||||
|
||||
Edit the **config.yml** configuration file
|
||||
|
||||
## Start
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
# Plex Playlist Setup
|
||||
|
||||
To assign a playlist as a channel, edit the summary if the playlist and write **psuedotv**.
|
||||
|
||||
Channel number and icon url are **optional** parameters.
|
||||
|
||||
Default channel number is the random Plex playlist ID
|
||||
|
||||

|
||||
|
||||
# Plex DVR Setup
|
||||
|
||||
Add the psuedotv-plex tuner to Plex. Use the **"Don't see your HDHomerun device? Enter its network address manually"** option if it doesn't show up automatically.
|
||||
|
||||
Click the **continue** button after clicking **connect**
|
||||
|
||||

|
||||
|
||||
Channels imported from Plex Playlists. **NOTE: If a new channel/playlist is added, you have to remove and re-setup the tuner in plex.**
|
||||
|
||||

|
||||
|
||||
**Use the XMLTV option and select the psuedotv-plex generated xmltv.xml file**
|
||||
|
||||

|
||||
|
||||
Channels should automatically be matched. **Click continue**
|
||||
|
||||

|
||||
BIN
docs/dvr1.png
Normal file
BIN
docs/dvr1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/dvr2.png
Normal file
BIN
docs/dvr2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/dvr3.png
Normal file
BIN
docs/dvr3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/dvr4.png
Normal file
BIN
docs/dvr4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/guide.png
Normal file
BIN
docs/guide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
BIN
docs/playlist.png
Normal file
BIN
docs/playlist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
47
index.js
Normal file
47
index.js
Normal file
@ -0,0 +1,47 @@
|
||||
const express = require('express')
|
||||
const fs = require('fs')
|
||||
var path = require("path")
|
||||
const config = require('config-yml')
|
||||
|
||||
const hdhr = require('./src/hdhr')
|
||||
const vlc = require('./src/vlc')
|
||||
const xmltv = require('./src/xmltv')
|
||||
const m3u = require('./src/m3u')
|
||||
const plex = require('./src/plex')
|
||||
|
||||
// Plex does not update the playlists updatedAt property when the summary or title changes
|
||||
var lastPlaylistUpdate = 0 // to watch for playlist updates
|
||||
var channelsInfo = "" // to watch for playlist updates
|
||||
|
||||
var refreshDate = new Date() // when the EPG will be updated
|
||||
refreshDate.setHours(refreshDate.getHours() + config.EPG_REFRESH)
|
||||
|
||||
plex.PlexChannelScan((channels, lastUpdate, info) => {
|
||||
console.log(`Generating EPG data(XMLTV) and channel playlists (M3U) from Plex. Channels: ${channels.length}`)
|
||||
lastPlaylistUpdate = lastUpdate
|
||||
channelsInfo = info
|
||||
m3u.WriteM3U(channels, () => { console.log(`M3U File Location: ${path.resolve(config.M3U_OUTPUT)}`) })
|
||||
xmltv.WriteXMLTV(channels, () => { console.log(`XMLTV File Location: ${path.resolve(config.XMLTV_OUTPUT)}`) })
|
||||
})
|
||||
|
||||
setInterval(() => {
|
||||
plex.PlexChannelScan((channels, lastUpdate, info) => {
|
||||
var now = new Date()
|
||||
// Update EPG whenever a psuedotv playlist is updated/added/removed, or at EPG_REFRESH interval
|
||||
if (lastUpdate > lastPlaylistUpdate || channelsInfo !== info || now > refreshDate ) {
|
||||
console.log(`Updating EPG data(XMLTV) and channel playlists (M3U) from Plex. Channels: ${channels.length}`)
|
||||
m3u.WriteM3U(channels)
|
||||
xmltv.UpdateXMLTV(channels)
|
||||
lastPlaylistUpdate = lastUpdate
|
||||
channelsInfo = info
|
||||
refreshDate.setHours(refreshDate.getHours() + config.EPG_REFRESH)
|
||||
}})
|
||||
}, config.PLEX_PLAYLIST_FETCH_TIMER * 1000)
|
||||
|
||||
var app = express()
|
||||
app.use(hdhr.router())
|
||||
app.use(vlc.router())
|
||||
app.listen(config.PORT, () => {
|
||||
hdhr.start()
|
||||
console.log(`Hosting VLC / HDHomeRun server at: http://${config.HOST}:${config.PORT}`)
|
||||
})
|
||||
1376
package-lock.json
generated
Normal file
1376
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "psuedotv-plex",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js -e js,json,yml"
|
||||
},
|
||||
"author": "Dan Ferguson",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"config-yml": "^0.10.3",
|
||||
"express": "^4.17.1",
|
||||
"morgan": "^1.10.0",
|
||||
"node-ssdp": "^4.0.0",
|
||||
"plex-api": "^5.3.1",
|
||||
"request": "^2.88.2",
|
||||
"xml-reader": "^2.4.3",
|
||||
"xml-writer": "^1.7.0"
|
||||
}
|
||||
}
|
||||
400
src/hdhr.js
Normal file
400
src/hdhr.js
Normal file
@ -0,0 +1,400 @@
|
||||
const Router = require('express').Router
|
||||
const SSDP = require('node-ssdp').Server
|
||||
const fs = require('fs')
|
||||
|
||||
const m3u = require('./m3u')
|
||||
const config = require('config-yml')
|
||||
|
||||
var device = {
|
||||
FriendlyName: "PsuedoTV",
|
||||
Manufacturer: "Silicondust",
|
||||
ManufacturerURL: "https://github.com/DEFENDORe",
|
||||
ModelNumber: "HDTC-2US",
|
||||
FirmwareName: "hdhomeruntc_atsc",
|
||||
TunerCount: config.HDHR_OPTIONS.tuners,
|
||||
FirmwareVersion: "20170930",
|
||||
DeviceID: config.HDHR_OPTIONS.uuid,
|
||||
DeviceAuth: "test1234",
|
||||
BaseURL: `http://${config.HOST}:${config.PORT}`,
|
||||
LineupURL: `http://${config.HOST}:${config.PORT}/lineup.json`
|
||||
}
|
||||
|
||||
const server = new SSDP({
|
||||
location: {
|
||||
port: config.PORT,
|
||||
path: '/device.xml'
|
||||
},
|
||||
udn: `uuid:${device.DeviceID}`,
|
||||
allowWildcards: true,
|
||||
ssdpSig: 'PPTV/3.0 UPnP/1.0'
|
||||
})
|
||||
|
||||
function startHDHR() {
|
||||
server.addUSN('upnp:rootdevice')
|
||||
server.addUSN('urn:schemas-upnp-org:device:MediaServer:1')
|
||||
server.addUSN('urn:schemas-upnp-org:service:ContentDirectory:1')
|
||||
server.addUSN('urn:schemas-upnp-org:service:ConnectionManager:1')
|
||||
server.start()
|
||||
}
|
||||
|
||||
function HDHRRouter() {
|
||||
|
||||
const router = Router()
|
||||
router.get('/device.xml', (req, res) => {
|
||||
res.header("Content-Type", "application/xml")
|
||||
var data = `<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:dlna="urn:schemas-dlna-org:device-1-0" xmlns:pnpx="http://schemas.microsoft.com/windows/pnpx/2005/11" xmlns:df="http://schemas.microsoft.com/windows/2008/09/devicefoundation">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<URLBase>${device.BaseURL}</URLBase>
|
||||
<device>
|
||||
<dlna:X_DLNADOC>DMS-1.50</dlna:X_DLNADOC>
|
||||
<pnpx:X_hardwareId>VEN_0115&DEV_1040&SUBSYS_0001&REV_0004 VEN_0115&DEV_1040&SUBSYS_0001 VEN_0115&DEV_1040</pnpx:X_hardwareId>
|
||||
<pnpx:X_deviceCategory>MediaDevices</pnpx:X_deviceCategory>
|
||||
<df:X_deviceCategory>Multimedia</df:X_deviceCategory>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>${device.FriendlyName}</friendlyName>
|
||||
<presentationURL>/</presentationURL>
|
||||
<manufacturer>${device.Manufacturer}</manufacturer>
|
||||
<manufacturerURL>${device.ManufacturerURL}</manufacturerURL>
|
||||
<modelDescription>${device.FriendlyName}</modelDescription>
|
||||
<modelName>${device.FriendlyName}</modelName>
|
||||
<modelNumber>${device.ModelNumber}</modelNumber>
|
||||
<modelURL>${device.ManufacturerURL}</modelURL>
|
||||
<serialNumber></serialNumber>
|
||||
<UDN>uuid:${device.DeviceID}</UDN>
|
||||
</device>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
|
||||
<SCPDURL>/ConnectionManager.xml</SCPDURL>
|
||||
<controlURL>${device.BaseURL}/ConnectionManager.xml</controlURL>
|
||||
<eventSubURL>${device.BaseURL}/ConnectionManager.xml</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
|
||||
<SCPDURL>/ContentDirectory.xml</SCPDURL>
|
||||
<controlURL>${device.BaseURL}/ContentDirectory.xml</controlURL>
|
||||
<eventSubURL>${device.BaseURL}/ContentDirectory.xml</eventSubURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</root>`
|
||||
res.send(data)
|
||||
})
|
||||
|
||||
router.get('/ConnectionManager.xml', (req, res) => {
|
||||
res.header("Content-Type", "application/xml")
|
||||
var data = `
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<actionList>
|
||||
<action>
|
||||
<name>GetProtocolInfo</name>
|
||||
<argumentList>
|
||||
<argument>
|
||||
<name>Source</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>Sink</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
|
||||
</argument>
|
||||
</argumentList>
|
||||
</action>
|
||||
<action>
|
||||
<name>GetCurrentConnectionIDs</name>
|
||||
<argumentList>
|
||||
<argument>
|
||||
<name>ConnectionIDs</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
|
||||
</argument>
|
||||
</argumentList>
|
||||
</action>
|
||||
<action>
|
||||
<name>GetCurrentConnectionInfo</name>
|
||||
<argumentList>
|
||||
<argument>
|
||||
<name>ConnectionID</name>
|
||||
<direction>in</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>RcsID</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>AVTransportID</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>ProtocolInfo</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>PeerConnectionManager</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>PeerConnectionID</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>Direction</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>Status</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
|
||||
</argument>
|
||||
</argumentList>
|
||||
</action>
|
||||
</actionList>
|
||||
<serviceStateTable>
|
||||
<stateVariable sendEvents="yes">
|
||||
<name>SourceProtocolInfo</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="yes">
|
||||
<name>SinkProtocolInfo</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="yes">
|
||||
<name>CurrentConnectionIDs</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_ConnectionStatus</name>
|
||||
<dataType>string</dataType>
|
||||
<allowedValueList>
|
||||
<allowedValue>OK</allowedValue>
|
||||
<allowedValue>ContentFormatMismatch</allowedValue>
|
||||
<allowedValue>InsufficientBandwidth</allowedValue>
|
||||
<allowedValue>UnreliableChannel</allowedValue>
|
||||
<allowedValue>Unknown</allowedValue>
|
||||
</allowedValueList>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_ConnectionManager</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_Direction</name>
|
||||
<dataType>string</dataType>
|
||||
<allowedValueList>
|
||||
<allowedValue>Input</allowedValue>
|
||||
<allowedValue>Output</allowedValue>
|
||||
</allowedValueList>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_ProtocolInfo</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_ConnectionID</name>
|
||||
<dataType>i4</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_AVTransportID</name>
|
||||
<dataType>i4</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_RcsID</name>
|
||||
<dataType>i4</dataType>
|
||||
</stateVariable>
|
||||
</serviceStateTable>
|
||||
</scpd>`;
|
||||
res.send(data)
|
||||
})
|
||||
|
||||
router.get('/ContentDirectory.xml', (req, res) => {
|
||||
res.header("Content-Type", "application/xml")
|
||||
var data = `
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<actionList>
|
||||
<action>
|
||||
<name>Browse</name>
|
||||
<argumentList>
|
||||
<argument>
|
||||
<name>ObjectID</name>
|
||||
<direction>in</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>BrowseFlag</name>
|
||||
<direction>in</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>Filter</name>
|
||||
<direction>in</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>StartingIndex</name>
|
||||
<direction>in</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>RequestedCount</name>
|
||||
<direction>in</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>SortCriteria</name>
|
||||
<direction>in</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>Result</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>NumberReturned</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>TotalMatches</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
|
||||
</argument>
|
||||
<argument>
|
||||
<name>UpdateID</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
|
||||
</argument>
|
||||
</argumentList>
|
||||
</action>
|
||||
<action>
|
||||
<name>GetSearchCapabilities</name>
|
||||
<argumentList>
|
||||
<argument>
|
||||
<name>SearchCaps</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>SearchCapabilities</relatedStateVariable>
|
||||
</argument>
|
||||
</argumentList>
|
||||
</action>
|
||||
<action>
|
||||
<name>GetSortCapabilities</name>
|
||||
<argumentList>
|
||||
<argument>
|
||||
<name>SortCaps</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>SortCapabilities</relatedStateVariable>
|
||||
</argument>
|
||||
</argumentList>
|
||||
</action>
|
||||
<action>
|
||||
<name>GetSystemUpdateID</name>
|
||||
<argumentList>
|
||||
<argument>
|
||||
<name>Id</name>
|
||||
<direction>out</direction>
|
||||
<relatedStateVariable>SystemUpdateID</relatedStateVariable>
|
||||
</argument>
|
||||
</argumentList>
|
||||
</action>
|
||||
</actionList>
|
||||
<serviceStateTable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_SortCriteria</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_UpdateID</name>
|
||||
<dataType>ui4</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_Filter</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_Result</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_Index</name>
|
||||
<dataType>ui4</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_ObjectID</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>SortCapabilities</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>SearchCapabilities</name>
|
||||
<dataType>string</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_Count</name>
|
||||
<dataType>ui4</dataType>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="no">
|
||||
<name>A_ARG_TYPE_BrowseFlag</name>
|
||||
<dataType>string</dataType>
|
||||
<allowedValueList>
|
||||
<allowedValue>BrowseMetadata</allowedValue>
|
||||
<allowedValue>BrowseDirectChildren</allowedValue>
|
||||
</allowedValueList>
|
||||
</stateVariable>
|
||||
<stateVariable sendEvents="yes">
|
||||
<name>SystemUpdateID</name>
|
||||
<dataType>ui4</dataType>
|
||||
</stateVariable>
|
||||
</serviceStateTable>
|
||||
</scpd>`
|
||||
res.send(data)
|
||||
})
|
||||
|
||||
router.get('/discover.json', (req, res) => {
|
||||
res.header("Content-Type", "application/json")
|
||||
res.send(JSON.stringify(device))
|
||||
})
|
||||
|
||||
router.get('/lineup_status.json', (req, res) => {
|
||||
res.header("Content-Type", "application/json")
|
||||
var data = {
|
||||
ScanInProgress: 0,
|
||||
ScanPossible: 1,
|
||||
Source: "Cable",
|
||||
SourceList: ["Cable"],
|
||||
}
|
||||
res.send(JSON.stringify(data))
|
||||
})
|
||||
|
||||
router.get('/lineup.json', (req, res) => {
|
||||
res.header("Content-Type", "application/json")
|
||||
var data = m3u.ReadChannels()
|
||||
res.send(JSON.stringify(data))
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
module.exports = { router: HDHRRouter, start: startHDHR }
|
||||
31
src/m3u.js
Normal file
31
src/m3u.js
Normal file
@ -0,0 +1,31 @@
|
||||
const fs = require('fs')
|
||||
const config = require('config-yml')
|
||||
|
||||
function WriteM3U(channels, cb) {
|
||||
var data = "#EXTM3U\n"
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
data += `#EXTINF:0 tvg-id="${channels[i].channel}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}",${channels[i].channel}\n`
|
||||
data += `http://${config.HOST}:${config.PORT}/video?channel=${channels[i].channel}\n`
|
||||
}
|
||||
fs.writeFileSync(config.M3U_OUTPUT, data)
|
||||
if (typeof cb == 'function')
|
||||
cb()
|
||||
}
|
||||
// Formatted for HDHR lineup..
|
||||
function ReadChannels() {
|
||||
var m3uData = fs.readFileSync(config.M3U_OUTPUT)
|
||||
var track = m3uData.toString().split(/[\n]+/)
|
||||
var channels = []
|
||||
track.splice(0, 1)
|
||||
track.pop()
|
||||
for (var i = 0; i < track.length; i += 2) {
|
||||
var tmp = track[i].split("\"")
|
||||
channels.push({ GuideNumber: tmp[1], GuideName: tmp[3], URL: track[i + 1] })
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WriteM3U: WriteM3U,
|
||||
ReadChannels: ReadChannels
|
||||
}
|
||||
83
src/plex.js
Normal file
83
src/plex.js
Normal file
@ -0,0 +1,83 @@
|
||||
const plex = require('plex-api')
|
||||
const config = require('config-yml')
|
||||
|
||||
const client = new plex(config.PLEX_OPTIONS)
|
||||
|
||||
function PlexChannelScan(cb) {
|
||||
getPsuedoTVPlaylists((lineup) => {
|
||||
getAllPlaylistsInfo(lineup, cb)
|
||||
})
|
||||
}
|
||||
|
||||
function getPsuedoTVPlaylists(cb) {
|
||||
var lineup = []
|
||||
client.query("/playlists/").then((result) => {
|
||||
var playlists = result.MediaContainer
|
||||
for (var i = 0; playlists.size > 0 && i < playlists.Metadata.length; i++) {
|
||||
var summaryData = playlists.Metadata[i].summary.split(/\s+/)
|
||||
if (playlists.Metadata[i].playlistType == 'video' && summaryData.length > 0 && summaryData[0].toLowerCase() == config.PLEX_PLAYLIST_SUMMARY_KEY) {
|
||||
var channelNumber = playlists.Metadata[i].ratingKey
|
||||
var channelIcon = ""
|
||||
if (summaryData.length > 1) {
|
||||
if (!isNaN(summaryData[1]))
|
||||
channelNumber = summaryData[1]
|
||||
else if (validURL(summaryData[1]))
|
||||
channelIcon = summaryData[1]
|
||||
}
|
||||
if (summaryData.length > 2) {
|
||||
if (!isNaN(summaryData[2]))
|
||||
channelNumber = summaryData[2]
|
||||
else if (validURL(summaryData[2]))
|
||||
channelIcon = summaryData[2]
|
||||
}
|
||||
lineup.push({ id: playlists.Metadata[i].ratingKey, channel: channelNumber, name: playlists.Metadata[i].title, icon: channelIcon, summary: playlists.Metadata[i].summary, updatedAt: playlists.Metadata[i].updatedAt })
|
||||
}
|
||||
}
|
||||
cb(lineup)
|
||||
}, (err) => {
|
||||
console.error("Could not connect to Plex server", err)
|
||||
})
|
||||
}
|
||||
|
||||
function getAllPlaylistsInfo(lineup, cb) {
|
||||
var channelIndex = 0
|
||||
if (lineup.length == 0)
|
||||
return cb([])
|
||||
var lastUpdatedAt = 0
|
||||
var channelInfo = []
|
||||
getPlaylist(channelIndex, () => {
|
||||
cb(lineup, lastUpdatedAt, channelInfo.join())
|
||||
})
|
||||
// Fetch each playlist (channel) recursivley from Plex
|
||||
function getPlaylist(i, _cb) {
|
||||
client.query("/playlists/" + lineup[i].id + "/items").then(function (result) {
|
||||
var playlist = result.MediaContainer.Metadata
|
||||
lastUpdatedAt = lastUpdatedAt > lineup[i].updatedAt ? lastUpdatedAt : lineup[i].updatedAt
|
||||
channelInfo.push(lineup[i].name)
|
||||
channelInfo.push(lineup[i].summary)
|
||||
lineup[i].duration = typeof result.MediaContainer.duration !== 'undefined' ? result.MediaContainer.duration : 0
|
||||
lineup[i].playlist = typeof playlist !== 'undefined' ? playlist : []
|
||||
channelIndex++
|
||||
if (channelIndex < lineup.length)
|
||||
getPlaylist(channelIndex, _cb)
|
||||
else
|
||||
_cb()
|
||||
}, function (err) {
|
||||
console.error("Could not connect to Plex server", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function validURL(str) {
|
||||
var pattern = new RegExp('^(https?:\\/\\/)?' + // protocol
|
||||
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
|
||||
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
|
||||
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
|
||||
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
|
||||
'(\\#[-a-z\\d_]*)?$', 'i'); // fragment locator
|
||||
return !!pattern.test(str);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PlexChannelScan: PlexChannelScan
|
||||
}
|
||||
81
src/vlc.js
Normal file
81
src/vlc.js
Normal file
@ -0,0 +1,81 @@
|
||||
const Router = require('express').Router
|
||||
const spawn = require('child_process').spawn
|
||||
const request = require('request')
|
||||
const config = require('config-yml')
|
||||
|
||||
const xmltv = require('./xmltv')
|
||||
|
||||
module.exports = { router: vlcRouter }
|
||||
|
||||
function vlcRouter() {
|
||||
var router = Router()
|
||||
var streams = []
|
||||
|
||||
router.get('/video', (req, res) => {
|
||||
var programs = xmltv.readXMLPrograms()
|
||||
if (!req.query.channel)
|
||||
return res.status(422).send("No channel queried")
|
||||
|
||||
req.query.channel = req.query.channel.split('?')[0]
|
||||
var streamIndex = -1
|
||||
for (var i = 0; i < streams.length; i++) {
|
||||
if (streams[i].channel === req.query.channel) {
|
||||
streamIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (streamIndex != -1) {
|
||||
streams[streamIndex].viewers++
|
||||
request('http://' + config.HOST + ':' + streams[streamIndex].port + '/').on('error', (err) => {/* ignore errors */}).pipe(res)
|
||||
} else {
|
||||
var args = []
|
||||
var startPos = 0
|
||||
var programIndex = 0
|
||||
for (var i = 0; i < programs.length; i++) {
|
||||
var date = new Date()
|
||||
if (programs[i].start <= date && programs[i].stop >= date && programs[i].channel == req.query.channel) {
|
||||
var dif = date.getTime() - programs[i].start.getTime()
|
||||
startPos = dif / 1000
|
||||
programIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
for (var i = programIndex; i < programs.length; i++)
|
||||
if (programs[i].channel == req.query.channel)
|
||||
args.push(programs[i].video)
|
||||
|
||||
if (args.length == 0)
|
||||
return res.status(422).send("Channel not found")
|
||||
|
||||
var vlcPort = config.PORT + streams.length + 1
|
||||
|
||||
args.push("--start-time=" + startPos)
|
||||
if (programs.optimized)
|
||||
args.push(`--sout=#http{mux=ts,dst=:${vlcPort}/}`)
|
||||
else
|
||||
args.push(`--sout=#${config.VLC_TRANSCODE_SETTINGS}:http{mux=ts,dst=:${vlcPort}/}`)
|
||||
if (config.VLC_HIDDEN)
|
||||
args.push("--intf=dummy")
|
||||
|
||||
|
||||
var vlcExe = spawn(config.VLC_EXECUTABLE, args)
|
||||
var stream = { vlcExe: vlcExe, channel: req.query.channel, viewers: 1, port: vlcPort }
|
||||
streamIndex = streams.length
|
||||
streams.push(stream)
|
||||
setTimeout(() => {
|
||||
request(`http://${config.HOST}:${vlcPort}/`).on('error', function (err) {/* ignore errors */}).pipe(res)
|
||||
}, config.VLC_STARTUP_DELAY)
|
||||
}
|
||||
|
||||
res.on('close', () => {
|
||||
streams[streamIndex].viewers--
|
||||
if (streams[streamIndex].viewers == 0) {
|
||||
streams[streamIndex].vlcExe.kill()
|
||||
streams.splice(streamIndex, 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
300
src/xmltv.js
Normal file
300
src/xmltv.js
Normal file
@ -0,0 +1,300 @@
|
||||
const XMLWriter = require('xml-writer')
|
||||
const XMLReader = require('xml-reader')
|
||||
const fs = require('fs')
|
||||
const config = require('config-yml')
|
||||
|
||||
function readXMLPrograms() {
|
||||
var data = fs.readFileSync(config.XMLTV_OUTPUT)
|
||||
var xmltv = XMLReader.parseSync(data.toString())
|
||||
var programs = []
|
||||
var tv = xmltv.children
|
||||
for (var i = 0; i < tv.length; i++) {
|
||||
if (tv[i].name == 'channel')
|
||||
continue;
|
||||
var program = {
|
||||
channel: tv[i].attributes.channel,
|
||||
start: createDate(tv[i].attributes.start),
|
||||
stop: createDate(tv[i].attributes.stop),
|
||||
video: tv[i].attributes.video,
|
||||
optimized: tv[i].attributes.optimized == "true" ? true : false
|
||||
}
|
||||
programs.push(program)
|
||||
}
|
||||
return programs
|
||||
}
|
||||
|
||||
function WriteXMLTV(channels, cb) {
|
||||
var xw = new XMLWriter(true);
|
||||
var time = new Date()
|
||||
// Build XMLTV and M3U files
|
||||
xw.startDocument()
|
||||
// Root TV Element
|
||||
xw.startElement('tv')
|
||||
xw.writeAttribute('generator-info-name', 'psuedotv-plex')
|
||||
writeChannels(xw, channels)
|
||||
// Programmes
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
var future = new Date()
|
||||
future.setHours(time.getHours() + config.EPG_CACHE)
|
||||
var tempDate = new Date(time.valueOf())
|
||||
while (tempDate < future && channels[i].playlist.length > 0) {
|
||||
for (var y = 0; y < channels[i].playlist.length; y++) {
|
||||
var stopDate = new Date(tempDate.valueOf())
|
||||
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration)
|
||||
var plexURL = channels[i].playlist[y].Media[0].Part[0].key
|
||||
var optimizedForStreaming = false
|
||||
for (var z = 0; z < channels[i].playlist[y].Media.length; z++) {
|
||||
var part = channels[i].playlist[y].Media[z].Part[0]
|
||||
if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) {
|
||||
plexURL = part.key
|
||||
optimizedForStreaming = part.optimizedForStreaming
|
||||
break;
|
||||
}
|
||||
}
|
||||
plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}`
|
||||
var program = {
|
||||
info: channels[i].playlist[y],
|
||||
channel: channels[i].channel,
|
||||
start: new Date(tempDate.valueOf()),
|
||||
stop: stopDate,
|
||||
plexURL: plexURL,
|
||||
optimizedForStreaming: optimizedForStreaming.toString()
|
||||
}
|
||||
writeProgramme(xw, program)
|
||||
tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].playlist[y].duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
// End TV
|
||||
xw.endElement()
|
||||
xw.endDocument()
|
||||
fs.writeFileSync(config.XMLTV_OUTPUT, xw.toString())
|
||||
if (typeof cb == 'function')
|
||||
cb()
|
||||
}
|
||||
|
||||
function UpdateXMLTV(channels, cb) {
|
||||
var xw = new XMLWriter(true)
|
||||
var data = fs.readFileSync(config.XMLTV_OUTPUT)
|
||||
var xml = XMLReader.parseSync(data.toString())
|
||||
var time = new Date()
|
||||
xw.startDocument()
|
||||
xw.startElement('tv')
|
||||
xw.writeAttribute('generator-info-name', 'psuedotv-plex')
|
||||
writeChannels(xw, channels)
|
||||
// Programmes
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
// get non-expired programmes for channel
|
||||
var validPrograms = []
|
||||
for (var y = 0; y < xml.children.length; y++) {
|
||||
if (xml.children[y].name == 'programme' && xml.children[y].attributes.channel == channels[i].channel) {
|
||||
var showStop = createDate(xml.children[y].attributes.stop)
|
||||
if (showStop > time)
|
||||
validPrograms.push(xml.children[y])
|
||||
}
|
||||
}
|
||||
// If Channel doesnt exists..
|
||||
if (validPrograms.length == 0) {
|
||||
// write out programs from plex
|
||||
var future = new Date()
|
||||
future.setHours(time.getHours() + config.EPG_CACHE)
|
||||
var tempDate = new Date(time.valueOf())
|
||||
while (tempDate < future) {
|
||||
for (var y = 0; y < channels[i].playlist.length; y++) { // foreach item in playlist
|
||||
var stopDate = new Date(tempDate.valueOf())
|
||||
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration)
|
||||
var plexURL = channels[i].playlist[y].Media[0].Part[0].key
|
||||
var optimizedForStreaming = false
|
||||
for (var z = 0; z < channels[i].playlist[y].Media.length; z++) { // get optimed video if there is one
|
||||
var part = channels[i].playlist[y].Media[z].Part[0]
|
||||
if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) {
|
||||
plexURL = part.key
|
||||
optimizedForStreaming = part.optimizedForStreaming
|
||||
break;
|
||||
}
|
||||
}
|
||||
plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}`
|
||||
var program = {
|
||||
info: channels[i].playlist[y],
|
||||
channel: channels[i].channel,
|
||||
start: new Date(tempDate.valueOf()),
|
||||
stop: stopDate,
|
||||
plexURL: plexURL,
|
||||
optimizedForStreaming: optimizedForStreaming.toString()
|
||||
}
|
||||
writeProgramme(xw, program)
|
||||
tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].playlist[y].duration)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var playlistStartIndex = 0
|
||||
var isFirstItemFound = false
|
||||
var startingDate = new Date(time.valueOf())
|
||||
var endDate = new Date(time.valueOf())
|
||||
endDate.setHours(endDate.getHours() + config.EPG_CACHE)
|
||||
// rewrite first valid xml programmes, if it still exists in the plex playlist..
|
||||
for (var z = 0; z < channels[i].playlist.length; z++) {
|
||||
if (channels[i].playlist[z].guid == validPrograms[0].attributes.guid) {
|
||||
|
||||
isFirstItemFound = true
|
||||
playlistStartIndex = z
|
||||
var program = {
|
||||
channel: validPrograms[0].attributes.channel,
|
||||
start: createDate(validPrograms[0].attributes.start),
|
||||
stop: createDate(validPrograms[0].attributes.stop),
|
||||
plexURL: validPrograms[0].attributes.video,
|
||||
optimizedForStreaming: validPrograms[0].attributes.optimized,
|
||||
info: channels[i].playlist[z]
|
||||
}
|
||||
startingDate = new Date(program.stop.valueOf())
|
||||
writeProgramme(xw, program)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isFirstItemFound) {
|
||||
playlistStartIndex++
|
||||
if (channels[i].playlist.length == playlistStartIndex)
|
||||
playlistStartIndex = 0
|
||||
}
|
||||
|
||||
// write programs from plex, starting at the live playlist index.
|
||||
while (startingDate < endDate) {
|
||||
for (var y = playlistStartIndex; y < channels[i].playlist.length; y++) {
|
||||
var stopDate = new Date(startingDate.valueOf())
|
||||
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration)
|
||||
var plexURL = channels[i].playlist[y].Media[0].Part[0].key
|
||||
var optimizedForStreaming = false
|
||||
for (var z = 0; z < channels[i].playlist[y].Media.length; z++) {
|
||||
var part = channels[i].playlist[y].Media[z].Part[0]
|
||||
if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) {
|
||||
plexURL = part.key
|
||||
optimizedForStreaming = part.optimizedForStreaming
|
||||
break;
|
||||
}
|
||||
}
|
||||
plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}`
|
||||
var program = {
|
||||
info: channels[i].playlist[y],
|
||||
channel: channels[i].channel,
|
||||
start: new Date(startingDate.valueOf()),
|
||||
stop: stopDate,
|
||||
plexURL: plexURL,
|
||||
optimizedForStreaming: optimizedForStreaming.toString()
|
||||
}
|
||||
writeProgramme(xw, program)
|
||||
startingDate.setMilliseconds(startingDate.getMilliseconds() + channels[i].playlist[y].duration)
|
||||
playlistStartIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// End TV
|
||||
xw.endElement()
|
||||
// End Doc
|
||||
xw.endDocument()
|
||||
fs.writeFileSync(config.XMLTV_OUTPUT, xw.toString())
|
||||
if (typeof cb == 'function')
|
||||
cb()
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
WriteXMLTV: WriteXMLTV,
|
||||
UpdateXMLTV: UpdateXMLTV,
|
||||
readXMLPrograms: readXMLPrograms
|
||||
}
|
||||
function writeChannels(xw, channels) {
|
||||
// Channels
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
xw.startElement('channel')
|
||||
xw.writeAttribute('id', channels[i].channel)
|
||||
xw.startElement('display-name')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
xw.text(channels[i].name)
|
||||
xw.endElement()
|
||||
if (channels[i].icon) {
|
||||
xw.startElement('icon')
|
||||
xw.writeAttribute('src', channels[i].icon)
|
||||
xw.endElement()
|
||||
}
|
||||
xw.endElement()
|
||||
}
|
||||
}
|
||||
function writeProgramme(xw, program) {
|
||||
// Programme
|
||||
xw.startElement('programme')
|
||||
xw.writeAttribute('start', createXMLTVDate(program.start))
|
||||
xw.writeAttribute('stop', createXMLTVDate(program.stop))
|
||||
xw.writeAttribute('channel', program.channel)
|
||||
// For VLC to handle...
|
||||
xw.writeAttribute('video', program.plexURL)
|
||||
xw.writeAttribute('optimized', program.optimizedForStreaming)
|
||||
xw.writeAttribute('guid', program.info.guid)
|
||||
// Title
|
||||
xw.startElement('title')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
if (program.info.type == 'episode')
|
||||
xw.text(program.info.grandparentTitle)
|
||||
else
|
||||
xw.text(program.info.title)
|
||||
xw.endElement()
|
||||
if (program.info.type == 'episode') {
|
||||
xw.writeRaw('\n <previously-shown/>')
|
||||
// Sub-Title
|
||||
xw.startElement('sub-title')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
xw.text(program.info.title)
|
||||
xw.endElement()
|
||||
// Episode-Number
|
||||
xw.startElement('episode-num')
|
||||
xw.writeAttribute('system', 'xmltv_ns')
|
||||
xw.text((program.info.parentIndex - 1) + ' . ' + (program.info.index - 1) + ' . 0/1')
|
||||
xw.endElement()
|
||||
}
|
||||
// Icon
|
||||
xw.startElement('icon')
|
||||
if (program.info.type == 'movie')
|
||||
xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.thumb + '?X-Plex-Token=' + config.PLEX_OPTIONS.token)
|
||||
else if (program.info.type == 'episode')
|
||||
xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.parentThumb + '?X-Plex-Token=' + config.PLEX_OPTIONS.token)
|
||||
xw.endElement()
|
||||
// Desc
|
||||
xw.startElement('desc')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
xw.text(program.info.summary)
|
||||
xw.endElement()
|
||||
// Date
|
||||
if (typeof program.info.originallyAvailableAt !== 'undefined')
|
||||
xw.writeElement('date', program.info.originallyAvailableAt.split('-').join(''))
|
||||
// Rating
|
||||
if (typeof program.info.contentRating != 'undefined') {
|
||||
xw.startElement('rating')
|
||||
xw.writeAttribute('system', 'MPAA')
|
||||
xw.writeElement('value', program.info.contentRating)
|
||||
xw.endElement()
|
||||
}
|
||||
// End of Programme
|
||||
xw.endElement()
|
||||
}
|
||||
function createXMLTVDate(d) {
|
||||
function pad(n) { return n < 10 ? '0' + n : n }
|
||||
var timezone = d.toString().split('GMT')
|
||||
timezone = timezone[timezone.length - 1].split(' ')[0]
|
||||
return d.getFullYear() + ""
|
||||
+ pad(d.getMonth() + 1) + ""
|
||||
+ pad(d.getDate()) + ""
|
||||
+ pad(d.getHours()) + ""
|
||||
+ pad(d.getMinutes()) + ""
|
||||
+ pad(d.getSeconds()) + " " + timezone
|
||||
}
|
||||
function createDate(xmlDate) {
|
||||
var year = xmlDate.substr(0, 4)
|
||||
var month = xmlDate.substr(4, 2) - 1
|
||||
var day = xmlDate.substr(6, 2)
|
||||
var hour = xmlDate.substr(8, 2)
|
||||
var min = xmlDate.substr(10, 2)
|
||||
var sec = xmlDate.substr(12, 2)
|
||||
var date = new Date(year, month, day, hour, min, sec) // fuck the timezone.. It'll be the same as a new Date()...
|
||||
return date
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user