diff --git a/.circleci/config.yml b/.circleci/config.yml index b41ea7c..b29cddc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,12 +4,8 @@ jobs: docker: - image: circleci/golang:1 - working_directory: /go/src/github.com/tellytv/telly steps: - checkout - - run: curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - - run: go get -u github.com/alecthomas/gometalinter - - run: gometalinter --install - - run: dep ensure -vendor-only - - run: go test -v ./... - - run: gometalinter --config=.gometalinter.json ./... + - run: go get -u github.com/golangci/golangci-lint/cmd/golangci-lint + - run: golangci-lint run ./... + - run: go test -v ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore index a22990c..a2f0f40 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,12 @@ telly .DS_Store /.GOPATH /bin -*.xml +/*.xml vendor/ /.build /.release /.tarballs *.tar.gz +telly.config.* +*.db +.gometalinter-* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c601d1c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "frontend"] + path = frontend + url = https://github.com/tellytv/frontend.git diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..bf28f12 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,34 @@ +linters-settings: + goimports: + local-prefixes: github.com/telly/telly + errcheck: + ignore: fmt:.*,AbortWithError + +linters: + enable-all: false + enable: + - deadcode + - errcheck + - gochecknoinits + - goconst + - gofmt + - goimports + - golint + - gosec + - ineffassign + - interfacer + - megacheck + - misspell + - nakedret + - structcheck + - unconvert + - varcheck + - vet + - vetshadow + disable: + - unused + - unparam + +run: + skip-files: + - ".*-packr.go$" \ No newline at end of file diff --git a/.gometalinter.json b/.gometalinter.json deleted file mode 100644 index 8addeeb..0000000 --- a/.gometalinter.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "Enable": [ - "deadcode", - "errcheck", - "gochecknoinits", - "goconst", - "gofmt", - "goimports", - "golint", - "gosec", - "gotype", - "gotypex", - "ineffassign", - "interfacer", - "megacheck", - "misspell", - "nakedret", - "safesql", - "structcheck", - "test", - "testify", - "unconvert", - "unparam", - "varcheck", - "vet", - "vetshadow" - ], - "Deadline": "5m", - "Sort": [ - "path", - "linter" - ], - "Vendor": true -} diff --git a/.promu.yml b/.promu.yml index e3986e5..2872458 100644 --- a/.promu.yml +++ b/.promu.yml @@ -1,14 +1,26 @@ repository: - path: github.com/tellytv/telly + path: github.com/tellytv/telly +go: + cgo: true build: - flags: -a -tags netgo - ldflags: | - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Version={{.Version}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Revision={{.Revision}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Branch={{.Branch}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildUser={{user}}@{{host}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} + flags: -a -tags 'netgo json1' + ldflags: | + -X github.com/prometheus/common/version.Version={{.Version}} + -X github.com/prometheus/common/version.Revision={{.Revision}} + -X github.com/prometheus/common/version.Branch={{.Branch}} + -X github.com/prometheus/common/version.BuildUser={{user}}@{{host}} + -X github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} tarball: - files: - - LICENSE - - NOTICE + files: + - LICENSE + - NOTICE +crossbuild: + platforms: + - linux/amd64 + - linux/386 + - darwin/amd64 + - darwin/386 + - windows/amd64 + - windows/386 + - linux/arm + - linux/arm64 diff --git a/Dockerfile b/Dockerfile index fb5a489..7284ee9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,6 @@ -FROM golang:alpine as builder - -# Download and install the latest release of dep -ADD https://github.com/golang/dep/releases/download/v0.5.0/dep-linux-amd64 /usr/bin/dep -RUN chmod +x /usr/bin/dep - -# Install git because gin/yaml needs it -RUN apk update && apk upgrade && apk add git - -# Copy the code from the host and compile it -WORKDIR $GOPATH/src/github.com/tellytv/telly -COPY Gopkg.toml Gopkg.lock ./ -RUN dep ensure --vendor-only -COPY . ./ -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /app . - -# install ca root certificates + listen on 0.0.0.0 + build -RUN apk add --no-cache ca-certificates \ - && find . -type f -print0 | xargs -0 sed -i 's/"listen", "localhost/"listen", "0.0.0.0/g' \ - && CGO_ENABLED=0 GOOS=linux go install -ldflags '-w -s -extldflags "-static"' - FROM scratch -COPY --from=builder /app ./ -COPY --from=builder /etc/ssl/certs/ /etc/ssl/certs/ +COPY .build/linux-amd64/telly ./app EXPOSE 6077 ENTRYPOINT ["./app"] + + diff --git a/Gopkg.lock b/Gopkg.lock deleted file mode 100644 index 6544274..0000000 --- a/Gopkg.lock +++ /dev/null @@ -1,227 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - branch = "master" - digest = "1:315c5f2f60c76d89b871c73f9bd5fe689cad96597afd50fb9992228ef80bdd34" - name = "github.com/alecthomas/template" - packages = [ - ".", - "parse", - ] - pruneopts = "UT" - revision = "a0175ee3bccc567396460bf5acd36800cb10c49c" - -[[projects]] - branch = "master" - digest = "1:c198fdc381e898e8fb62b8eb62758195091c313ad18e52a3067366e1dda2fb3c" - name = "github.com/alecthomas/units" - packages = ["."] - pruneopts = "UT" - revision = "2efee857e7cfd4f3d0138cc3cbb1b4966962b93a" - -[[projects]] - branch = "master" - digest = "1:d6afaeed1502aa28e80a4ed0981d570ad91b2579193404256ce672ed0a609e0d" - name = "github.com/beorn7/perks" - packages = ["quantile"] - pruneopts = "UT" - revision = "3a771d992973f24aa725d07868b467d1ddfceafb" - -[[projects]] - branch = "master" - digest = "1:36fe9527deed01d2a317617e59304eb2c4ce9f8a24115bcc5c2e37b3aee5bae4" - name = "github.com/gin-contrib/sse" - packages = ["."] - pruneopts = "UT" - revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" - -[[projects]] - digest = "1:489e108f21464371ebf9cb5c30b1eceb07c6dd772dff073919267493dd9d04ea" - name = "github.com/gin-gonic/gin" - packages = [ - ".", - "binding", - "render", - ] - pruneopts = "UT" - revision = "d459835d2b077e44f7c9b453505ee29881d5d12d" - version = "v1.2" - -[[projects]] - digest = "1:15042ad3498153684d09f393bbaec6b216c8eec6d61f63dff711de7d64ed8861" - name = "github.com/golang/protobuf" - packages = ["proto"] - pruneopts = "UT" - revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" - version = "v1.1.0" - -[[projects]] - branch = "master" - digest = "1:8f57afa9ef1d9205094e9d89b9cb4ecb3123f342c4eb0053d7631181b511e6e4" - name = "github.com/koron/go-ssdp" - packages = ["."] - pruneopts = "UT" - revision = "4a0ed625a78b6858dc8d3a55fb7728968b712122" - -[[projects]] - digest = "1:fa610f9fe6a93f4a75e64c83673dfff9bf1a34bbb21e6102021b6bc7850834a3" - name = "github.com/mattn/go-isatty" - packages = ["."] - pruneopts = "UT" - revision = "57fdcb988a5c543893cc61bce354a6e24ab70022" - -[[projects]] - digest = "1:ff5ebae34cfbf047d505ee150de27e60570e8c394b3b8fdbb720ff6ac71985fc" - name = "github.com/matttproud/golang_protobuf_extensions" - packages = ["pbutil"] - pruneopts = "UT" - revision = "c12348ce28de40eed0136aa2b644d0ee0650e56c" - version = "v1.0.1" - -[[projects]] - branch = "master" - digest = "1:5ab79470a1d0fb19b041a624415612f8236b3c06070161a910562f2b2d064355" - name = "github.com/mitchellh/mapstructure" - packages = ["."] - pruneopts = "UT" - revision = "f15292f7a699fcc1a38a80977f80a046874ba8ac" - -[[projects]] - digest = "1:d14a5f4bfecf017cb780bdde1b6483e5deb87e12c332544d2c430eda58734bcb" - name = "github.com/prometheus/client_golang" - packages = [ - "prometheus", - "prometheus/promhttp", - ] - pruneopts = "UT" - revision = "c5b7fccd204277076155f10851dad72b76a49317" - version = "v0.8.0" - -[[projects]] - branch = "master" - digest = "1:2d5cd61daa5565187e1d96bae64dbbc6080dacf741448e9629c64fd93203b0d4" - name = "github.com/prometheus/client_model" - packages = ["go"] - pruneopts = "UT" - revision = "5c3871d89910bfb32f5fcab2aa4b9ec68e65a99f" - -[[projects]] - branch = "master" - digest = "1:9b2b68310a7555601c28980840f4d6966f8ff5443e11f4f78d227dbf73205132" - name = "github.com/prometheus/common" - packages = [ - "expfmt", - "internal/bitbucket.org/ww/goautoneg", - "model", - "version", - ] - pruneopts = "UT" - revision = "c7de2306084e37d54b8be01f3541a8464345e9a5" - -[[projects]] - branch = "master" - digest = "1:8c49953a1414305f2ff5465147ee576dd705487c35b15918fcd4efdc0cb7a290" - name = "github.com/prometheus/procfs" - packages = [ - ".", - "internal/util", - "nfs", - "xfs", - ] - pruneopts = "UT" - revision = "05ee40e3a273f7245e8777337fc7b46e533a9a92" - -[[projects]] - digest = "1:d867dfa6751c8d7a435821ad3b736310c2ed68945d05b50fb9d23aee0540c8cc" - name = "github.com/sirupsen/logrus" - packages = ["."] - pruneopts = "UT" - revision = "3e01752db0189b9157070a0e1668a620f9a85da2" - version = "v1.0.6" - -[[projects]] - digest = "1:c268acaa4a4d94a467980e5e91452eb61c460145765293dc0aed48e5e9919cc6" - name = "github.com/ugorji/go" - packages = ["codec"] - pruneopts = "UT" - revision = "c88ee250d0221a57af388746f5cf03768c21d6e2" - -[[projects]] - branch = "master" - digest = "1:7e4543a28ce437be9d263089699c5fd6cefc0f02a63592f7f85c0c4e21245e0a" - name = "github.com/zsais/go-gin-prometheus" - packages = ["."] - pruneopts = "UT" - revision = "3f93884fa240fd102425d65ce9781e561ba40496" - -[[projects]] - branch = "master" - digest = "1:3f3a05ae0b95893d90b9b3b5afdb79a9b3d96e4e36e099d841ae602e4aca0da8" - name = "golang.org/x/crypto" - packages = ["ssh/terminal"] - pruneopts = "UT" - revision = "de0752318171da717af4ce24d0a2e8626afaeb11" - -[[projects]] - branch = "master" - digest = "1:937d8f64b118c494c48b0cc9c990f2163c7483e6c70b5828f20006d81c61412f" - name = "golang.org/x/net" - packages = [ - "bpf", - "internal/iana", - "internal/socket", - "ipv4", - ] - pruneopts = "UT" - revision = "c39426892332e1bb5ec0a434a079bf82f5d30c54" - -[[projects]] - branch = "master" - digest = "1:a60cae5be8993938498243605b120290533a5208fd5cac81c932afbad3642fb0" - name = "golang.org/x/sys" - packages = [ - "unix", - "windows", - ] - pruneopts = "UT" - revision = "98c5dad5d1a0e8a73845ecc8897d0bd56586511d" - -[[projects]] - digest = "1:c06d9e11d955af78ac3bbb26bd02e01d2f61f689e1a3bce2ef6fb683ef8a7f2d" - name = "gopkg.in/alecthomas/kingpin.v2" - packages = ["."] - pruneopts = "UT" - revision = "947dcec5ba9c011838740e680966fd7087a71d0d" - version = "v2.2.6" - -[[projects]] - digest = "1:1b4724d3c8125f6044925f02b485b74bfec9905cbf579d95aafd1a6c8f8447d3" - name = "gopkg.in/go-playground/validator.v8" - packages = ["."] - pruneopts = "UT" - revision = "5f57d2222ad794d0dffb07e664ea05e2ee07d60c" - version = "v8.18.1" - -[[projects]] - digest = "1:cacb98d52c60c337c2ce95a7af83ba0313a93ce5e73fa9e99a96aff70776b9d3" - name = "gopkg.in/yaml.v2" - packages = ["."] - pruneopts = "UT" - revision = "a5b47d31c556af34a302ce5d659e6fea44d90de0" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - input-imports = [ - "github.com/gin-gonic/gin", - "github.com/koron/go-ssdp", - "github.com/mitchellh/mapstructure", - "github.com/prometheus/client_golang/prometheus", - "github.com/prometheus/common/version", - "github.com/sirupsen/logrus", - "github.com/zsais/go-gin-prometheus", - "gopkg.in/alecthomas/kingpin.v2", - ] - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml deleted file mode 100644 index 31ebde3..0000000 --- a/Gopkg.toml +++ /dev/null @@ -1,62 +0,0 @@ -# Gopkg.toml example -# -# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html -# for detailed Gopkg.toml documentation. -# -# required = ["github.com/user/thing/cmd/thing"] -# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] -# -# [[constraint]] -# name = "github.com/user/project" -# version = "1.0.0" -# -# [[constraint]] -# name = "github.com/user/project2" -# branch = "dev" -# source = "github.com/myfork/project2" -# -# [[override]] -# name = "github.com/x/y" -# version = "2.4.0" -# -# [prune] -# non-go = false -# go-tests = true -# unused-packages = true - - -[[constraint]] - name = "github.com/gin-gonic/gin" - version = "1.2.0" - -[[constraint]] - name = "github.com/koron/go-ssdp" - branch = "master" - -[[constraint]] - branch = "master" - name = "github.com/mitchellh/mapstructure" - -[[constraint]] - name = "github.com/prometheus/client_golang" - version = "0.8.0" - -[[constraint]] - branch = "master" - name = "github.com/prometheus/common" - -[[constraint]] - name = "github.com/sirupsen/logrus" - version = "1.0.6" - -[[constraint]] - branch = "master" - name = "github.com/zsais/go-gin-prometheus" - -[[constraint]] - name = "gopkg.in/alecthomas/kingpin.v2" - version = "2.2.6" - -[prune] - go-tests = true - unused-packages = true diff --git a/Makefile b/Makefile index 437f729..38379ae 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,39 @@ -GO := GO15VENDOREXPERIMENT=1 go +GO := go +GOPATH ?= $(HOME)/go PROMU := $(GOPATH)/bin/promu -pkgs = $(shell $(GO) list ./... | grep -v /vendor/) +CILINT := $(GOPATH)/bin/golangci-lint PREFIX ?= $(shell pwd) BIN_DIR ?= $(shell pwd) DOCKER_IMAGE_NAME ?= telly -DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) - +DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) all: format build test style: @echo ">> checking code style" - @! gofmt -d $(shell find . -path ./vendor -prune -o -name '*.go' -print) | grep '^' + @$(GO) get -u github.com/golangci/golangci-lint/cmd/golangci-lint + @$(CILINT) run ./... test: @echo ">> running tests" - @$(GO) test -short $(pkgs) + @$(GO) test -short ./... format: @echo ">> formatting code" - @$(GO) fmt $(pkgs) + @$(GO) fmt ./... vet: @echo ">> vetting code" - @$(GO) vet $(pkgs) + @$(GO) vet ./... + +cross: promu + @echo ">> crossbuilding binaries" + @$(PROMU) crossbuild + +tarballs: promu + @echo ">> creating release tarballs" + @$(PROMU) crossbuild tarballs build: promu @echo ">> building binaries" @@ -32,16 +41,13 @@ build: promu tarball: promu @echo ">> building release tarball" - @$(PROMU) tarball --prefix $(PREFIX) $(BIN_DIR) + @$(PROMU) tarball $(BIN_DIR) -docker: +docker: cross @echo ">> building docker image" @docker build -t "$(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG)" . promu: - @GOOS=$(shell uname -s | tr A-Z a-z) \ - GOARCH=$(subst x86_64,amd64,$(patsubst i%86,386,$(shell uname -m))) \ - $(GO) get -u github.com/prometheus/promu - + @$(GO) get -u github.com/prometheus/promu -.PHONY: all style format build test vet tarball docker promu +.PHONY: all style format build test vet tarball docker promu \ No newline at end of file diff --git a/README.md b/README.md index 3bf1c0f..ef1e987 100644 --- a/README.md +++ b/README.md @@ -2,79 +2,32 @@ IPTV proxy for Plex Live written in Golang -# Setup +## This is an ![#f92307](https://placehold.it/15/f92307/000000?text=+) unsupported branch ![#f92307](https://placehold.it/15/f92307/000000?text=+). It is under active development and prereleases based on it [1.5.x] should not be used by anyone who is intolerant of breakage. -> **If you are looking for information about the new config-file based ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) PRERELEASE BETA ![#f03c15](https://placehold.it/15/f03c15/000000?text=+), go to the [dev branch](https://github.com/tellytv/telly/tree/dev)** +# Configuration -> **See end of setup section for an important note about channel filtering** - -1) Go to the releases page and download the correct version for your Operating System -2) Mark the file as executable for non-windows platforms `chmod a+x ` -3) Rename the file to "telly" if desired; note that from here this readme will refer to "telly"; the file you downloaded is probably called "telly-linux-amd64.dms" or something like that. -**If you do not rename the file, then substitute references here to "telly" with the name of the file you've downloaded.** -**Under Windows, don't forget the `.exe`; i.e. `telly.exe`.** -4) Have the .m3u file on hand from your IPTV provider of choice -**Any command arguments can also be supplied as environment variables, for example --iptv.playlist can also be provided as the TELLY_IPTV_PLAYLIST environment variable** -5) Run `telly` with the `--iptv.playlist` commandline argument pointing to your .m3u file. (This can be a local file or a URL) For example: `./telly --iptv.playlist=/home/github/myiptv.m3u` -6) If you would like multiple streams/tuners use the `--iptv.streams` commandline option. Default is 1. When setting or changing this option, `plexmediaserver` will need to be completely **restarted**. -7) If you would like `telly` to attempt to the filter the m3u a bit, add the `--filter.regex` commandline option. If you would like to use your own regex, run `telly` with `--filter.regex=""`, for example `--filter.regex=".*UK.*"` Regex behavior is by default a blacklist; telly will EXCLUDE channels that match your regex [and if unspecified the filter matches ALL channels]; to reverse this and INCLUDE channels that match your regex, add `--filter.regex-inclusive` to the command line. -8) If `telly` tells you `[telly] [info] listening on ...` - great! Your .m3u file was successfully parsed and `telly` is running. Check below for how to add it into Plex. -9) If `telly` fails to run, check the error. If it's self explanatory, great. If you don't understand, feel free to open an issue and we'll help you out. As of telly v0.4 `sed` commands are no longer needed. Woop! -10) For your IPTV provider m3u, try using option `type=m3u_plus` and `output=ts`. - -> **Regex handling changed in 1.0. `filter.regex` has become blacklist which defaults to blocking everything. If you are not using a regex to filter your M3U file, you will need to add at a minimum `--filter.regex-inclusive` to the command line. If you do not add this, telly will by default EXCLUDE everything in your M3U. The symptom here is typically telly seeming to start up just fine but reporting 0 channels.** - -# Adding it into Plex - -1) Once `telly` is running, you can add it to Plex. **Plex Live requires Plex Pass at the time of writing** -2) Navigate to `app.plex.tv` and make sure you're logged in. Go to Settings -> Server -> Live TV & DVR -3) Click 'Setup' or 'Add'. The Telly virtual DVR should show up automatically. If it doesn't, press the text to add it manually - input `THE_IP_WHERE_TELLY_IS:6077` (or whatever port you're using - you can change it using the `-listen` commandline argument, i.e. `-listen THE_IP_WHERE_TELLY_IS:12345`) -4) Plex will find your device (in some cases it continues to load but the continue button becomes orange, i.e. clickable. Click it) - select the country in the bottom left and ensure Plex has found the channels. Proceed. -5) Once you get to the channel listing, `telly` currently __doesn't__ have any idea of EPG data so it __starts the channel numbers at 10000 to avoid complications__ with selecting channels at this stage. EPG APIs will come in the future, but for now you'll have to manually match up what `telly` is telling Plex to the actual channel numbers. For UK folk, `Sky HD` is the best option I've found. -6) Once you've matched up all the channels, hit next and Plex will start downloading necessary EPG data. -7) Once that is done, you might need to restart Plex so the telly tuner is not marked as dead. -8) You're done! Enjoy using `telly`. :-) +This branch uses a web ui for configuration and stored its configuration in a database. This UI and database are under development and subject to change without notice. # Docker +## tellytv/telly:v1.5.0 +The standard docker image for this branch + ## `docker run` ``` docker run -d \ --name='telly' \ --net='bridge' \ - -e TZ="Europe/Amsterdam" \ - -e 'TELLY_IPTV_PLAYLIST'='/home/github/myiptv.m3u' \ - -e TELLY_IPTV_STREAMS=1 \ - -e TELLY_FILTER_REGEX='.*UK.*' \ - -p '6077:6077/tcp' \ - -v '/tmp/telly':'/tmp':'rw' \ - tellytv/telly --web.base-address=localhost:6077 + -e TZ="America/Chicago" \ + -v ${PWD}/appdata:/etc/telly \ + --restart unless-stopped \ + tellytv/telly:v1.5.0 --database.file=/etc/telly/telly.db ``` -## docker-compose -``` -telly: - image: tellytv/telly - ports: - - "6077:6077" - environment: - - TZ=Europe/Amsterdam - - TELLY_IPTV_PLAYLIST=/home/github/myiptv.m3u - - TELLY_FILTER_REGEX='.*UK.*' - - TELLY_WEB_LISTEN_ADDRESS=telly:6077 - - TELLY_IPTV_STREAMS=1 - - TELLY_DISCOVERY_FRIENDLYNAME=Tuner1 - - TELLY_DISCOVERY_DEVICEID=12345678 - command: -base=telly:6077 - restart: unless-stopped -``` - - # Troubleshooting -Please free to open an issue if you run into any issues at all, I'll be more than happy to help. +Please free to [open an issue](https://github.com/tellytv/telly/issues) if you run into any problems at all, we'll be more than happy to help. # Social We have [a Discord server you can join!](https://discord.gg/bnNC8qX) - diff --git a/VERSION b/VERSION index 21e8796..26ca594 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.3 +1.5.1 diff --git a/frontend b/frontend new file mode 160000 index 0000000..053f7ef --- /dev/null +++ b/frontend @@ -0,0 +1 @@ +Subproject commit 053f7ef37e81e8a20a13c63be1353f42b4a0a476 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7f76ddc --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module github.com/tellytv/telly + +go 1.12 + +require ( + github.com/Masterminds/squirrel v1.1.0 + github.com/NebulousLabs/go-upnp v0.0.0-20181203152547-b32978b8ccbf + github.com/gin-contrib/cors v1.3.0 + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.4.0 + github.com/go-logfmt/logfmt v0.4.0 // indirect + github.com/go-sql-driver/mysql v1.4.1 // indirect + github.com/gobuffalo/packd v0.1.0 // indirect + github.com/gobuffalo/packr v1.25.0 + github.com/gofrs/uuid v3.2.0+incompatible + github.com/jmoiron/sqlx v1.2.0 + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b + github.com/kr/pretty v0.1.0 + github.com/lib/pq v1.1.1 // indirect + github.com/magiconair/properties v1.8.1 // indirect + github.com/mattn/go-isatty v0.0.8 // indirect + github.com/mattn/go-sqlite3 v1.10.0 + github.com/mitchellh/mapstructure v1.1.2 + github.com/onsi/ginkgo v1.8.0 // indirect + github.com/onsi/gomega v1.5.0 // indirect + github.com/pelletier/go-toml v1.4.0 // indirect + github.com/prometheus/client_golang v0.9.4 + github.com/prometheus/common v0.4.1 + github.com/robfig/cron v1.1.0 + github.com/rubenv/sql-migrate v0.0.0-20190327083759-54bad0a9b051 + github.com/schollz/closestmatch v2.1.0+incompatible + github.com/sirupsen/logrus v1.4.2 + github.com/spf13/afero v1.2.2 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.3 + github.com/spf13/viper v1.4.0 + github.com/tellytv/go.schedulesdirect v0.0.0-20180903021109-bb2d9eec79e9 + github.com/tellytv/go.xtream-codes v0.0.0-20190427212115-45e8162ba888 + github.com/ugorji/go v1.1.5-pre // indirect + github.com/ziutek/mymysql v1.5.4 // indirect + github.com/zsais/go-gin-prometheus v0.0.0-20181030200533-58963fb32f54 + gitlab.com/NebulousLabs/go-upnp v0.0.0-20181011194642-3a71999ed0d3 // indirect + golang.org/x/net v0.0.0-20190607175257-26fcbda1b1be + golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444 // indirect + google.golang.org/appengine v1.6.1 // indirect + gopkg.in/gorp.v1 v1.7.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a2dd9a0 --- /dev/null +++ b/go.sum @@ -0,0 +1,325 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/squirrel v1.1.0 h1:baP1qLdoQCeTw3ifCdOq2dkYc6vGcmRdaociKLbEJXs= +github.com/Masterminds/squirrel v1.1.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA= +github.com/NebulousLabs/go-upnp v0.0.0-20181203152547-b32978b8ccbf h1:1UP+tqdgLAKwt6NpefYq/SdyFaelU8MXOThESt6Od1U= +github.com/NebulousLabs/go-upnp v0.0.0-20181203152547-b32978b8ccbf/go.mod h1:GbuBk21JqF+driLX3XtJYNZjGa45YDoa9IqCTzNSfEc= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/cors v1.3.0 h1:PolezCc89peu+NgkIWt9OB01Kbzt6IP0J/JvkG6xxlg= +github.com/gin-contrib/cors v1.3.0/go.mod h1:artPvLlhkF7oG06nK8v3U8TNz6IeX+w1uzCSEId5/Vc= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.4.0 h1:3tMoCCfM7ppqsR0ptz/wi1impNpT7/9wQtMZ8lr1mCQ= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= +github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= +github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/envy v1.7.0 h1:GlXgaiBkmrYMHco6t4j7SacKO4XUjvh5pwXh0f4uxXU= +github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= +github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= +github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= +github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2 h1:8thhT+kUJMTMy3HlX4+y9Da+BNJck+p109tqqKp7WDs= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= +github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/mapi v1.0.2 h1:fq9WcL1BYrm36SzK6+aAnZ8hcp+SrmnDyAxhNx8dvJk= +github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packd v0.1.0 h1:4sGKOD8yaYJ+dek1FDkwcxCHA40M4kfKgFHx8N2kwbU= +github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packr v1.25.0 h1:NtPK45yOKFdTKHTvRGKL+UIKAKmJVWIVJOZBDI/qEdY= +github.com/gobuffalo/packr v1.25.0/go.mod h1:NqsGg8CSB2ZD+6RBIRs18G7aZqdYDlYNNvsSqP6T4/U= +github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= +github.com/gobuffalo/packr/v2 v2.1.0/go.mod h1:n90ZuXIc2KN2vFAOQascnPItp9A2g9QYSvYvS3AjQEM= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754 h1:tpom+2CJmpzAWj5/VEHync2rJGi+epHNIeRSWjzGA+4= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b h1:wxtKgYHEncAU00muMD06dzLiahtGM1eouRNOzVV7tdQ= +github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b/go.mod h1:5Ky9EC2xfoUKUor0Hjgi2BJhCSXJfMOFlmyYrVKGQMk= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2 h1:JgVTCPf0uBVcUSWpyXmGpgOc62nK5HWUBKAGc3Qqa5k= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= +github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg= +github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v0.9.4 h1:Y8E/JaaPbmFSW2V81Ab/d8yZFYQQGbni1b1jPcG9Y6A= +github.com/prometheus/client_golang v0.9.4/go.mod h1:oCXIBxdI62A4cR6aTRJCgetEjecSIYzOEaeAn4iYEpM= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1 h1:K0MGApIoQvMw27RTdJkPbr3JZ7DNbtxQNyi5STVM6Kw= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNGfs= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/robfig/cron v1.1.0 h1:jk4/Hud3TTdcrJgUOBgsqrZBarcxl6ADIjSC2iniwLY= +github.com/robfig/cron v1.1.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rubenv/sql-migrate v0.0.0-20190327083759-54bad0a9b051 h1:p32bQkgLiadYiOqs294BAx/7f1Aerfva8rj+rVvzR0A= +github.com/rubenv/sql-migrate v0.0.0-20190327083759-54bad0a9b051/go.mod h1:WS0rl9eEliYI8DPnr3TOwz4439pay+qNgzJoVya/DmY= +github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/tellytv/go.schedulesdirect v0.0.0-20180903021109-bb2d9eec79e9 h1:0CH/kIZdr6lUbW8R6+ZJS9GskQu2Mpg4zMfuUx6hz1c= +github.com/tellytv/go.schedulesdirect v0.0.0-20180903021109-bb2d9eec79e9/go.mod h1:pBZcxidsU285nwpDZ3NQIONgAyOo4wiUoOutTMu7KU4= +github.com/tellytv/go.xtream-codes v0.0.0-20190427212115-45e8162ba888 h1:AvoYr+NW3npUjbVjBMihfM699o+xlG6N5ftA+xEjurE= +github.com/tellytv/go.xtream-codes v0.0.0-20190427212115-45e8162ba888/go.mod h1:gWtQ2uZJ49dBh4cWiFuz7Tb5ALxLB9hY1GFoz34lsGs= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.5-pre h1:jyJKFOSEbdOc2HODrf2qcCkYOdq7zzXqA9bhW5oV4fM= +github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0= +github.com/ugorji/go/codec v1.1.5-pre h1:5YV9PsFAN+ndcCtTM7s60no7nY7eTG3LPtxhSwuxzCs= +github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +github.com/zsais/go-gin-prometheus v0.0.0-20181030200533-58963fb32f54 h1:pnZSRJZsHRBoamnhJn8/mXK+H6NnHoA2sD+7xw1vi3w= +github.com/zsais/go-gin-prometheus v0.0.0-20181030200533-58963fb32f54/go.mod h1:Slirjzuz8uM8Cw0jmPNqbneoqcUtY2GGjn2bEd4NRLY= +gitlab.com/NebulousLabs/go-upnp v0.0.0-20181011194642-3a71999ed0d3 h1:qXqiXDgeQxspR3reot1pWme00CX1pXbxesdzND+EjbU= +gitlab.com/NebulousLabs/go-upnp v0.0.0-20181011194642-3a71999ed0d3/go.mod h1:sleOmkovWsDEQVYXmOJhx69qheoMTmCuPYyiCFCihlg= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 h1:58fnuSXlxZmFdJyvtTFVmVhcMLU6v5fEb/ok4wyqtNU= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190607175257-26fcbda1b1be h1:Vb7KUggkvLRs0EQYvRuP3FunY+I4dwMGbYWD3Y4p+tg= +golang.org/x/net v0.0.0-20190607175257-26fcbda1b1be/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444 h1:/d2cWp6PSamH4jDPFLyO150psQdqvtoNX8Zjg3AQ31g= +golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190404132500-923d25813098/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw= +gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/api/guide_source.go b/internal/api/guide_source.go new file mode 100644 index 0000000..d4de2e1 --- /dev/null +++ b/internal/api/guide_source.go @@ -0,0 +1,304 @@ +package api + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/schollz/closestmatch" + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/guideproviders" + "github.com/tellytv/telly/internal/models" +) + +func addGuide(cc *context.CContext, c *gin.Context) { + var payload models.GuideSource + if c.BindJSON(&payload) == nil { + newGuide, providerErr := cc.API.GuideSource.InsertGuideSource(payload, nil) + if providerErr != nil { + log.WithError(providerErr).Errorln("error inserting guide source") + c.AbortWithError(http.StatusInternalServerError, providerErr) + return + } + + providerCfg := newGuide.ProviderConfiguration() + + provider, providerErr := providerCfg.GetProvider() + if providerErr != nil { + log.WithError(providerErr).Errorln("Error getting provider") + c.AbortWithError(http.StatusInternalServerError, providerErr) + return + } + + cc.GuideSourceProviders[newGuide.ID] = provider + + cc.Log.Infoln("Detected passed config is for provider", provider.Name()) + + lineupMetadata, reloadErr := provider.Refresh(nil) + if reloadErr != nil { + log.WithError(reloadErr).Errorln("Error refreshing provider") + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error while initializing guide data provider: %s", reloadErr)) + return + } + + if updateErr := cc.API.GuideSource.UpdateProviderData(newGuide.ID, lineupMetadata); updateErr != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error while updating guide source with provider state: %s", updateErr)) + return + } + + channels, channelsErr := provider.Channels() + if channelsErr != nil { + cc.Log.WithError(channelsErr).Errorln("unable to get channels from provider") + c.AbortWithError(http.StatusBadRequest, channelsErr) + return + } + + for _, channel := range channels { + newChannel, newChannelErr := cc.API.GuideSourceChannel.InsertGuideSourceChannel(newGuide.ID, channel, nil) + if newChannelErr != nil { + cc.Log.WithError(newChannelErr).Errorf("Error creating new guide source channel %s!", channel.ID) + c.AbortWithError(http.StatusInternalServerError, newChannelErr) + return + } + newGuide.Channels = append(newGuide.Channels, *newChannel) + } + + c.JSON(http.StatusOK, newGuide) + } +} + +func saveGuideSource(cc *context.CContext, c *gin.Context) { + guideSourceID := c.Param("sourceId") + + iGuideSourceID, err := strconv.ParseInt(guideSourceID, 0, 32) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + var payload models.GuideSource + if c.BindJSON(&payload) == nil { + provider, providerErr := cc.API.GuideSource.UpdateGuideSource(int(iGuideSourceID), payload) + if providerErr != nil { + c.AbortWithError(http.StatusInternalServerError, providerErr) + return + } + + c.JSON(http.StatusOK, provider) + } +} + +func deleteGuideSource(cc *context.CContext, c *gin.Context) { + guideSourceID := c.Param("sourceId") + + iGuideSourceID, err := strconv.ParseInt(guideSourceID, 0, 32) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + err = cc.API.GuideSource.DeleteGuideSource(int(iGuideSourceID)) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} + +func getGuideSources(cc *context.CContext, c *gin.Context) { + sources, sourcesErr := cc.API.GuideSource.GetAllGuideSources(true) + if sourcesErr != nil { + c.AbortWithError(http.StatusInternalServerError, sourcesErr) + return + } + c.JSON(http.StatusOK, sources) +} + +func getAllChannels(cc *context.CContext, c *gin.Context) { + sources, sourcesErr := cc.API.GuideSource.GetAllGuideSources(true) + if sourcesErr != nil { + c.AbortWithError(http.StatusInternalServerError, sourcesErr) + return + } + + channels := make([]models.GuideSourceChannel, 0) + + for _, source := range sources { + for _, channel := range source.Channels { + channel.GuideSourceName = source.Name + channels = append(channels, channel) + } + } + + c.JSON(http.StatusOK, channels) +} + +func getAllProgrammes(cc *context.CContext, c *gin.Context) { + programmes, programmesErr := cc.API.GuideSourceProgramme.GetProgrammesForGuideID(2) + if programmesErr != nil { + c.AbortWithError(http.StatusInternalServerError, programmesErr) + return + } + c.JSON(http.StatusOK, programmes) +} + +func getLineupCoverage(guideSource *models.GuideSource, provider guideproviders.GuideProvider, cc *context.CContext, c *gin.Context) { + coverage, coverageErr := provider.LineupCoverage() + if coverageErr != nil { + c.AbortWithError(http.StatusInternalServerError, coverageErr) + return + } + c.JSON(http.StatusOK, coverage) +} + +func getAvailableLineups(guideSource *models.GuideSource, provider guideproviders.GuideProvider, cc *context.CContext, c *gin.Context) { + countryCode := c.Query("countryCode") + postalCode := c.Query("postalCode") + lineups, lineupsErr := provider.AvailableLineups(countryCode, postalCode) + if lineupsErr != nil { + c.AbortWithError(http.StatusInternalServerError, lineupsErr) + return + } + c.JSON(http.StatusOK, lineups) +} + +func previewLineupChannels(guideSource *models.GuideSource, provider guideproviders.GuideProvider, cc *context.CContext, c *gin.Context) { + lineupID := c.Param("lineupId") + channels, channelsErr := provider.PreviewLineupChannels(lineupID) + if channelsErr != nil { + c.AbortWithError(http.StatusInternalServerError, channelsErr) + return + } + c.JSON(http.StatusOK, channels) +} + +func subscribeToLineup(guideSource *models.GuideSource, provider guideproviders.GuideProvider, cc *context.CContext, c *gin.Context) { + lineupID := c.Param("lineupId") + newLineup, subscribeErr := provider.SubscribeToLineup(lineupID) + if subscribeErr != nil { + c.AbortWithError(http.StatusInternalServerError, subscribeErr) + return + } + + lineupMetadata, reloadErr := provider.Refresh(nil) + if reloadErr != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error while initializing guide data provider: %s", reloadErr)) + return + } + + if updateErr := cc.API.GuideSource.UpdateProviderData(guideSource.ID, lineupMetadata); updateErr != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error while updating guide source with provider state: %s", updateErr)) + return + } + + channels, channelsErr := provider.Channels() + if channelsErr != nil { + cc.Log.WithError(channelsErr).Errorln("unable to get channels from provider") + c.AbortWithError(http.StatusBadRequest, channelsErr) + return + } + + for _, channel := range channels { + // Only add new channels, not existing ones. + if channel.Lineup == lineupID { + _, newChannelErr := cc.API.GuideSourceChannel.InsertGuideSourceChannel(guideSource.ID, channel, nil) + if newChannelErr != nil { + cc.Log.WithError(newChannelErr).Errorf("Error creating new guide source channel %s!", channel.ID) + c.AbortWithError(http.StatusInternalServerError, newChannelErr) + return + } + } + } + + c.JSON(http.StatusOK, newLineup) +} + +func unsubscribeFromLineup(guideSource *models.GuideSource, provider guideproviders.GuideProvider, cc *context.CContext, c *gin.Context) { + lineupID := c.Param("lineupId") + if unsubscribeErr := provider.UnsubscribeFromLineup(lineupID); unsubscribeErr != nil { + c.AbortWithError(http.StatusInternalServerError, unsubscribeErr) + return + } + // FIXME: Remove channels from database that were in removed lineup(s). + c.JSON(http.StatusOK, gin.H{"status": "okay"}) +} + +// func guideSourceRoute(cc *context.CContext, originalFunc func(*models.GuideSource, *context.CContext, *gin.Context)) gin.HandlerFunc { +// return wrapContext(cc, func(cc *context.CContext, c *gin.Context) { +// guideSourceID, guideSourceIDErr := strconv.Atoi(c.Param("guideSourceId")) +// if guideSourceIDErr != nil { +// c.AbortWithError(http.StatusBadRequest, guideSourceIDErr) +// return +// } +// guideSource, guideSourceErr := cc.API.GuideSource.GetGuideSourceByID(guideSourceID) +// if guideSourceErr != nil { +// c.AbortWithError(http.StatusInternalServerError, guideSourceErr) +// return +// } +// originalFunc(guideSource, cc, c) +// }) +// } + +func guideSourceLineupRoute(cc *context.CContext, originalFunc func(*models.GuideSource, guideproviders.GuideProvider, *context.CContext, *gin.Context)) gin.HandlerFunc { + return wrapContext(cc, func(cc *context.CContext, c *gin.Context) { + guideSourceID, guideSourceIDErr := strconv.Atoi(c.Param("guideSourceId")) + if guideSourceIDErr != nil { + c.AbortWithError(http.StatusBadRequest, guideSourceIDErr) + return + } + + guideSource, guideSourceErr := cc.API.GuideSource.GetGuideSourceByID(guideSourceID) + if guideSourceErr != nil { + c.AbortWithError(http.StatusInternalServerError, guideSourceErr) + return + } + + provider, ok := cc.GuideSourceProviders[guideSourceID] + if !ok { + c.AbortWithError(http.StatusNotFound, fmt.Errorf("%d is not a valid guide source provider", guideSourceID)) + return + } + + if !provider.SupportsLineups() { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("Provider %d does not support lineups", guideSourceID)) + return + } + + originalFunc(guideSource, provider, cc, c) + }) +} + +func match(guideSource *models.GuideSource, provider guideproviders.GuideProvider, cc *context.CContext, c *gin.Context) { + inputChannelName := c.Query("channelName") // this is a string, ensure it's not empty + + if inputChannelName != "" { + c.JSON(http.StatusOK, gin.H{"status": "empty input"}) + } + channels := make([]string, len(guideSource.Channels)) + channelMap := make(map[string]models.GuideSourceChannel) + + for _, channel := range guideSource.Channels { + name := channel.GuideProviderChannel.Name + channels = append(channels, name) + channelMap[name] = channel + } + + bagSizes := []int{3} + + // Create a closestmatch object + cm := closestmatch.New(channels, bagSizes) + + results := cm.ClosestN(inputChannelName, 3) + + var filteredChannels []models.GuideSourceChannel + + for _, result := range results { + filteredChannels = append(filteredChannels, channelMap[result]) + } + + // get matching channels back and form into json for response + + c.JSON(http.StatusOK, filteredChannels) +} diff --git a/internal/api/lineup.go b/internal/api/lineup.go new file mode 100644 index 0000000..bc68742 --- /dev/null +++ b/internal/api/lineup.go @@ -0,0 +1,53 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/models" +) + +func addLineup(cc *context.CContext, c *gin.Context) { + var payload models.Lineup + if c.BindJSON(&payload) == nil { + newLineup, lineupErr := cc.API.Lineup.InsertLineup(payload) + if lineupErr != nil { + c.AbortWithError(http.StatusInternalServerError, lineupErr) + return + } + + tunerChan := make(chan bool) + cc.Tuners[newLineup.ID] = tunerChan + go ServeLineup(cc, tunerChan, newLineup) + + c.JSON(http.StatusOK, newLineup) + } +} + +func getLineups(cc *context.CContext, c *gin.Context) { + allLineups, lineupErr := cc.API.Lineup.GetEnabledLineups(true) + if lineupErr != nil { + c.AbortWithError(http.StatusInternalServerError, lineupErr) + return + } + + c.JSON(http.StatusOK, allLineups) +} + +func lineupRoute(cc *context.CContext, originalFunc func(*models.Lineup, *context.CContext, *gin.Context)) gin.HandlerFunc { + return wrapContext(cc, func(cc *context.CContext, c *gin.Context) { + lineupID, lineupIDErr := strconv.Atoi(c.Param("lineupId")) + if lineupIDErr != nil { + c.AbortWithError(http.StatusBadRequest, lineupIDErr) + return + } + lineup, lineupErr := cc.API.Lineup.GetLineupByID(lineupID, true) + if lineupErr != nil { + c.AbortWithError(http.StatusInternalServerError, lineupErr) + return + } + originalFunc(lineup, cc, c) + }) +} diff --git a/internal/api/lineup_channel.go b/internal/api/lineup_channel.go new file mode 100644 index 0000000..27809f1 --- /dev/null +++ b/internal/api/lineup_channel.go @@ -0,0 +1,110 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/tellytv/telly/internal/commands" + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/models" + "github.com/tellytv/telly/internal/utils" +) + +func getLineup(lineup *models.Lineup, cc *context.CContext, c *gin.Context) { + c.JSON(http.StatusOK, lineup) +} + +func addLineupChannel(lineup *models.Lineup, cc *context.CContext, c *gin.Context) { + var payload models.LineupChannel + if c.BindJSON(&payload) == nil { + payload.LineupID = lineup.ID + newChannel, lineupErr := cc.API.LineupChannel.InsertLineupChannel(payload) + if lineupErr != nil { + c.AbortWithError(http.StatusInternalServerError, lineupErr) + return + } + + RestartTuner(cc, lineup) + + c.JSON(http.StatusOK, newChannel) + } +} + +func updateLineupChannels(lineup *models.Lineup, cc *context.CContext, c *gin.Context) { + providedChannels := make([]models.LineupChannel, 0) + guideSources := make(map[int]*models.GuideSource) + existingChannelIDs := make([]string, 0) + passedChannelIDs := make([]string, 0) + + for _, channel := range lineup.Channels { + existingChannelIDs = append(existingChannelIDs, strconv.Itoa(channel.ID)) + } + + if c.BindJSON(&providedChannels) == nil { + for _, channel := range providedChannels { + if channel.ID > 0 { + passedChannelIDs = append(passedChannelIDs, strconv.Itoa(channel.ID)) + } + } + + deletedChannelIDs := utils.Difference(existingChannelIDs, passedChannelIDs) + + for idx, channel := range providedChannels { + if utils.Contains(deletedChannelIDs, strconv.Itoa(channel.ID)) { + // Channel is about to be deleted, no reason to upsert it. + continue + } + channel.LineupID = lineup.ID + channel.GuideChannel = nil + channel.HDHR = nil + channel.VideoTrack = nil + newChannel, lineupErr := cc.API.LineupChannel.UpsertLineupChannel(channel) + if lineupErr != nil { + c.AbortWithError(http.StatusInternalServerError, lineupErr) + return + } + newChannel.Fill(cc.API) + guideSources[newChannel.GuideChannel.GuideSource.ID] = newChannel.GuideChannel.GuideSource + providedChannels[idx] = *newChannel + } + + for _, deletedID := range deletedChannelIDs { + if deleteProgrammesErr := cc.API.GuideSourceProgramme.DeleteGuideSourceProgrammesForChannel(deletedID); deleteProgrammesErr != nil { + c.AbortWithError(http.StatusInternalServerError, deleteProgrammesErr) + return + } + if deleteErr := cc.API.LineupChannel.DeleteLineupChannel(deletedID); deleteErr != nil { + c.AbortWithError(http.StatusInternalServerError, deleteErr) + return + } + } + + lineup.Channels = providedChannels + + // Update guide data for every provider with a new channel in the background + for _, source := range guideSources { + go commands.StartFireGuideUpdates(cc, source) + } + + // Finally, restart the tuner + RestartTuner(cc, lineup) + + c.JSON(http.StatusOK, lineup) + } +} + +func refreshLineup(lineup *models.Lineup, cc *context.CContext, c *gin.Context) { + guideSources := make(map[int]*models.GuideSource) + + for _, channel := range lineup.Channels { + guideSources[channel.GuideChannel.GuideSource.ID] = channel.GuideChannel.GuideSource + } + + // Update guide data for every provider with a new channel in the background + for _, source := range guideSources { + go commands.StartFireGuideUpdates(cc, source) + } + + c.JSON(http.StatusOK, gin.H{"status": "okay", "message": "Beginning refresh of lineup data"}) +} diff --git a/internal/api/main.go b/internal/api/main.go new file mode 100644 index 0000000..7032e8e --- /dev/null +++ b/internal/api/main.go @@ -0,0 +1,72 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gobuffalo/packr" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/tellytv/telly/internal/context" +) + +// ServeAPI starts up the telly frontend + REST API. +func ServeAPI(cc *context.CContext) { + cc.Log.Debugln("creating webserver routes") + + if viper.GetString("log.level") != logrus.DebugLevel.String() { + gin.SetMode(gin.ReleaseMode) + } + + router := newGin(cc) + + box := packr.NewBox("../../frontend/dist/telly-fe") + + router.Use(ServeBox("/", box)) + + router.GET("/epg.xml", wrapContext(cc, xmlTV)) + + apiGroup := router.Group("/api") + + apiGroup.GET("/guide/scan", scanXMLTV) + + apiGroup.GET("/lineups", wrapContext(cc, getLineups)) + apiGroup.POST("/lineups", wrapContext(cc, addLineup)) + apiGroup.GET("/lineups/:lineupId", lineupRoute(cc, getLineup)) + apiGroup.PUT("/lineups/:lineupId/channels", lineupRoute(cc, updateLineupChannels)) + apiGroup.POST("/lineups/:lineupId/channels", lineupRoute(cc, addLineupChannel)) + apiGroup.PUT("/lineups/:lineupId/refresh", lineupRoute(cc, refreshLineup)) + apiGroup.GET("/lineup/scan", scanM3U) + + apiGroup.GET("/guide_sources", wrapContext(cc, getGuideSources)) + apiGroup.POST("/guide_sources", wrapContext(cc, addGuide)) + apiGroup.PUT("/guide_sources/:sourceId", wrapContext(cc, saveGuideSource)) + apiGroup.DELETE("/guide_sources/:sourceId", wrapContext(cc, deleteGuideSource)) + apiGroup.GET("/guide_sources/channels", wrapContext(cc, getAllChannels)) + apiGroup.GET("/guide_sources/programmes", wrapContext(cc, getAllProgrammes)) + + apiGroup.GET("/guide_source/:guideSourceId/coverage", guideSourceLineupRoute(cc, getLineupCoverage)) + apiGroup.GET("/guide_source/:guideSourceId/match", guideSourceLineupRoute(cc, match)) + apiGroup.GET("/guide_source/:guideSourceId/lineups", guideSourceLineupRoute(cc, getAvailableLineups)) + apiGroup.PUT("/guide_source/:guideSourceId/lineups/:lineupId", guideSourceLineupRoute(cc, subscribeToLineup)) + apiGroup.DELETE("/guide_source/:guideSourceId/lineups/:lineupId", guideSourceLineupRoute(cc, unsubscribeFromLineup)) + apiGroup.GET("/guide_source/:guideSourceId/lineups/:lineupId/channels", guideSourceLineupRoute(cc, previewLineupChannels)) + + apiGroup.GET("/video_sources", wrapContext(cc, getVideoSources)) + apiGroup.POST("/video_sources", wrapContext(cc, addVideoSource)) + apiGroup.PUT("/video_sources/:sourceId", wrapContext(cc, saveVideoSource)) + apiGroup.DELETE("/video_sources/:sourceId", wrapContext(cc, deleteVideoSource)) + apiGroup.GET("/video_sources/tracks", wrapContext(cc, getAllTracks)) + + apiGroup.GET("/streams", func(c *gin.Context) { + c.JSON(http.StatusOK, cc.Streams) + }) + + cc.Log.Infof("telly is live and on the air!") + cc.Log.Infof("Broadcasting from http://%s/", viper.GetString("web.listen-address")) + cc.Log.Infof("EPG URL: http://%s/epg.xml", viper.GetString("web.listen-address")) + + if err := router.Run(viper.GetString("web.listen-address")); err != nil { + cc.Log.WithError(err).Panicln("Error starting up web server") + } +} diff --git a/internal/api/tuner.go b/internal/api/tuner.go new file mode 100644 index 0000000..4be3166 --- /dev/null +++ b/internal/api/tuner.go @@ -0,0 +1,259 @@ +package api + +import ( + "context" + "database/sql" + "encoding/xml" + "fmt" + "net/http" + "strings" + "time" + + upnp "github.com/NebulousLabs/go-upnp/goupnp" + "github.com/gin-gonic/gin" + "github.com/gofrs/uuid" + "github.com/koron/go-ssdp" + "github.com/sirupsen/logrus" + ccontext "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/metrics" + "github.com/tellytv/telly/internal/models" + "github.com/tellytv/telly/internal/streamsuite" +) + +var log = &logrus.Logger{} + +// ServeLineup starts up a server dedicated to a single Lineup. +func ServeLineup(cc *ccontext.CContext, exit chan bool, lineup *models.Lineup) { + log = cc.Log + channels, channelsErr := cc.API.LineupChannel.GetChannelsForLineup(lineup.ID, true) + if channelsErr != nil { + log.WithError(channelsErr).Errorln("error getting channels in lineup") + return + } + + hdhrItems := make([]models.HDHomeRunLineupItem, 0) + for _, channel := range channels { + hdhrItems = append(hdhrItems, *channel.HDHR) + metrics.ExposedChannels.WithLabelValues(lineup.Name, channel.VideoTrack.VideoSource.Name, channel.VideoTrack.VideoSource.Provider).Inc() + } + + discoveryData := lineup.GetDiscoveryData() + + log.Debugln("creating device xml") + upnp := discoveryData.UPNP() + + router := newGin(cc) + + router.GET("/", deviceXML(upnp)) + router.GET("/device.xml", deviceXML(upnp)) + router.GET("/discover.json", discovery(discoveryData)) + router.GET("/lineup_status.json", lineupStatus(lineup)) + router.POST("/lineup.post", scanChannels(lineup)) + router.GET("/lineup.json", serveHDHRLineup(hdhrItems)) + router.GET("/lineup.xml", serveHDHRLineup(hdhrItems)) + router.GET("/auto/:channelNumber", stream(cc, lineup)) + + baseAddr := fmt.Sprintf("%s:%d", lineup.ListenAddress, lineup.Port) + + if lineup.SSDP { + if ssdpErr := setupSSDP(baseAddr, lineup.Name, lineup.DeviceUUID, exit); ssdpErr != nil { + log.WithError(ssdpErr).Errorln("telly cannot advertise over ssdp") + } + } + + log.Infof(`telly lineup "%s" is live at http://%s/`, lineup.Name, baseAddr) + + srv := &http.Server{ + Addr: baseAddr, + Handler: router, + } + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.WithError(err).Panicln("Error starting up web server") + } + }() + + // nolint + for { + select { + case <-exit: + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.WithError(err).Fatalln("error during tuner shutdown") + } + log.Warnln("Tuner restart commanded") + return + } + } +} + +func setupSSDP(baseAddress, deviceName, deviceUUID string, exit chan bool) error { + log.Debugf("Advertising telly as %s (%s) on %s", deviceName, deviceUUID, baseAddress) + + adv, err := ssdp.Advertise( + ssdp.RootDevice, + fmt.Sprintf("uuid:%s::upnp:rootdevice", deviceUUID), + fmt.Sprintf("http://%s/device.xml", baseAddress), + `telly/2.0 UPnP/1.0`, + 1800) + + if err != nil { + return err + } + + go func() { + aliveTick := time.NewTicker(300 * time.Second) + + loop: + for { + select { + case <-exit: + break loop + case <-aliveTick.C: + log.Debugln("Sending SSDP heartbeat") + if err := adv.Alive(); err != nil { + log.WithError(err).Panicln("error when sending ssdp heartbeat") + } + } + } + + if byeErr := adv.Bye(); byeErr != nil { + log.WithError(byeErr).Panicln("error when sending ssdp bye") + } + if closeErr := adv.Close(); closeErr != nil { + log.WithError(closeErr).Panicln("error when closing ssdp") + } + }() + + return nil +} + +type dXMLContainer struct { + upnp.RootDevice + XMLName xml.Name `xml:"urn:schemas-upnp-org:device-1-0 root"` +} + +func deviceXML(deviceXML upnp.RootDevice) gin.HandlerFunc { + return func(c *gin.Context) { + c.XML(http.StatusOK, dXMLContainer{deviceXML, xml.Name{}}) + } +} + +func discovery(data models.DiscoveryData) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, data) + } +} + +type hdhrLineupContainer struct { + XMLName xml.Name `xml:"Lineup" json:"-"` + Programs []models.HDHomeRunLineupItem `xml:"Program"` +} + +func serveHDHRLineup(hdhrItems []models.HDHomeRunLineupItem) gin.HandlerFunc { + return func(c *gin.Context) { + if strings.HasSuffix(c.Request.URL.String(), ".xml") { + buf, marshallErr := xml.MarshalIndent(hdhrLineupContainer{Programs: hdhrItems}, "", "\t") + if marshallErr != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error marshalling lineup to XML: %s", marshallErr)) + } + c.Data(http.StatusOK, "application/xml", []byte(``+"\n"+string(buf))) + return + } + c.JSON(http.StatusOK, hdhrItems) + } +} + +// NewStreamStatus creates a new stream status +func NewStreamStatus(cc *ccontext.CContext, lineup *models.Lineup, channelID string) (*streamsuite.Stream, string, error) { + statusUUID := uuid.Must(uuid.NewV4()).String() + ss := &streamsuite.Stream{ + UUID: statusUUID, + } + channel, channelErr := cc.API.LineupChannel.GetLineupChannelByID(lineup.ID, channelID) + if channelErr != nil { + if channelErr == sql.ErrNoRows { + return nil, statusUUID, fmt.Errorf("unknown channel number %s", channelID) + } + return nil, statusUUID, channelErr + } + + ss.Channel = channel + + streamURL, streamURLErr := cc.VideoSourceProviders[channel.VideoTrack.VideoSourceID].StreamURL(channel.VideoTrack.StreamID, "ts") + if streamURLErr != nil { + return nil, statusUUID, streamURLErr + } + + ss.StreamURL = streamURL + + if lineup.StreamTransport == "ffmpeg" { + ss.Transport = streamsuite.FFMPEG{} + } else { + ss.Transport = &streamsuite.HTTP{} + } + + ss.PromLabels = []string{lineup.Name, channel.VideoTrack.VideoSource.Name, channel.VideoTrack.VideoSource.Provider, channel.Title, ss.Transport.Type()} + + return ss, statusUUID, nil +} + +func stream(cc *ccontext.CContext, lineup *models.Lineup) gin.HandlerFunc { + return func(c *gin.Context) { + stream, streamUUID, streamErr := NewStreamStatus(cc, lineup, c.Param("channelNumber")[1:]) + if streamErr != nil { + log.WithError(streamErr).Errorf("Error when starting streaming") + c.AbortWithError(http.StatusInternalServerError, streamErr) + return + } + + cc.Streams[streamUUID] = stream + + log.Infof("Serving via %s: %s", stream.Transport.Type(), stream.Channel) + + stream.Start(c) + + } +} + +func scanChannels(lineup *models.Lineup) gin.HandlerFunc { + return func(c *gin.Context) { + scanAction := c.Query("scan") + if scanAction == "start" { + // FIXME: Actually implement a scan... + // if refreshErr := lineup.Scan(); refreshErr != nil { + // c.AbortWithError(http.StatusInternalServerError, refreshErr) + // } + c.AbortWithStatus(http.StatusOK) + return + } else if scanAction == "abort" { + c.AbortWithStatus(http.StatusOK) + return + } + c.String(http.StatusBadRequest, "%s is not a valid scan command", scanAction) + } +} + +func lineupStatus(lineup *models.Lineup) gin.HandlerFunc { + return func(c *gin.Context) { + payload := LineupStatus{ + ScanInProgress: models.ConvertibleBoolean(false), + ScanPossible: models.ConvertibleBoolean(true), + Source: "Cable", + SourceList: []string{"Cable"}, + } + // FIXME: Implement a scan param on Lineup. + if false { + payload = LineupStatus{ + ScanInProgress: models.ConvertibleBoolean(true), + // Gotta fake out Plex. + Progress: 50, + Found: 50, + } + } + + c.JSON(http.StatusOK, payload) + } +} diff --git a/internal/api/utils.go b/internal/api/utils.go new file mode 100644 index 0000000..022ec6e --- /dev/null +++ b/internal/api/utils.go @@ -0,0 +1,128 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "github.com/gobuffalo/packr" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/models" + "github.com/tellytv/telly/internal/utils" + ginprometheus "github.com/zsais/go-gin-prometheus" +) + +func scanM3U(c *gin.Context) { + rawPlaylist, m3uErr := utils.GetM3U(c.Query("m3u_url")) + if m3uErr != nil { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("unable to get m3u file: %s", m3uErr)) + return + } + + c.JSON(http.StatusOK, rawPlaylist) +} + +func scanXMLTV(c *gin.Context) { + epg, epgErr := utils.GetXMLTV(c.Query("epg_url")) + if epgErr != nil { + c.AbortWithError(http.StatusInternalServerError, epgErr) + return + } + + epg.Programmes = nil + + c.JSON(http.StatusOK, epg) +} + +// LineupStatus exposes the status of the channel lineup. +type LineupStatus struct { + ScanInProgress models.ConvertibleBoolean + ScanPossible models.ConvertibleBoolean `json:",omitempty"` + Source string `json:",omitempty"` + SourceList []string `json:",omitempty"` + Progress int `json:",omitempty"` // Percent complete + Found int `json:",omitempty"` // Number of found channels +} + +func ginrus(cc *context.CContext) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + // some evil middlewares modify this values + path := c.Request.URL.Path + c.Next() + + end := time.Now() + latency := end.Sub(start) + end = end.UTC() + + logFields := logrus.Fields{ + "status": c.Writer.Status(), + "method": c.Request.Method, + "path": path, + "ipAddress": c.ClientIP(), + "latency": latency, + "userAgent": c.Request.UserAgent(), + "time": end.Format(time.RFC3339), + } + + if len(c.Errors) > 0 { + // Append error field if this is an erroneous request. + logFields["error"] = c.Errors.String() + cc.Log.WithFields(logFields).Errorln("Error while serving request") + } else if viper.GetBool("log.requests") { + cc.Log.WithFields(logFields).Infoln() + } + } +} + +func wrapContext(cc *context.CContext, originalFunc func(*context.CContext, *gin.Context)) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := cc.Copy() + originalFunc(ctx, c) + } +} + +// ServeBox returns a middleware handler that serves static files from a Packr box. +func ServeBox(urlPrefix string, box packr.Box) gin.HandlerFunc { + fileserver := http.FileServer(box) + if urlPrefix != "" { + fileserver = http.StripPrefix(urlPrefix, fileserver) + } + return func(c *gin.Context) { + if box.Has(c.Request.URL.Path) { + fileserver.ServeHTTP(c.Writer, c.Request) + c.Abort() + } + } +} + +var prom = ginprometheus.NewPrometheus("http") + +func newGin(cc *context.CContext) *gin.Engine { + router := gin.New() + router.Use(cors.Default()) + router.Use(gin.Recovery()) + router.Use(ginrus(cc)) + + prom.Use(router) + return router +} + +// StartTuner will start a new tuner server for the given lineup. +func StartTuner(cc *context.CContext, lineup *models.Lineup) { + tunerChan := make(chan bool) + cc.Tuners[lineup.ID] = tunerChan + go ServeLineup(cc, tunerChan, lineup) +} + +// RestartTuner will trigger a restart of the tuner server for the given lineup. +func RestartTuner(cc *context.CContext, lineup *models.Lineup) { + if tuner, ok := cc.Tuners[lineup.ID]; ok { + tuner <- true + } + StartTuner(cc, lineup) +} diff --git a/internal/api/video_source.go b/internal/api/video_source.go new file mode 100644 index 0000000..e6be4b5 --- /dev/null +++ b/internal/api/video_source.go @@ -0,0 +1,127 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/models" +) + +func getVideoSources(cc *context.CContext, c *gin.Context) { + sources, sourcesErr := cc.API.VideoSource.GetAllVideoSources(false) + if sourcesErr != nil { + cc.Log.WithError(sourcesErr).Errorln("error getting all video sources") + c.AbortWithError(http.StatusInternalServerError, sourcesErr) + return + } + c.JSON(http.StatusOK, sources) +} + +func addVideoSource(cc *context.CContext, c *gin.Context) { + var payload models.VideoSource + if c.BindJSON(&payload) == nil { + newProvider, providerErr := cc.API.VideoSource.InsertVideoSource(payload) + if providerErr != nil { + c.AbortWithError(http.StatusInternalServerError, providerErr) + return + } + + providerCfg := newProvider.ProviderConfiguration() + + provider, providerErr := providerCfg.GetProvider() + if providerErr != nil { + cc.Log.WithError(providerErr).Errorln("error getting provider") + c.AbortWithError(http.StatusInternalServerError, providerErr) + return + } + + cc.VideoSourceProviders[newProvider.ID] = provider + + cc.Log.Infoln("Detected passed config is for provider", provider.Name()) + + channels, channelsErr := provider.Channels() + if channelsErr != nil { + c.AbortWithError(http.StatusInternalServerError, channelsErr) + return + } + + for _, channel := range channels { + newTrack, newTrackErr := cc.API.VideoSourceTrack.InsertVideoSourceTrack(models.VideoSourceTrack{ + VideoSourceID: newProvider.ID, + Name: channel.Name, + StreamID: channel.StreamID, + Logo: channel.Logo, + Type: string(channel.Type), + Category: channel.Category, + EPGID: channel.EPGID, + }) + if newTrackErr != nil { + cc.Log.WithError(newTrackErr).Errorln("Error creating new video source track!") + c.AbortWithError(http.StatusInternalServerError, newTrackErr) + return + } + newProvider.Tracks = append(newProvider.Tracks, *newTrack) + } + c.JSON(http.StatusCreated, newProvider) + } +} + +func saveVideoSource(cc *context.CContext, c *gin.Context) { + videoSourceID := c.Param("sourceId") + + iVideoSourceID, err := strconv.ParseInt(videoSourceID, 0, 32) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + var payload models.VideoSource + if c.BindJSON(&payload) == nil { + provider, providerErr := cc.API.VideoSource.UpdateVideoSource(int(iVideoSourceID), payload) + if providerErr != nil { + c.AbortWithError(http.StatusInternalServerError, providerErr) + return + } + + c.JSON(http.StatusOK, provider) + } +} + +func deleteVideoSource(cc *context.CContext, c *gin.Context) { + videoSourceID := c.Param("sourceId") + + iVideoSourceID, err := strconv.ParseInt(videoSourceID, 0, 32) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + err = cc.API.VideoSource.DeleteVideoSource(int(iVideoSourceID)) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} + +func getAllTracks(cc *context.CContext, c *gin.Context) { + sources, sourcesErr := cc.API.VideoSource.GetAllVideoSources(true) + if sourcesErr != nil { + c.AbortWithError(http.StatusInternalServerError, sourcesErr) + return + } + + tracks := make([]models.VideoSourceTrack, 0) + + for _, source := range sources { + for _, track := range source.Tracks { + track.VideoSourceName = source.Name + tracks = append(tracks, track) + } + } + + c.JSON(http.StatusOK, tracks) +} diff --git a/internal/api/xmltv.go b/internal/api/xmltv.go new file mode 100644 index 0000000..8263529 --- /dev/null +++ b/internal/api/xmltv.go @@ -0,0 +1,74 @@ +package api + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/common/version" + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/guideproviders" + "github.com/tellytv/telly/internal/xmltv" +) + +func xmlTV(cc *context.CContext, c *gin.Context) { + epg := &xmltv.TV{ + Date: time.Now().Format("2006-01-02"), + GeneratorInfoName: fmt.Sprintf("telly/%s", version.Version), + GeneratorInfoURL: "https://github.com/tellytv/telly", + } + + lineups, lineupsErr := cc.API.Lineup.GetEnabledLineups(true) + if lineupsErr != nil { + c.AbortWithError(http.StatusInternalServerError, lineupsErr) + return + } + + programmes, programmesErr := cc.API.GuideSourceProgramme.GetProgrammesForActiveChannels() + if programmesErr != nil { + c.AbortWithError(http.StatusInternalServerError, programmesErr) + return + } + + epgMatchMap := make(map[string]int) + + for _, lineup := range lineups { + for _, channel := range lineup.Channels { + epgMatchMap[channel.GuideChannel.XMLTVID] = channel.ID + + var guideChannel guideproviders.Channel + + if jsonErr := json.Unmarshal(channel.GuideChannel.Data, &guideChannel); jsonErr != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error while unmarshalling lineupchannel to guideproviders.channel: %s", jsonErr)) + return + } + + xChannel := guideChannel.XMLTV() + + displayNames := []xmltv.CommonElement{{Value: channel.Title}} + displayNames = append(displayNames, xChannel.DisplayNames...) + + epg.Channels = append(epg.Channels, xmltv.Channel{ + ID: strconv.Itoa(channel.ID), + DisplayNames: displayNames, + Icons: xChannel.Icons, + LCN: channel.ChannelNumber, + }) + } + } + + for _, programme := range programmes { + programme.XMLTV.Channel = strconv.Itoa(epgMatchMap[programme.Channel]) + epg.Programmes = append(epg.Programmes, *programme.XMLTV) + } + + buf, marshallErr := xml.MarshalIndent(epg, "", "\t") + if marshallErr != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error marshalling EPG to XML")) + } + c.Data(http.StatusOK, "application/xml", []byte(xml.Header+``+"\n"+string(buf))) +} diff --git a/internal/commands/guide_updates.go b/internal/commands/guide_updates.go new file mode 100644 index 0000000..5bc9937 --- /dev/null +++ b/internal/commands/guide_updates.go @@ -0,0 +1,136 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/sirupsen/logrus" + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/guideproviders" + "github.com/tellytv/telly/internal/models" +) + +var ( + log = &logrus.Logger{ + Out: os.Stderr, + Formatter: &logrus.TextFormatter{ + FullTimestamp: true, + }, + Hooks: make(logrus.LevelHooks), + Level: logrus.DebugLevel, + } +) + +// FireGuideUpdatesCommand Command to fire one off guide source updates +func FireGuideUpdatesCommand() { + cc, err := context.NewCContext(log) + if err != nil { + log.Fatalln("Couldn't create context", err) + } + + provider, providerErr := cc.API.GuideSource.GetGuideSourceByID(1) + if providerErr != nil { + log.Fatalln("couldnt find guide source", providerErr) + } + + if err = fireGuideUpdates(cc, provider); err != nil { + log.Errorln("Could not complete guide updates " + err.Error()) + } +} + +func fireGuideUpdates(cc *context.CContext, provider *models.GuideSource) error { + + log.Infoln("Guide source update is beginning") + + lineupMetadata, reloadErr := cc.GuideSourceProviders[provider.ID].Refresh(provider.ProviderData) + if reloadErr != nil { + return fmt.Errorf("error when refreshing for provider %s (%s): %s", provider.Name, provider.Provider, reloadErr) + } + + if updateErr := cc.API.GuideSource.UpdateProviderData(provider.ID, lineupMetadata); updateErr != nil { + return fmt.Errorf("error when updating guide source provider metadata: %s", updateErr) + } + + // TODO: Inspect the input metadata and output metadata and update channels as needed. + + guideChannels, guideChannelsErr := cc.API.LineupChannel.GetEnabledChannelsForGuideProvider(provider.ID) + if guideChannelsErr != nil { + return fmt.Errorf("error getting guide sources for lineup: %s", guideChannelsErr) + } + + if len(guideChannels) == 0 { + return nil + } + + channelsToGet := make(map[string]guideproviders.Channel) + + for _, channel := range guideChannels { + var pChannel guideproviders.Channel + if marshalErr := json.Unmarshal(channel.GuideChannel.Data, &pChannel); marshalErr != nil { + return fmt.Errorf("error when marshalling channel.data to guideproviders.channel: %s", marshalErr) + } + pChannel.ProviderData = channel.GuideChannel.ProviderData + channelsToGet[channel.GuideChannel.XMLTVID] = pChannel + } + + channelIDs := make([]string, 0) + existingChannels := make([]guideproviders.Channel, 0) + for channelID, channel := range channelsToGet { + channelIDs = append(channelIDs, channelID) + existingChannels = append(existingChannels, channel) + } + + // Get all programmes in DB to pass into the Schedule function. + existingProgrammes, existingProgrammesErr := cc.API.GuideSourceProgramme.GetProgrammesForActiveChannels() + if existingProgrammesErr != nil { + return fmt.Errorf("error getting all programmes in database: %s", existingProgrammesErr) + } + + programmeContainers := make([]guideproviders.ProgrammeContainer, 0) + for _, programme := range existingProgrammes { + programmeContainers = append(programmeContainers, guideproviders.ProgrammeContainer{ + Programme: *programme.XMLTV, + ProviderData: programme.ProviderData, + }) + } + + log.Infof("Beginning import of guide data from provider %d, getting %d channels: %s", provider.ID, len(channelsToGet), strings.Join(channelIDs, ", ")) + channelProviderData, newProgrammes, scheduleErr := cc.GuideSourceProviders[provider.ID].Schedule(14, existingChannels, programmeContainers) + if scheduleErr != nil { + return fmt.Errorf("error when updating schedule for provider %d: %s", provider.ID, scheduleErr) + } + + for channelID, providerData := range channelProviderData { + marshalledPD, marshalErr := json.Marshal(providerData) + if marshalErr != nil { + return fmt.Errorf("error when marshalling schedules direct channel data to json: %s", marshalErr) + } + log.Infof("Updating Channel ID: %s to %s", channelID, string(marshalledPD)) + if updateErr := cc.API.GuideSourceChannel.UpdateGuideSourceChannel(channelID, marshalledPD); updateErr != nil { + return fmt.Errorf("error while updating provider specific data to guide source channel: %s", updateErr) + } + } + + for _, programme := range newProgrammes { + _, programmeErr := cc.API.GuideSourceProgramme.InsertGuideSourceProgramme(provider.ID, programme.Programme, programme.ProviderData) + if programmeErr != nil { + return fmt.Errorf("error while inserting new programmes: %s", programmeErr) + } + } + + log.Infof("Completed import of %d programs", len(newProgrammes)) + + return nil +} + +// StartFireGuideUpdates Scheduler triggered function to update guide sources +func StartFireGuideUpdates(cc *context.CContext, provider *models.GuideSource) { + err := fireGuideUpdates(cc, provider) + if err != nil { + log.Errorf("could not complete guide updates: %s", err) + } + + log.Infoln("Guide source has been updated successfully") +} diff --git a/internal/commands/video_updates.go b/internal/commands/video_updates.go new file mode 100644 index 0000000..b43e138 --- /dev/null +++ b/internal/commands/video_updates.go @@ -0,0 +1,55 @@ +package commands + +import ( + "fmt" + + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/models" +) + +// FireVideoUpdatesCommand Command to fire one off video source updates +func FireVideoUpdatesCommand() { + cc, err := context.NewCContext(nil) + if err != nil { + log.WithError(err).Errorf("couldn't create context") + } + if err = fireVideoUpdates(cc, nil); err != nil { + log.WithError(err).Errorf("could not complete video updates") + } +} + +func fireVideoUpdates(cc *context.CContext, provider *models.VideoSource) error { + log.Debugln("Video source update is beginning for provider", provider.Name) + + channels, channelsErr := cc.VideoSourceProviders[provider.ID].Channels() + if channelsErr != nil { + return fmt.Errorf("error while getting video channels during update of %s: %s", provider.Name, channelsErr) + } + + for _, channel := range channels { + newTrackErr := cc.API.VideoSourceTrack.UpdateVideoSourceTrack(provider.ID, channel.StreamID, models.VideoSourceTrack{ + VideoSourceID: provider.ID, + Name: channel.Name, + StreamID: channel.StreamID, + Logo: channel.Logo, + Type: string(channel.Type), + Category: channel.Category, + EPGID: channel.EPGID, + }) + if newTrackErr != nil { + return fmt.Errorf("error while inserting video track (source id: %d stream id: %d name: %s) during update: %s", provider.ID, channel.StreamID, channel.Name, newTrackErr) + } + } + + return nil +} + +// StartFireVideoUpdates Scheduler triggered function to update video sources +func StartFireVideoUpdates(cc *context.CContext, provider *models.VideoSource) { + err := fireVideoUpdates(cc, provider) + if err != nil { + log.WithError(err).Errorln("could not complete video updates for provider", provider.Name) + } + + log.Infof("Video source %s has been updated successfully", provider.Name) +} diff --git a/internal/context/context.go b/internal/context/context.go new file mode 100644 index 0000000..a700d56 --- /dev/null +++ b/internal/context/context.go @@ -0,0 +1,139 @@ +// Package context provides Telly specific context functions like SQLite access, along with initialized API clients and other packages such as models. +package context + +import ( + ctx "context" + "os" + + "github.com/gobuffalo/packr" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" // the SQLite driver + migrate "github.com/rubenv/sql-migrate" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/tellytv/telly/internal/guideproviders" + "github.com/tellytv/telly/internal/models" + "github.com/tellytv/telly/internal/streamsuite" + "github.com/tellytv/telly/internal/videoproviders" +) + +// CContext is a context struct that gets passed around the application. +type CContext struct { + API *models.APICollection + Ctx ctx.Context + GuideSourceProviders map[int]guideproviders.GuideProvider + Log *logrus.Logger + Streams map[string]*streamsuite.Stream + Tuners map[int]chan bool + VideoSourceProviders map[int]videoproviders.VideoProvider + + RawSQL *sqlx.DB +} + +// Copy returns a cloned version of the input CContext minus the User and Device fields. +func (cc *CContext) Copy() *CContext { + return &CContext{ + API: cc.API, + Ctx: cc.Ctx, + GuideSourceProviders: cc.GuideSourceProviders, + Log: cc.Log, + RawSQL: cc.RawSQL, + Streams: cc.Streams, + Tuners: cc.Tuners, + VideoSourceProviders: cc.VideoSourceProviders, + } +} + +// NewCContext returns an initialized CContext struct +func NewCContext(log *logrus.Logger) (*CContext, error) { + + if log == nil { + log = &logrus.Logger{ + Out: os.Stderr, + Formatter: &logrus.TextFormatter{ + FullTimestamp: true, + }, + Hooks: make(logrus.LevelHooks), + Level: logrus.DebugLevel, + } + } + + theCtx := ctx.Background() + + sql, dbErr := sqlx.Open("sqlite3", viper.GetString("database.file")) + if dbErr != nil { + log.WithError(dbErr).Panicln("Unable to open database") + } + + if _, execErr := sql.Exec(`PRAGMA foreign_keys = ON;`); execErr != nil { + log.WithError(execErr).Panicln("error enabling foreign keys") + } + + log.Debugln("Checking migrations status and running any required migrations...") + + migrate.SetTable("migrations") + + migrations := &migrate.PackrMigrationSource{ + Box: packr.NewBox("../../migrations"), + } + + numMigrations, upErr := migrate.Exec(sql.DB, "sqlite3", migrations, migrate.Up) + if upErr != nil { + log.WithError(upErr).Panicln("error migrating database to newer version") + } + if numMigrations > 0 { + log.Debugf("successfully applied %d migrations to database", numMigrations) + } + + api := models.NewAPICollection(sql, log) + + tuners := make(map[int]chan bool) + + guideSources, guideSourcesErr := api.GuideSource.GetAllGuideSources(false) + if guideSourcesErr != nil { + log.WithError(guideSourcesErr).Panicln("error initializing video sources") + } + + guideSourceProvidersMap := make(map[int]guideproviders.GuideProvider) + + for _, guideSource := range guideSources { + providerCfg := guideSource.ProviderConfiguration() + provider, providerErr := providerCfg.GetProvider() + if providerErr != nil { + log.WithError(providerErr).Panicln("error initializing provider") + } + guideSourceProvidersMap[guideSource.ID] = provider + } + + videoSources, videoSourcesErr := api.VideoSource.GetAllVideoSources(false) + if videoSourcesErr != nil { + log.WithError(videoSourcesErr).Panicln("error initializing video sources") + } + + videoSourceProvidersMap := make(map[int]videoproviders.VideoProvider) + + for _, videoSource := range videoSources { + log.Infof("Initializing video source %s (%s)", videoSource.Name, videoSource.Provider) + providerCfg := videoSource.ProviderConfiguration() + provider, providerErr := providerCfg.GetProvider() + if providerErr != nil { + log.WithError(providerErr).Panicln("error initializing provider") + } + videoSourceProvidersMap[videoSource.ID] = provider + } + + context := &CContext{ + API: api, + Ctx: theCtx, + GuideSourceProviders: guideSourceProvidersMap, + Log: log, + RawSQL: sql, + Streams: make(map[string]*streamsuite.Stream), + Tuners: tuners, + VideoSourceProviders: videoSourceProvidersMap, + } + + log.Debugln("Context: Context build complete") + + return context, nil +} diff --git a/internal/guideproviders/main.go b/internal/guideproviders/main.go new file mode 100644 index 0000000..70526e6 --- /dev/null +++ b/internal/guideproviders/main.go @@ -0,0 +1,136 @@ +// Package guideproviders is a telly internal package to provide electronic program guide (EPG) data. +// It is generally modeled after the XMLTV standard with slight deviations to accommodate other providers. +package guideproviders + +import ( + "encoding/json" + "strings" + + "github.com/tellytv/telly/internal/xmltv" +) + +// Configuration is the basic configuration struct for guideproviders with generic values for specific providers. +type Configuration struct { + Name string `json:"-"` + Provider string + + // Only used for Schedules Direct provider + Username string + Password string + Lineups []string + + // Only used for XMLTV provider + XMLTVURL string +} + +// GetProvider returns an initialized GuideProvider for the Configuration. +func (i *Configuration) GetProvider() (GuideProvider, error) { + switch strings.ToLower(i.Provider) { + case "schedulesdirect", "schedules-direct", "sd": + return newSchedulesDirect(i) + default: + return newXMLTV(i) + } +} + +// Channel describes a channel available in the providers lineup with necessary pieces parsed into fields. +type Channel struct { + // Required Fields + ID string `json:",omitempty"` + Name string `json:",omitempty"` + Logos []Logo `json:",omitempty"` + Number string `json:",omitempty"` + + // Optional fields + CallSign string `json:",omitempty"` + URLs []string `json:",omitempty"` + Lineup string `json:",omitempty"` + Affiliate string `json:",omitempty"` + + ProviderData interface{} `json:",omitempty"` +} + +// XMLTV returns the xmltv.Channel representation of the Channel. +func (c *Channel) XMLTV() xmltv.Channel { + ch := xmltv.Channel{ + ID: c.ID, + LCN: c.Number, + URLs: c.URLs, + } + + // Why do we do this? From tv_grab_zz_sdjson: + // + // MythTV seems to assume that the first three display-name elements are + // name, callsign and channel number. We follow that scheme here. + ch.DisplayNames = []xmltv.CommonElement{ + { + Value: c.Name, + }, + { + Value: c.CallSign, + }, + { + Value: c.Number, + }, + } + + for _, logo := range c.Logos { + ch.Icons = append(ch.Icons, xmltv.Icon{ + Source: logo.URL, + Width: logo.Width, + Height: logo.Height, + }) + } + + return ch +} + +// A Logo stores the information about a channel logo +type Logo struct { + URL string `json:"URL"` + Height int `json:"height"` + Width int `json:"width"` +} + +// ProgrammeContainer contains information about a single provider in the XMLTV format +// as well as provider specific data. +type ProgrammeContainer struct { + Programme xmltv.Programme + ProviderData interface{} +} + +// AvailableLineup is a lineup that a user can subscribe to. +type AvailableLineup struct { + Location string + Transport string + Name string + ProviderID string +} + +// CoverageArea describes a region that a provider supports. +type CoverageArea struct { + RegionName string `json:",omitempty"` + FullName string `json:",omitempty"` + PostalCode string `json:",omitempty"` + PostalCodeExample string `json:",omitempty"` + ShortName string `json:",omitempty"` + OnePostalCode bool `json:",omitempty"` +} + +// GuideProvider describes a IPTV provider configuration. +type GuideProvider interface { + Name() string + Channels() ([]Channel, error) + Schedule(daysToGet int, inputChannels []Channel, inputProgrammes []ProgrammeContainer) (map[string]interface{}, []ProgrammeContainer, error) + + Refresh(lastStatusJSON *json.RawMessage) ([]byte, error) + Configuration() Configuration + + // Schedules Direct specific functions that others might someday use. + SupportsLineups() bool + LineupCoverage() ([]CoverageArea, error) + AvailableLineups(countryCode, postalCode string) ([]AvailableLineup, error) + PreviewLineupChannels(lineupID string) ([]Channel, error) + SubscribeToLineup(lineupID string) (interface{}, error) + UnsubscribeFromLineup(providerID string) error +} diff --git a/internal/guideproviders/schedules_direct.go b/internal/guideproviders/schedules_direct.go new file mode 100644 index 0000000..f5d2f85 --- /dev/null +++ b/internal/guideproviders/schedules_direct.go @@ -0,0 +1,919 @@ +package guideproviders + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + "time" + + schedulesdirect "github.com/tellytv/go.schedulesdirect" + "github.com/tellytv/telly/internal/utils" + "github.com/tellytv/telly/internal/xmltv" +) + +// SchedulesDirect is a GuideProvider supporting the Schedules Direct JSON service. +type SchedulesDirect struct { + BaseConfig Configuration + + client *schedulesdirect.Client + channels []Channel + stations map[string]sdStationContainer +} + +func newSchedulesDirect(config *Configuration) (GuideProvider, error) { + return &SchedulesDirect{BaseConfig: *config}, nil +} + +// Name returns the name of the GuideProvider. +func (s *SchedulesDirect) Name() string { + return "Schedules Direct" +} + +// SupportsLineups returns true if the provider supports the concept of subscribing to lineups. +func (s *SchedulesDirect) SupportsLineups() bool { + return true +} + +// LineupCoverage returns a map of regions and countries the provider has support for. +func (s *SchedulesDirect) LineupCoverage() ([]CoverageArea, error) { + if s.client == nil { + sdClient, sdClientErr := schedulesdirect.NewClient(s.BaseConfig.Username, s.BaseConfig.Password) + if sdClientErr != nil { + return nil, fmt.Errorf("error setting up schedules direct client: %s", sdClientErr) + } + + s.client = sdClient + } + + coverage, coverageErr := s.client.GetAvailableCountries() + if coverageErr != nil { + return nil, fmt.Errorf("error while getting coverage from provider %s: %s", s.Name(), coverageErr) + } + + outputCoverage := make([]CoverageArea, 0) + + for region, countries := range coverage { + for _, country := range countries { + outputCoverage = append(outputCoverage, CoverageArea{ + RegionName: region, + FullName: country.FullName, + PostalCode: country.PostalCode, + PostalCodeExample: country.PostalCodeExample, + ShortName: country.ShortName, + OnePostalCode: country.OnePostalCode, + }) + } + } + + return outputCoverage, nil +} + +// AvailableLineups will return a slice of AvailableLineup for the given countryCode and postalCode. +func (s *SchedulesDirect) AvailableLineups(countryCode, postalCode string) ([]AvailableLineup, error) { + if s.client == nil { + sdClient, sdClientErr := schedulesdirect.NewClient(s.BaseConfig.Username, s.BaseConfig.Password) + if sdClientErr != nil { + return nil, fmt.Errorf("error setting up schedules direct client: %s", sdClientErr) + } + + s.client = sdClient + } + + headends, headendsErr := s.client.GetHeadends(countryCode, postalCode) + if headendsErr != nil { + return nil, fmt.Errorf("error while getting available lineups from provider %s: %s", s.Name(), headendsErr) + } + + lineups := make([]AvailableLineup, 0) + for _, headend := range headends { + for _, lineup := range headend.Lineups { + lineups = append(lineups, AvailableLineup{ + Location: headend.Location, + Transport: headend.Transport, + Name: lineup.Name, + ProviderID: lineup.Lineup, + }) + } + } + + return lineups, nil +} + +// PreviewLineupChannels will return a slice of Channels for the given provider specific lineupID. +func (s *SchedulesDirect) PreviewLineupChannels(lineupID string) ([]Channel, error) { + if s.client == nil { + sdClient, sdClientErr := schedulesdirect.NewClient(s.BaseConfig.Username, s.BaseConfig.Password) + if sdClientErr != nil { + return nil, fmt.Errorf("error setting up schedules direct client: %s", sdClientErr) + } + + s.client = sdClient + } + + channels, channelsErr := s.client.PreviewLineup(lineupID) + if channelsErr != nil { + return nil, fmt.Errorf("error while previewing channels in lineup from provider %s: %s", s.Name(), channelsErr) + } + + outputChannels := make([]Channel, 0) + + for _, channel := range channels { + outputChannels = append(outputChannels, Channel{ + Name: channel.Name, + Number: channel.Channel, + CallSign: channel.CallSign, + Affiliate: channel.Affiliate, + Lineup: lineupID, + }) + } + + return outputChannels, nil +} + +// SubscribeToLineup will subscribe the user to a lineup. +func (s *SchedulesDirect) SubscribeToLineup(lineupID string) (interface{}, error) { + if s.client == nil { + sdClient, sdClientErr := schedulesdirect.NewClient(s.BaseConfig.Username, s.BaseConfig.Password) + if sdClientErr != nil { + return nil, fmt.Errorf("error setting up schedules direct client: %s", sdClientErr) + } + + s.client = sdClient + } + + newLineup, addLineupErr := s.client.AddLineup(lineupID) + if addLineupErr != nil { + return nil, fmt.Errorf("error while subscribing to lineup from provider %s: %s", s.Name(), addLineupErr) + } + return newLineup, nil +} + +// UnsubscribeFromLineup will remove a lineup from the provider account. +func (s *SchedulesDirect) UnsubscribeFromLineup(lineupID string) error { + if s.client == nil { + sdClient, sdClientErr := schedulesdirect.NewClient(s.BaseConfig.Username, s.BaseConfig.Password) + if sdClientErr != nil { + return fmt.Errorf("error setting up schedules direct client: %s", sdClientErr) + } + + s.client = sdClient + } + + _, deleteLineupErr := s.client.AddLineup(lineupID) + if deleteLineupErr != nil { + return fmt.Errorf("error while deleting lineup from provider %s: %s", s.Name(), deleteLineupErr) + } + return nil +} + +// Channels returns a slice of Channel that the provider has available. +func (s *SchedulesDirect) Channels() ([]Channel, error) { + return s.channels, nil +} + +// Schedule returns a slice of xmltv.Programme for the given channelIDs. +func (s *SchedulesDirect) Schedule(daysToGet int, inputChannels []Channel, inputProgrammes []ProgrammeContainer) (map[string]interface{}, []ProgrammeContainer, error) { + if s.client == nil { + sdClient, sdClientErr := schedulesdirect.NewClient(s.BaseConfig.Username, s.BaseConfig.Password) + if sdClientErr != nil { + return nil, nil, fmt.Errorf("error setting up schedules direct client: %s", sdClientErr) + } + + s.client = sdClient + } + // First, convert the slice of channelIDs into a slice of schedule requests. + reqs := make([]schedulesdirect.StationScheduleRequest, 0) + channelsCache := make(map[string]map[string]schedulesdirect.LastModifiedEntry) + requestingDates := getDaysBetweenTimes(time.Now(), time.Now().AddDate(0, 0, daysToGet)) + channelShortToLongIDMap := make(map[string]string) + for _, inputChannel := range inputChannels { + splitID := strings.Split(inputChannel.ID, ".")[1] + + channelShortToLongIDMap[splitID] = inputChannel.ID + + if len(inputChannel.ProviderData.(json.RawMessage)) > 0 { + channelCache := make(map[string]schedulesdirect.LastModifiedEntry) + if unmarshalErr := json.Unmarshal(inputChannel.ProviderData.(json.RawMessage), &channelCache); unmarshalErr != nil { + return nil, nil, unmarshalErr + } + + if len(channelCache) > 0 { + fmt.Printf("Channel %s exists in cache already with %d days of schedule available\n", inputChannel.ID, len(channelCache)) + channelsCache[splitID] = channelCache + } + } + + reqs = append(reqs, schedulesdirect.StationScheduleRequest{ + StationID: splitID, + Dates: requestingDates, + }) + } + + // Next, we get all modified parts of the schedule for any channels. + lastModifieds, lastModifiedsErr := s.client.GetLastModified(reqs) + if lastModifiedsErr != nil { + return nil, nil, fmt.Errorf("error getting lastModifieds from schedules direct: %s", lastModifiedsErr) + } + + channelsNeedingUpdate := make(map[string][]string) + + for stationID, dates := range lastModifieds { + longStationID := channelShortToLongIDMap[stationID] + if channelsNeedingUpdate[stationID] == nil { + channelsNeedingUpdate[stationID] = make([]string, 0) + } + for date, lastMod := range dates { + needsData := false + if cachedDate, ok := channelsCache[stationID][date]; ok { + fmt.Printf("For date %s: checking cached MD5 %s against server MD5 %s for %s\n", date, cachedDate.MD5, lastMod.MD5, longStationID) + if cachedDate.MD5 != lastMod.MD5 { + fmt.Printf("Station %s needs updated data for %s\n", longStationID, date) + needsData = true + channelsNeedingUpdate[stationID] = append(channelsNeedingUpdate[stationID], date) + } + } else { + fmt.Printf("Station %s needs data for %s\n", longStationID, date) + needsData = true + channelsNeedingUpdate[stationID] = append(channelsNeedingUpdate[stationID], date) + } + if needsData { + if channelsCache[stationID] == nil { + channelsCache[stationID] = make(map[string]schedulesdirect.LastModifiedEntry) + } + channelsCache[stationID][date] = lastMod + } + } + if _, ok := channelsCache[stationID]; !ok { + fmt.Printf("Station %s needs initial data\n", longStationID) + channelsNeedingUpdate[stationID] = requestingDates + continue + } + } + + fullScheduleReqs := make([]schedulesdirect.StationScheduleRequest, 0) + // Next, using the channelsNeedingUpdate, build new schedule requests for station(s) missing data for date(s). + // Let's also add all these values to channelsCache to use that for the return. + for stationID, dates := range channelsNeedingUpdate { + if len(dates) > 0 { + fmt.Printf("Requesting dates %s for station %s\n", strings.Join(dates, ", "), stationID) + fullScheduleReqs = append(fullScheduleReqs, schedulesdirect.StationScheduleRequest{ + StationID: stationID, + Dates: dates, + }) + } + } + + outputChannelsMap := make(map[string]interface{}) + for shortChannelID, longChannelID := range channelShortToLongIDMap { + outputChannelsMap[longChannelID] = channelsCache[shortChannelID] + } + + if reflect.DeepEqual(outputChannelsMap, channelsCache) { + outputChannelsMap = nil + } + + // Great, we don't need to get any new schedule data, let's terminate early. + if len(fullScheduleReqs) == 0 { + fmt.Println("No updates required, exiting Schedule()") + return outputChannelsMap, nil, nil + } + + // So we do have some requests to make, let's do that now. + schedules, schedulesErr := s.client.GetSchedules(fullScheduleReqs) + if schedulesErr != nil { + return nil, nil, fmt.Errorf("error getting schedules from schedules direct: %s", schedulesErr) + } + + // Next, we need to bundle up all the program IDs and request detailed information about them. + neededProgramIDs := make(map[string]struct{}) + + for _, schedule := range schedules { + for _, program := range schedule.Programs { + neededProgramIDs[program.ProgramID] = struct{}{} + } + } + + extendedProgramInfo := make(map[string]schedulesdirect.ProgramInfo) + + programsWithArtwork := make(map[string]struct{}) + + // IDs slice is built, let's chunk and get the info. + for _, chunk := range utils.ChunkStringSlice(utils.GetStringMapKeys(neededProgramIDs), 5000) { + moreInfo, moreInfoErr := s.client.GetProgramInfo(chunk) + if moreInfoErr != nil { + return nil, nil, fmt.Errorf("error when getting more program details from schedules direct: %s", moreInfoErr) + } + + for _, program := range moreInfo { + extendedProgramInfo[program.ProgramID] = program + if program.HasArtwork() { + for _, programID := range program.ArtworkLookupIDs() { + programsWithArtwork[programID] = struct{}{} + } + } + } + } + + allArtwork := make(map[string][]schedulesdirect.Artwork) + + // Now that we have the initial program info results, let's get all the artwork. + artworkResp, artworkErr := s.client.GetArtworkForProgramIDs(utils.GetStringMapKeys(programsWithArtwork)) + if artworkErr != nil { + return nil, nil, fmt.Errorf("error when getting artwork from schedules direct: %s", artworkErr) + } + + for _, artworks := range artworkResp { + allArtwork[artworks.ProgramID] = *artworks.Artwork + } + + // We finally have all the data, time to convert to the XMLTV format. + programmes := make([]ProgrammeContainer, 0) + + // Iterate over every result, converting to XMLTV format. + for _, schedule := range schedules { + station := s.stations[schedule.StationID] + for _, airing := range schedule.Programs { + programInfo := extendedProgramInfo[airing.ProgramID] + artworks := make([]schedulesdirect.Artwork, 0) + for _, lookupKey := range programInfo.ArtworkLookupIDs() { + if hasArtwork, ok := allArtwork[lookupKey]; ok { + artworks = append(artworks, hasArtwork...) + } + } + + sort.Slice(artworks, func(i, j int) bool { + tier := func(a schedulesdirect.Artwork) int { + return int(parseArtworkTierToOrder(a.Tier)) + } + category := func(a schedulesdirect.Artwork) int { + return int(parseArtworkCategoryToOrder(a.Category)) + } + a := tier(artworks[i]) + b := tier(artworks[i]) + if a == b { + return category(artworks[i]) < category(artworks[j]) + } + return a < b + }) + + extendedInfo := extendedProgramInfo[airing.ProgramID] + + programme, programmeErr := s.processProgrammeToXMLTV(&airing, &extendedInfo, artworks, &station) + if programmeErr != nil { + return nil, nil, fmt.Errorf("error while processing schedules direct result to xmltv format: %s", programmeErr) + } + programmes = append(programmes, *programme) + } + } + + return outputChannelsMap, programmes, nil +} + +// Refresh causes the provider to request the latest information. +func (s *SchedulesDirect) Refresh(lastStatusJSON *json.RawMessage) ([]byte, error) { + if s.client == nil { + sdClient, sdClientErr := schedulesdirect.NewClient(s.BaseConfig.Username, s.BaseConfig.Password) + if sdClientErr != nil { + return nil, fmt.Errorf("error setting up schedules direct client: %s", sdClientErr) + } + + s.client = sdClient + } + + lineupsMetadataMap := make(map[string]schedulesdirect.Lineup) + var lastStatus schedulesdirect.StatusResponse + if lastStatusJSON != nil && len(*lastStatusJSON) > 0 { + if unmarshalErr := json.Unmarshal(*lastStatusJSON, &lastStatus); unmarshalErr != nil { + return nil, fmt.Errorf("error unmarshalling cached status JSON: %s", unmarshalErr) + } + + for _, lineup := range lastStatus.Lineups { + lineupsMetadataMap[lineup.Lineup] = lineup + } + } + + // First, get the lineups added to the users account. + // SD API docs say to check system status before proceeding. + // NewClient above does that automatically for us. + status, statusErr := s.client.GetStatus() + if statusErr != nil { + return nil, fmt.Errorf("error getting schedules direct status: %s", statusErr) + } + + marshalledLineups, marshalledLineupsErr := json.Marshal(status) + if marshalledLineupsErr != nil { + return nil, fmt.Errorf("error when marshalling schedules direct lineups to json: %s", marshalledLineupsErr) + } + + // If there's anything in this slice we know that channels in the SD lineup are changing. + allLineups := make([]string, 0) + + for _, lineup := range status.Lineups { + // if existingLineup, ok := lineupsMetadataMap[lineup.Lineup]; ok { + // // If lineup modified in database is not equal to lineup modified API provided + // // append lineup ID to allLineups + // if !existingLineup.Modified.Equal(lineup.Modified) { + // allLineups = append(allLineups, lineup.Lineup) + // } + // } + allLineups = append(allLineups, lineup.Lineup) + } + + // Figure out if we need to add any lineups to the account. + neededLineups := make([]string, 0) + + for _, wantedLineup := range s.BaseConfig.Lineups { + needLineup := true + for _, previouslyAddedLineup := range allLineups { + if previouslyAddedLineup == wantedLineup { + needLineup = false + allLineups = append(allLineups, previouslyAddedLineup) + } + } + if needLineup { + neededLineups = append(neededLineups, wantedLineup) + } + } + + // Sanity check + if len(status.Lineups) == status.Account.MaxLineups && len(neededLineups) > 0 { + return marshalledLineups, fmt.Errorf("attempting to add more than %d lineups to a schedules direct account will fail, exiting prematurely", status.Account.MaxLineups) + } + + // Add needed lineups + for _, neededLineupName := range neededLineups { + if _, err := s.client.AddLineup(neededLineupName); err != nil { + return marshalledLineups, fmt.Errorf("error when adding lineup %s to schedules direct account: %s", neededLineupName, err) + } + allLineups = append(allLineups, neededLineupName) + } + + // Next, let's fill in the available channels in all the lineups. + for _, lineupName := range allLineups { + channels, channelsErr := s.client.GetChannels(lineupName, true) + if channelsErr != nil { + return marshalledLineups, fmt.Errorf("error getting channels from schedules direct for lineup %s: %s", lineupName, channelsErr) + } + + stationsMap := make(map[string]sdStationContainer) + + for _, stn := range channels.Stations { + stationsMap[stn.StationID] = sdStationContainer{Station: stn} + } + + for _, entry := range channels.Map { + if val, ok := stationsMap[entry.StationID]; ok { + stationsMap[entry.StationID] = sdStationContainer{Station: val.Station, ChannelMap: entry} + } + } + + if s.stations == nil { + s.stations = make(map[string]sdStationContainer) + } + + if s.channels == nil { + s.channels = make([]Channel, 0) + } + + for _, station := range stationsMap { + logos := make([]Logo, 0) + + for _, stnLogo := range station.Station.Logos { + logos = append(logos, Logo{ + URL: stnLogo.URL, + Height: stnLogo.Height, + Width: stnLogo.Width, + }) + } + + s.channels = append(s.channels, Channel{ + ID: fmt.Sprintf("I%s.%s.schedulesdirect.org", station.ChannelMap.Channel, station.Station.StationID), + Name: station.Station.Name, + Logos: logos, + Number: station.ChannelMap.Channel, + CallSign: station.Station.CallSign, + Lineup: lineupName, + }) + + s.stations[station.Station.StationID] = station + } + } + + // We're done! + + return marshalledLineups, nil +} + +// Configuration returns the base configuration backing the provider. +func (s *SchedulesDirect) Configuration() Configuration { + return s.BaseConfig +} + +type sdStationContainer struct { + Station schedulesdirect.Station + ChannelMap schedulesdirect.ChannelMap +} + +func getXMLTVNumber(mdata []map[string]schedulesdirect.Metadata, multipartInfo *schedulesdirect.Part) string { + seasonNumber := 0 + episodeNumber := 0 + totalSeasons := 0 + totalEpisodes := 0 + numbersFilled := false + + for _, meta := range mdata { + for _, metadata := range meta { + if metadata.Season > 0 { + seasonNumber = metadata.Season - 1 // SD metadata isnt 0 index + numbersFilled = true + } + if metadata.Episode > 0 { + episodeNumber = metadata.Episode - 1 + numbersFilled = true + } + if metadata.TotalEpisodes > 0 { + totalEpisodes = metadata.TotalEpisodes + numbersFilled = true + } + if metadata.TotalSeasons > 0 { + totalSeasons = metadata.TotalSeasons + numbersFilled = true + } + } + } + + if numbersFilled { + seasonNumberStr := fmt.Sprintf("%d", seasonNumber) + if totalSeasons > 0 { + seasonNumberStr = fmt.Sprintf("%d/%d", seasonNumber, totalSeasons) + } + episodeNumberStr := fmt.Sprintf("%d", episodeNumber) + if totalEpisodes > 0 { + episodeNumberStr = fmt.Sprintf("%d/%d", episodeNumber, totalEpisodes) + } + + partStr := "0" + + partNumber := 0 + totalParts := 0 + + if multipartInfo != nil { + partNumber = multipartInfo.PartNumber + totalParts = multipartInfo.TotalParts + } + + if partNumber > 0 { + partStr = fmt.Sprintf("%d", partNumber) + if totalParts > 0 { + partStr = fmt.Sprintf("%d/%d", partNumber, totalParts) + } + } + + return fmt.Sprintf("%s.%s.%s", seasonNumberStr, episodeNumberStr, partStr) + } + + return "" +} + +type sdProgrammeData struct { + Airing schedulesdirect.Program + ProgramInfo schedulesdirect.ProgramInfo + AllArtwork []schedulesdirect.Artwork + Station sdStationContainer +} + +func (s *SchedulesDirect) processProgrammeToXMLTV(airing *schedulesdirect.Program, programInfo *schedulesdirect.ProgramInfo, allArtwork []schedulesdirect.Artwork, station *sdStationContainer) (*ProgrammeContainer, error) { + stationID := fmt.Sprintf("I%s.%s.schedulesdirect.org", station.ChannelMap.Channel, station.Station.StationID) + endTime := airing.AirDateTime.Add(time.Duration(airing.Duration) * time.Second) + length := xmltv.Length{Units: "seconds", Value: strconv.Itoa(airing.Duration)} + + // First we fill in all the "simple" fields that don't require any extra processing. + xmlProgramme := xmltv.Programme{ + Channel: stationID, + ID: airing.ProgramID, + Length: &length, + Start: &xmltv.Time{Time: *airing.AirDateTime}, + Stop: &xmltv.Time{Time: endTime}, + } + + // Now for the fields that have to be parsed. + for _, broadcastLang := range station.Station.BroadcastLanguage { + xmlProgramme.Languages = []xmltv.CommonElement{{ + Value: broadcastLang, + Lang: broadcastLang, + }} + } + + xmlProgramme.Titles = make([]xmltv.CommonElement, 0) + for _, sdTitle := range programInfo.Titles { + xmlProgramme.Titles = append(xmlProgramme.Titles, xmltv.CommonElement{ + Value: sdTitle.Title120, + }) + } + + if programInfo.EpisodeTitle150 != "" { + xmlProgramme.SecondaryTitles = []xmltv.CommonElement{{ + Value: programInfo.EpisodeTitle150, + }} + } + + xmlProgramme.Descriptions = make([]xmltv.CommonElement, 0) + if d1000, ok := programInfo.Descriptions["description1000"]; ok && len(d1000) > 0 { + // TODO: This doesn't account for if the program has descriptions in different languages. + // It will always just use the first description. + xmlProgramme.Descriptions = append(xmlProgramme.Descriptions, xmltv.CommonElement{ + Value: d1000[0].Description, + Lang: d1000[0].Language, + }) + } + + if d100, ok := programInfo.Descriptions["description100"]; ok && len(d100) > 0 { + xmlProgramme.Descriptions = append(xmlProgramme.Descriptions, xmltv.CommonElement{ + Value: d100[0].Description, + Lang: d100[0].Language, + }) + } + + for _, sdCast := range append(programInfo.Cast, programInfo.Crew...) { + if xmlProgramme.Credits == nil { + xmlProgramme.Credits = &xmltv.Credits{} + } + lowerRole := strings.ToLower(sdCast.Role) + if strings.Contains(lowerRole, "director") { + xmlProgramme.Credits.Directors = append(xmlProgramme.Credits.Directors, sdCast.Name) + } else if strings.Contains(lowerRole, "actor") || strings.Contains(lowerRole, "voice") { + role := "" + if sdCast.Role != "Actor" { + role = sdCast.Role + } + xmlProgramme.Credits.Actors = append(xmlProgramme.Credits.Actors, xmltv.Actor{ + Role: role, + Value: sdCast.Name, + }) + } else if strings.Contains(lowerRole, "writer") { + xmlProgramme.Credits.Writers = append(xmlProgramme.Credits.Writers, sdCast.Name) + } else if strings.Contains(lowerRole, "producer") { + xmlProgramme.Credits.Producers = append(xmlProgramme.Credits.Producers, sdCast.Name) + } else if strings.Contains(lowerRole, "host") || strings.Contains(lowerRole, "anchor") { + xmlProgramme.Credits.Presenters = append(xmlProgramme.Credits.Presenters, sdCast.Name) + } else if strings.Contains(lowerRole, "guest") || strings.Contains(lowerRole, "contestant") { + xmlProgramme.Credits.Guests = append(xmlProgramme.Credits.Guests, sdCast.Name) + } + } + + if programInfo.Movie != nil && programInfo.Movie.Year != nil && !programInfo.Movie.Year.Time.IsZero() { + xmlProgramme.Date = xmltv.Date(*programInfo.Movie.Year.Time) + } + + xmlProgramme.Categories = make([]xmltv.CommonElement, 0) + seenCategories := make(map[string]struct{}) + for _, sdCategory := range programInfo.Genres { + if _, ok := seenCategories[sdCategory]; !ok { + xmlProgramme.Categories = append(xmlProgramme.Categories, xmltv.CommonElement{ + Value: sdCategory, + }) + seenCategories[sdCategory] = struct{}{} + } + } + + entityTypeCat := string(programInfo.EntityType) + + if programInfo.EntityType == "episode" { + entityTypeCat = "series" + } + + if _, ok := seenCategories[entityTypeCat]; !ok { + xmlProgramme.Categories = append(xmlProgramme.Categories, xmltv.CommonElement{ + Value: entityTypeCat, + }) + } + + seenKeywords := make(map[string]struct{}) + for _, keywords := range programInfo.Keywords { + for _, keyword := range keywords { + if _, ok := seenKeywords[keyword]; !ok { + xmlProgramme.Keywords = append(xmlProgramme.Keywords, xmltv.CommonElement{ + Value: utils.KebabCase(keyword), + }) + seenKeywords[keyword] = struct{}{} + } + } + } + + if programInfo.OfficialURL != "" { + xmlProgramme.URLs = []string{programInfo.OfficialURL} + } + + for _, artworkItem := range allArtwork { + if strings.HasPrefix(artworkItem.URI, "assets/") { + artworkItem.URI = fmt.Sprint(schedulesdirect.DefaultBaseURL, schedulesdirect.APIVersion, "/image/", artworkItem.URI) + } + xmlProgramme.Icons = append(xmlProgramme.Icons, xmltv.Icon{ + Source: artworkItem.URI, + Width: artworkItem.Width, + Height: artworkItem.Height, + }) + } + + xmlProgramme.EpisodeNums = append(xmlProgramme.EpisodeNums, xmltv.EpisodeNum{ + System: "dd_progid", + Value: programInfo.ProgramID, + }) + + xmltvns := getXMLTVNumber(programInfo.Metadata, airing.ProgramPart) + if xmltvns != "" { + xmlProgramme.EpisodeNums = append(xmlProgramme.EpisodeNums, xmltv.EpisodeNum{System: "xmltv_ns", Value: xmltvns}) + } + + sxxexx := "" + + for _, metadata := range programInfo.Metadata { + for _, mdProvider := range metadata { + if mdProvider.Season > 0 && mdProvider.Episode > 0 { + sxxexx = fmt.Sprintf("S%sE%s", utils.PadNumberWithZeros(mdProvider.Season, 2), utils.PadNumberWithZeros(mdProvider.Episode, 2)) + } + } + } + + if sxxexx != "" { + xmlProgramme.EpisodeNums = append(xmlProgramme.EpisodeNums, xmltv.EpisodeNum{System: "SxxExx", Value: sxxexx}) + } + + for _, videoProperty := range airing.VideoProperties { + if xmlProgramme.Video == nil { + xmlProgramme.Video = &xmltv.Video{} + } + if station.Station.IsRadioStation { + continue + } + xmlProgramme.Video.Present = "yes" + if strings.ToLower(videoProperty) == "hdtv" { + xmlProgramme.Video.Quality = "HDTV" + xmlProgramme.Video.Aspect = "16:9" + } else if strings.ToLower(videoProperty) == "uhdtv" { + xmlProgramme.Video.Quality = "UHD" + } else if strings.ToLower(videoProperty) == "sdtv" { + xmlProgramme.Video.Aspect = "4:3" + } + } + + for _, audioProperty := range airing.AudioProperties { + switch strings.ToLower(audioProperty) { + case "dd": + xmlProgramme.Audio = &xmltv.Audio{Stereo: "dolby digital"} + case "dd 5.1", "surround", "atmos": + xmlProgramme.Audio = &xmltv.Audio{Stereo: "surround"} + case "dolby": + xmlProgramme.Audio = &xmltv.Audio{Stereo: "dolby"} + case "stereo": + xmlProgramme.Audio = &xmltv.Audio{Stereo: "stereo"} + case "mono": + xmlProgramme.Audio = &xmltv.Audio{Stereo: "mono"} + case "cc", "subtitled": + xmlProgramme.Subtitles = append(xmlProgramme.Subtitles, xmltv.Subtitle{Type: "teletext"}) + } + } + + if airing.Signed { + xmlProgramme.Subtitles = append(xmlProgramme.Subtitles, xmltv.Subtitle{Type: "deaf-signed"}) + } + + if programInfo.OriginalAirDate != nil && !programInfo.OriginalAirDate.Time.IsZero() { + if !airing.New { + xmlProgramme.PreviouslyShown = &xmltv.PreviouslyShown{ + Start: xmltv.Time{Time: *programInfo.OriginalAirDate.Time}, + } + } + + timeToUse := programInfo.OriginalAirDate.Time + if airing.New { + timeToUse = airing.AirDateTime + } + + xmlProgramme.EpisodeNums = append(xmlProgramme.EpisodeNums, xmltv.EpisodeNum{ + System: "original-air-date", + Value: timeToUse.Format("2006-01-02 15:04:05"), + }) + } + + if airing.Repeat && xmlProgramme.PreviouslyShown != nil { + xmlProgramme.PreviouslyShown = nil + } + + seenRatings := make(map[string]string) + for _, rating := range append(programInfo.ContentRating, airing.Ratings...) { + if _, ok := seenRatings[rating.Body]; !ok { + xmlProgramme.Ratings = append(xmlProgramme.Ratings, xmltv.Rating{ + Value: rating.Code, + System: rating.Body, + }) + seenRatings[rating.Body] = rating.Code + } + } + + if programInfo.Movie != nil { + for _, starRating := range programInfo.Movie.QualityRating { + xmlProgramme.StarRatings = append(xmlProgramme.StarRatings, xmltv.Rating{ + Value: fmt.Sprintf("%s/%s", starRating.Rating, starRating.MaxRating), + System: starRating.RatingsBody, + }) + } + } + + if airing.IsPremiereOrFinale != nil && *airing.IsPremiereOrFinale != "" { + xmlProgramme.Premiere = &xmltv.CommonElement{ + Lang: "en", + Value: string(*airing.IsPremiereOrFinale), + } + } + + if airing.Premiere { + xmlProgramme.Premiere = &xmltv.CommonElement{} + } + + if airing.New { + elm := xmltv.ElementPresent(true) + xmlProgramme.New = &elm + } + + // Done processing! + return &ProgrammeContainer{ + Programme: xmlProgramme, + ProviderData: sdProgrammeData{ + *airing, + *programInfo, + allArtwork, + *station, + }, + }, nil + +} + +func getDaysBetweenTimes(start, end time.Time) []string { + dates := make([]string, 0) + for last := start; last.Before(end); last = last.AddDate(0, 0, 1) { + dates = append(dates, last.Format("2006-01-02")) + } + return dates +} + +type artworkTierOrder int + +const ( + episodeTier artworkTierOrder = 1 + seasonTier artworkTierOrder = 2 + seriesTier artworkTierOrder = 3 + + dontCareTier artworkTierOrder = 10 +) + +func parseArtworkTierToOrder(tier schedulesdirect.ArtworkTier) artworkTierOrder { + switch tier { + case schedulesdirect.EpisodeTier: + return episodeTier + case schedulesdirect.SeasonTier: + return seasonTier + case schedulesdirect.SeriesTier: + return seriesTier + default: + return dontCareTier + } +} + +type artworkCategoryOrder int + +const ( + bannerL1 artworkCategoryOrder = 1 + bannerL1T artworkCategoryOrder = 2 + banner artworkCategoryOrder = 3 + bannerL2 artworkCategoryOrder = 4 + bannerL3 artworkCategoryOrder = 5 + bannerLO artworkCategoryOrder = 6 + bannerLOT artworkCategoryOrder = 7 + + dontCareCategory artworkCategoryOrder = 10 +) + +func parseArtworkCategoryToOrder(Category schedulesdirect.ArtworkCategory) artworkCategoryOrder { + switch Category { + case schedulesdirect.BannerL1: + return bannerL1 + case schedulesdirect.BannerL1T: + return bannerL1T + case schedulesdirect.Banner: + return banner + case schedulesdirect.BannerL2: + return bannerL2 + case schedulesdirect.BannerL3: + return bannerL3 + case schedulesdirect.BannerLO: + return bannerLO + case schedulesdirect.BannerLOT: + return bannerLOT + } + + return dontCareCategory +} diff --git a/internal/guideproviders/xmltv.go b/internal/guideproviders/xmltv.go new file mode 100644 index 0000000..732d000 --- /dev/null +++ b/internal/guideproviders/xmltv.go @@ -0,0 +1,122 @@ +package guideproviders + +import ( + "encoding/json" + "fmt" + + "github.com/tellytv/telly/internal/utils" + "github.com/tellytv/telly/internal/xmltv" +) + +// XMLTV is a GuideProvider supporting XMLTV files. +type XMLTV struct { + BaseConfig Configuration + + channels []Channel + file *xmltv.TV +} + +func newXMLTV(config *Configuration) (GuideProvider, error) { + provider := &XMLTV{BaseConfig: *config} + + if _, loadErr := provider.Refresh(nil); loadErr != nil { + return nil, loadErr + } + + return provider, nil +} + +// Name returns the name of the GuideProvider. +func (x *XMLTV) Name() string { + return "XMLTV" +} + +// SupportsLineups returns true if the provider supports the concept of subscribing to lineups. +func (x *XMLTV) SupportsLineups() bool { + return false +} + +// LineupCoverage returns a map of regions and countries the provider has support for. +func (x *XMLTV) LineupCoverage() ([]CoverageArea, error) { + return nil, nil +} + +// AvailableLineups will return a slice of AvailableLineup for the given countryCode and postalCode. +func (x *XMLTV) AvailableLineups(countryCode, postalCode string) ([]AvailableLineup, error) { + return nil, nil +} + +// PreviewLineupChannels will return a slice of Channels for the given provider specific lineupID. +func (x *XMLTV) PreviewLineupChannels(lineupID string) ([]Channel, error) { + return nil, nil +} + +// SubscribeToLineup will subscribe the user to a lineup. +func (x *XMLTV) SubscribeToLineup(lineupID string) (interface{}, error) { + return nil, nil +} + +// UnsubscribeFromLineup will remove a lineup from the provider account. +func (x *XMLTV) UnsubscribeFromLineup(providerID string) error { + return nil +} + +// Channels returns a slice of Channel that the provider has available. +func (x *XMLTV) Channels() ([]Channel, error) { + return x.channels, nil +} + +// Schedule returns a slice of xmltv.Programme for the given channelIDs. +func (x *XMLTV) Schedule(daysToGet int, inputChannels []Channel, inputProgrammes []ProgrammeContainer) (map[string]interface{}, []ProgrammeContainer, error) { + channelIDMap := make(map[string]struct{}) + for _, chanID := range inputChannels { + channelIDMap[chanID.ID] = struct{}{} + } + + filteredProgrammes := make([]ProgrammeContainer, 0) + + for _, programme := range x.file.Programmes { + if _, ok := channelIDMap[programme.Channel]; ok { + filteredProgrammes = append(filteredProgrammes, ProgrammeContainer{programme, nil}) + } + } + + return nil, filteredProgrammes, nil +} + +// Refresh causes the provider to request the latest information. +func (x *XMLTV) Refresh(lastStatusJSON *json.RawMessage) ([]byte, error) { + xTV, xTVErr := utils.GetXMLTV(x.BaseConfig.XMLTVURL) + if xTVErr != nil { + return nil, fmt.Errorf("error when getting XMLTV file: %s", xTVErr) + } + + x.file = xTV + + for _, channel := range xTV.Channels { + logos := make([]Logo, 0) + + for _, icon := range channel.Icons { + logos = append(logos, Logo{ + URL: icon.Source, + Height: icon.Height, + Width: icon.Width, + }) + } + + x.channels = append(x.channels, Channel{ + ID: channel.ID, + Name: channel.DisplayNames[0].Value, + Logos: logos, + Number: channel.LCN, + CallSign: "UNK", + }) + } + + return nil, nil +} + +// Configuration returns the base configuration backing the provider +func (x *XMLTV) Configuration() Configuration { + return x.BaseConfig +} diff --git a/m3u/main.go b/internal/m3uplus/main.go similarity index 80% rename from m3u/main.go rename to internal/m3uplus/main.go index ee33ce7..d3b61d0 100644 --- a/m3u/main.go +++ b/internal/m3uplus/main.go @@ -1,4 +1,5 @@ -package m3u +// Package m3uplus provides a M3U Plus parser. +package m3uplus import ( "bytes" @@ -13,15 +14,17 @@ import ( // Playlist is a type that represents an m3u playlist containing 0 or more tracks type Playlist struct { - Tracks []*Track + Tracks []Track } // Track represents an m3u track type Track struct { - Name string - Length float64 - URI string - Tags map[string]string + Name string + Length float64 + URI string + Tags map[string]string + Raw string + LineNumber int } // UnmarshalTags will decode the Tags map into a struct containing fields with `m3u` tags matching map keys. @@ -69,28 +72,31 @@ func decode(playlist *Playlist, buf *bytes.Buffer) error { } if lineNum == 1 && !strings.HasPrefix(strings.TrimSpace(line), "#EXTM3U") { - return fmt.Errorf("malformed M3U provided") + return fmt.Errorf("malformed M3U provided, got: %s", buf.String()) } - if err = decodeLine(playlist, line); err != nil { + if err = decodeLine(playlist, line, lineNum); err != nil { return err } } return nil } -func decodeLine(playlist *Playlist, line string) error { +func decodeLine(playlist *Playlist, line string, lineNumber int) error { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "#EXTINF:"): - track := new(Track) + track := Track{ + Raw: line, + LineNumber: lineNumber, + } track.Length, track.Name, track.Tags = decodeInfoLine(line) playlist.Tracks = append(playlist.Tracks, track) - case strings.HasPrefix(line, "http"): + case strings.HasPrefix(line, "http") || strings.HasPrefix(line, "udp"): playlist.Tracks[len(playlist.Tracks)-1].URI = line } @@ -120,7 +126,7 @@ func decodeInfoLine(line string) (float64, string, map[string]string) { if val == "" { // If empty string find a number in [3] val = match[3] } - keyMap[match[1]] = val + keyMap[strings.ToLower(match[1])] = val } return durationFloat, title, keyMap diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..a130f4b --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,72 @@ +//Package metrics provides Prometheus metrics. +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/version" +) + +var ( + // ExposedChannels tracks the total number of exposed channels + ExposedChannels = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "telly", + Subsystem: "channels", + Name: "total", + Help: "Number of exposed channels.", + }, + []string{"lineup_name", "video_source_name", "video_source_provider"}, + ) + // ActiveStreams tracks the realtime number of active streams. + ActiveStreams = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "telly", + Subsystem: "channels", + Name: "active", + Help: "Number of active streams.", + }, + []string{"lineup_name", "video_source_name", "video_source_provider", "channel_name", "stream_transport"}, + ) + // PausedStreams tracks the realtime number of paused streams. + PausedStreams = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "telly", + Subsystem: "channels", + Name: "paused", + Help: "Number of paused streams.", + }, + []string{"lineup_name", "video_source_name", "video_source_provider", "channel_name", "stream_transport"}, + ) + // StreamPlayingTime reports the total amount of time streamed since startup. + StreamPlayingTime = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "telly", + Subsystem: "channels", + Name: "playing_time", + Help: "Amount of stream playing time in seconds.", + Buckets: prometheus.ExponentialBuckets(0.1, 1.5, 5), + }, + []string{"lineup_name", "video_source_name", "video_source_provider", "channel_name", "stream_transport"}, + ) + // StreamPausedTime reports the total amount of time streamed since startup. + StreamPausedTime = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "telly", + Subsystem: "channels", + Name: "paused_time", + Help: "Amount of stream paused time in seconds.", + Buckets: prometheus.ExponentialBuckets(0.1, 1.5, 5), + }, + []string{"lineup_name", "video_source_name", "video_source_provider", "channel_name", "stream_transport"}, + ) +) + +// nolint +func init() { + prometheus.MustRegister(version.NewCollector("telly")) + prometheus.MustRegister(ExposedChannels) + prometheus.MustRegister(ActiveStreams) + prometheus.MustRegister(PausedStreams) + prometheus.MustRegister(StreamPlayingTime) + prometheus.MustRegister(StreamPausedTime) +} diff --git a/internal/models/guide_source.go b/internal/models/guide_source.go new file mode 100644 index 0000000..3bc3e1b --- /dev/null +++ b/internal/models/guide_source.go @@ -0,0 +1,181 @@ +package models + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/tellytv/telly/internal/guideproviders" +) + +// GuideSourceDB is a struct containing initialized the SQL connection as well as the APICollection. +type GuideSourceDB struct { + SQL *sqlx.DB + Collection *APICollection +} + +func newGuideSourceDB( + SQL *sqlx.DB, + Collection *APICollection, +) *GuideSourceDB { + db := &GuideSourceDB{ + SQL: SQL, + Collection: Collection, + } + return db +} + +func (db *GuideSourceDB) tableName() string { + return "guide_source" +} + +// GuideSource describes a source of EPG data. +type GuideSource struct { + ID int `db:"id"` + Name string `db:"name"` + Provider string `db:"provider"` + Username string `db:"username"` + Password string `db:"password"` + URL string `db:"xmltv_url" json:"XMLTV_URL"` + ProviderData *json.RawMessage `db:"provider_data"` + UpdateFrequency string `db:"update_frequency"` + ImportedAt *time.Time `db:"imported_at"` + + Channels []GuideSourceChannel `db:"-"` +} + +// ProviderConfiguration returns a guideproviders.Configurator for the GuideSource. +func (g *GuideSource) ProviderConfiguration() *guideproviders.Configuration { + return &guideproviders.Configuration{ + Name: g.Name, + Provider: g.Provider, + Username: g.Username, + Password: g.Password, + XMLTVURL: g.URL, + } +} + +// GuideSourceAPI contains all methods for the User struct +type GuideSourceAPI interface { + InsertGuideSource(guideSourceStruct GuideSource, providerData interface{}) (*GuideSource, error) + DeleteGuideSource(guideSourceID int) error + UpdateGuideSource(guideSourceID int, guideSourceStruct GuideSource) (*GuideSource, error) + UpdateProviderData(guideSourceID int, providerData interface{}) error + GetGuideSourceByID(id int) (*GuideSource, error) + GetAllGuideSources(includeChannels bool) ([]GuideSource, error) + GetGuideSourcesForLineup(lineupID int) ([]GuideSource, error) +} + +const baseGuideSourceQuery string = ` +SELECT + G.id, + G.name, + G.provider, + G.username, + G.password, + G.xmltv_url, + G.provider_data, + G.update_frequency, + G.imported_at + FROM guide_source G` + +// InsertGuideSource inserts a new GuideSource into the database. +func (db *GuideSourceDB) InsertGuideSource(guideSourceStruct GuideSource, providerData interface{}) (*GuideSource, error) { + guideSource := GuideSource{} + + providerDataJSON, providerDataJSONErr := json.Marshal(providerData) + if providerDataJSONErr != nil { + return nil, fmt.Errorf("error when marshalling providerData for use in guide_source_programme insert: %s", providerDataJSONErr) + } + + rawJSON := json.RawMessage(providerDataJSON) + + guideSourceStruct.ProviderData = &rawJSON + + if guideSourceStruct.UpdateFrequency == "" { + guideSourceStruct.UpdateFrequency = "@daily" + } + + res, err := db.SQL.NamedExec(` + INSERT INTO guide_source (name, provider, username, password, xmltv_url, provider_data, update_frequency) + VALUES (:name, :provider, :username, :password, :xmltv_url, :provider_data, :update_frequency);`, guideSourceStruct) + if err != nil { + return &guideSource, err + } + rowID, rowIDErr := res.LastInsertId() + if rowIDErr != nil { + return &guideSource, rowIDErr + } + err = db.SQL.Get(&guideSource, "SELECT * FROM guide_source WHERE id = $1", rowID) + return &guideSource, err +} + +// GetGuideSourceByID returns a single GuideSource for the given ID. +func (db *GuideSourceDB) GetGuideSourceByID(id int) (*GuideSource, error) { + var guideSource GuideSource + sql, args, sqlGenErr := squirrel.Select("*").From("guide_source").Where(squirrel.Eq{"id": id}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + err := db.SQL.Get(&guideSource, sql, args...) + return &guideSource, err +} + +// DeleteGuideSource marks a guideSource with the given ID as deleted. +func (db *GuideSourceDB) DeleteGuideSource(guideSourceID int) error { + _, err := db.SQL.Exec(`DELETE FROM guide_source WHERE id = $1`, guideSourceID) + return err +} + +// UpdateGuideSource updates a guideSource. +func (db *GuideSourceDB) UpdateGuideSource(guideSourceID int, guideSourceStruct GuideSource) (*GuideSource, error) { + guideSourceStruct.ID = guideSourceID + + _, err := db.SQL.NamedQuery(` + UPDATE guide_source + SET name = :name, provider = :provider, username = :username, password = :password, + xmltv_url = :xmltv_url, update_frequency = :update_frequency + WHERE id = :id`, guideSourceStruct) + if err != nil { + return nil, err + } + + return &guideSourceStruct, nil +} + +// UpdateProviderData updates provider_data. +func (db *GuideSourceDB) UpdateProviderData(guideSourceID int, providerData interface{}) error { + _, err := db.SQL.Exec(`UPDATE guide_source SET provider_data = ? WHERE id = ?`, providerData, guideSourceID) + return err +} + +// GetAllGuideSources returns all video sources in the database. +func (db *GuideSourceDB) GetAllGuideSources(includeChannels bool) ([]GuideSource, error) { + sources := make([]GuideSource, 0) + err := db.SQL.Select(&sources, baseGuideSourceQuery) + if err != nil { + return nil, err + } + if includeChannels { + newSources := make([]GuideSource, 0) + for _, source := range sources { + allChannels, channelsErr := db.Collection.GuideSourceChannel.GetChannelsForGuideSource(source.ID) + if channelsErr != nil { + return nil, channelsErr + } + source.Channels = append(source.Channels, allChannels...) + newSources = append(newSources, source) + } + return newSources, nil + } + return sources, nil +} + +// GetGuideSourcesForLineup returns a slice of GuideSource for the given lineup ID. +func (db *GuideSourceDB) GetGuideSourcesForLineup(lineupID int) ([]GuideSource, error) { + providers := make([]GuideSource, 0) + err := db.SQL.Select(&providers, `SELECT * FROM guide_source WHERE id IN (SELECT guide_id FROM guide_source_channel WHERE id IN (SELECT id FROM lineup_channel WHERE lineup_id = $1))`, lineupID) + return providers, err +} diff --git a/internal/models/guide_source_channel.go b/internal/models/guide_source_channel.go new file mode 100644 index 0000000..c654e90 --- /dev/null +++ b/internal/models/guide_source_channel.go @@ -0,0 +1,155 @@ +package models + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/tellytv/telly/internal/guideproviders" +) + +// GuideSourceChannelDB is a struct containing initialized the SQL connection as well as the APICollection. +type GuideSourceChannelDB struct { + SQL *sqlx.DB + Collection *APICollection +} + +func newGuideSourceChannelDB( + SQL *sqlx.DB, + Collection *APICollection, +) *GuideSourceChannelDB { + db := &GuideSourceChannelDB{ + SQL: SQL, + Collection: Collection, + } + return db +} + +func (db *GuideSourceChannelDB) tableName() string { + return "guide_source_channel" +} + +// GuideSourceChannel is a single channel in a guide providers lineup. +type GuideSourceChannel struct { + ID int `db:"id"` + GuideID int `db:"guide_id"` + XMLTVID string `db:"xmltv_id"` + ProviderData json.RawMessage `db:"provider_data"` + Data json.RawMessage `db:"data"` + ImportedAt *time.Time `db:"imported_at"` + + GuideSource *GuideSource + GuideSourceName string + GuideProviderChannel *guideproviders.Channel `json:"-"` +} + +// GuideSourceChannelAPI contains all methods for the User struct +type GuideSourceChannelAPI interface { + InsertGuideSourceChannel(guideID int, channel guideproviders.Channel, providerData interface{}) (*GuideSourceChannel, error) + DeleteGuideSourceChannel(channelID int) (*GuideSourceChannel, error) + UpdateGuideSourceChannel(XMLTVID string, providerData interface{}) error + GetGuideSourceChannelByID(id int, expanded bool) (*GuideSourceChannel, error) + GetChannelsForGuideSource(guideSourceID int) ([]GuideSourceChannel, error) +} + +// nolint +const baseGuideSourceChannelQuery string = ` +SELECT + G.id, + G.guide_id, + G.xmltv_id, + G.provider_data, + G.data, + G.imported_at + FROM guide_source_channel G` + +// InsertGuideSourceChannel inserts a new GuideSourceChannel into the database. +func (db *GuideSourceChannelDB) InsertGuideSourceChannel(guideID int, channel guideproviders.Channel, providerData interface{}) (*GuideSourceChannel, error) { + channelJSON, channelJSONErr := json.Marshal(channel) + if channelJSONErr != nil { + return nil, fmt.Errorf("error when marshalling guideproviders.Channel for use in guide_source_channel insert: %s", channelJSONErr) + } + + providerDataJSON, providerDataJSONErr := json.Marshal(providerData) + if providerDataJSONErr != nil { + return nil, fmt.Errorf("error when marshalling providerData for use in guide_source_programme insert: %s", providerDataJSONErr) + } + + insertingChannel := GuideSourceChannel{ + GuideID: guideID, + XMLTVID: channel.ID, + Data: channelJSON, + ProviderData: providerDataJSON, + } + + res, err := db.SQL.NamedExec(` + INSERT OR REPLACE INTO guide_source_channel (guide_id, xmltv_id, data, provider_data) + VALUES (:guide_id, :xmltv_id, :data, :provider_data)`, insertingChannel) + if err != nil { + return nil, err + } + rowID, rowIDErr := res.LastInsertId() + if rowIDErr != nil { + return nil, rowIDErr + } + outputChannel := GuideSourceChannel{} + if getErr := db.SQL.Get(&outputChannel, "SELECT * FROM guide_source_channel WHERE id = $1", rowID); getErr != nil { + return nil, getErr + } + if unmarshalErr := json.Unmarshal(outputChannel.Data, &outputChannel.GuideProviderChannel); unmarshalErr != nil { + return nil, unmarshalErr + } + return &outputChannel, err +} + +// GetGuideSourceChannelByID returns a single GuideSourceChannel for the given ID. +func (db *GuideSourceChannelDB) GetGuideSourceChannelByID(id int, expanded bool) (*GuideSourceChannel, error) { + var channel GuideSourceChannel + sql, args, sqlGenErr := squirrel.Select("*").From("guide_source_channel").Where(squirrel.Eq{"id": id}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + err := db.SQL.Get(&channel, sql, args...) + if err != nil { + return nil, err + } + if expanded { + guide, guideErr := db.Collection.GuideSource.GetGuideSourceByID(channel.GuideID) + if guideErr != nil { + return nil, guideErr + } + channel.GuideSource = guide + + if unmarshalErr := json.Unmarshal(channel.Data, &channel.GuideProviderChannel); unmarshalErr != nil { + return nil, unmarshalErr + } + + } + return &channel, err +} + +// DeleteGuideSourceChannel marks a channel with the given ID as deleted. +func (db *GuideSourceChannelDB) DeleteGuideSourceChannel(channelID int) (*GuideSourceChannel, error) { + channel := GuideSourceChannel{} + err := db.SQL.Get(&channel, `DELETE FROM guide_source_channel WHERE id = $1`, channelID) + return &channel, err +} + +// UpdateGuideSourceChannel updates a channel. +func (db *GuideSourceChannelDB) UpdateGuideSourceChannel(XMLTVID string, providerData interface{}) error { + _, err := db.SQL.Exec(`UPDATE guide_source_channel SET provider_data = ? WHERE xmltv_id = ?`, providerData, XMLTVID) + return err +} + +// GetChannelsForGuideSource returns a slice of GuideSourceChannels for the given video source ID. +func (db *GuideSourceChannelDB) GetChannelsForGuideSource(guideSourceID int) ([]GuideSourceChannel, error) { + channels := make([]GuideSourceChannel, 0) + sql, args, sqlGenErr := squirrel.Select("*").From("guide_source_channel").Where(squirrel.Eq{"guide_id": guideSourceID}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + err := db.SQL.Select(&channels, sql, args...) + return channels, err +} diff --git a/internal/models/guide_source_programme.go b/internal/models/guide_source_programme.go new file mode 100644 index 0000000..4812e60 --- /dev/null +++ b/internal/models/guide_source_programme.go @@ -0,0 +1,202 @@ +package models + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/tellytv/telly/internal/xmltv" +) + +// GuideSourceProgrammeDB is a struct containing initialized the SQL connection as well as the APICollection. +// Why is it spelled like this instead of "program"? Matches XMLTV spec which this code is based on. +type GuideSourceProgrammeDB struct { + SQL *sqlx.DB + Collection *APICollection +} + +func newGuideSourceProgrammeDB( + SQL *sqlx.DB, + Collection *APICollection, +) *GuideSourceProgrammeDB { + db := &GuideSourceProgrammeDB{ + SQL: SQL, + Collection: Collection, + } + return db +} + +func (db *GuideSourceProgrammeDB) tableName() string { + return "guide_source_programme" +} + +// GuideSourceProgramme is a single programme available in a guide providers lineup. +type GuideSourceProgramme struct { + GuideID int `db:"guide_id"` + Channel string `db:"channel"` + ProviderData json.RawMessage `db:"provider_data"` + StartTime *time.Time `db:"start"` + EndTime *time.Time `db:"end"` + Date *time.Time `db:"date,omitempty"` + Data json.RawMessage `db:"data"` + ImportedAt *time.Time `db:"imported_at"` + + XMLTV *xmltv.Programme `json:"-"` +} + +// GuideSourceProgrammeAPI contains all methods for the User struct +type GuideSourceProgrammeAPI interface { + InsertGuideSourceProgramme(guideID int, programme xmltv.Programme, providerData interface{}) (*GuideSourceProgramme, error) + DeleteGuideSourceProgrammesForChannel(channelID string) error + UpdateGuideSourceProgramme(programmeID string, providerData interface{}) error + GetGuideSourceProgrammeByID(id int) (*GuideSourceProgramme, error) + GetProgrammesForActiveChannels() ([]GuideSourceProgramme, error) + GetProgrammesForChannel(channelID string) ([]GuideSourceProgramme, error) + GetProgrammesForGuideID(guideSourceID int) ([]GuideSourceProgramme, error) +} + +// nolint +const baseGuideSourceProgrammeQuery string = ` +SELECT + G.guide_id, + G.channel, + G.provider_data, + G.start, + G.end, + G.date, + G.data, + G.imported_at + FROM guide_source_programme G` + +// InsertGuideSourceProgramme inserts a new GuideSourceProgramme into the database. +func (db *GuideSourceProgrammeDB) InsertGuideSourceProgramme(guideID int, programme xmltv.Programme, providerData interface{}) (*GuideSourceProgramme, error) { + programmeJSON, programmeMarshalErr := json.Marshal(programme) + if programmeMarshalErr != nil { + return nil, fmt.Errorf("error when marshalling xmltv.Programme for use in guide_source_programme insert: %s", programmeMarshalErr) + } + + providerDataJSON, providerDataJSONErr := json.Marshal(providerData) + if providerDataJSONErr != nil { + return nil, fmt.Errorf("error when marshalling providerData for use in guide_source_programme insert: %s", providerDataJSONErr) + } + + date := time.Time(programme.Date) + insertingProgramme := GuideSourceProgramme{ + GuideID: guideID, + Channel: programme.Channel, + ProviderData: providerDataJSON, + StartTime: &programme.Start.Time, + EndTime: &programme.Stop.Time, + Date: &date, + Data: programmeJSON, + } + + res, err := db.SQL.NamedExec(` + INSERT OR REPLACE INTO guide_source_programme (guide_id, channel, provider_data, start, end, date, data) + VALUES (:guide_id, :channel, :provider_data, :start, :end, :date, :data)`, insertingProgramme) + if err != nil { + return nil, fmt.Errorf("error when inserting guide_source_programme row: %s", err) + } + rowID, rowIDErr := res.LastInsertId() + if rowIDErr != nil { + return nil, fmt.Errorf("error when getting last inserted row id during guide_source_programme insert: %s", rowIDErr) + } + outputProgramme := GuideSourceProgramme{} + if getErr := db.SQL.Get(&outputProgramme, "SELECT * FROM guide_source_programme WHERE rowid = $1", rowID); getErr != nil { + return nil, fmt.Errorf("error when selecting newly inserted row during guide_source_programme insert: %s", getErr) + } + if unmarshalErr := json.Unmarshal(outputProgramme.Data, &outputProgramme.XMLTV); unmarshalErr != nil { + return nil, fmt.Errorf("error when unmarshalling json.RawMessage to xmltv.Programme during guide_source_programme insert: %s", unmarshalErr) + } + return &outputProgramme, nil +} + +// GetGuideSourceProgrammeByID returns a single GuideSourceProgramme for the given ID. +func (db *GuideSourceProgrammeDB) GetGuideSourceProgrammeByID(id int) (*GuideSourceProgramme, error) { + var programme GuideSourceProgramme + sql, args, sqlGenErr := squirrel.Select("*").From("guide_source_programme").Where(squirrel.Eq{"id": id}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + err := db.SQL.Get(&programme, sql, args...) + if err != nil { + return nil, err + } + return &programme, err +} + +// DeleteGuideSourceProgrammesForChannel marks a programme with the given ID as deleted. +func (db *GuideSourceProgrammeDB) DeleteGuideSourceProgrammesForChannel(channelID string) error { + _, err := db.SQL.Exec(`DELETE FROM guide_source_programme WHERE channel IN (SELECT xmltv_id FROM guide_source_channel WHERE id IN (SELECT guide_channel_id FROM lineup_channel WHERE id = ?))`, channelID) + return err +} + +// UpdateGuideSourceProgramme updates a programme. +func (db *GuideSourceProgrammeDB) UpdateGuideSourceProgramme(programmeID string, providerData interface{}) error { + _, err := db.SQL.Exec(`UPDATE guide_source_programme SET provider_data = ? WHERE id = ?`, providerData, programmeID) + return err +} + +// GetProgrammesForActiveChannels returns a slice of GuideSourceProgrammes for actively assigned channels. +func (db *GuideSourceProgrammeDB) GetProgrammesForActiveChannels() ([]GuideSourceProgramme, error) { + programmes := make([]GuideSourceProgramme, 0) + sql, args, sqlGenErr := squirrel.Select("*").From("guide_source_programme").Where("channel IN (SELECT xmltv_id FROM guide_source_channel WHERE id IN (SELECT guide_channel_id FROM lineup_channel)) AND (start >= datetime('now') OR end >= datetime('now')) AND start <= datetime('now', '+7 days')").OrderBy("start ASC").ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + err := db.SQL.Select(&programmes, sql, args...) + if err != nil { + return nil, err + } + for idx, programme := range programmes { + if unmarshalErr := json.Unmarshal(programme.Data, &programme.XMLTV); unmarshalErr != nil { + return nil, unmarshalErr + } + programmes[idx] = programme + } + return programmes, err +} + +// GetProgrammesForChannel returns a slice of GuideSourceProgrammes for the given XMLTV channel ID. +func (db *GuideSourceProgrammeDB) GetProgrammesForChannel(channelID string) ([]GuideSourceProgramme, error) { + programmes := make([]GuideSourceProgramme, 0) + sql, args, sqlGenErr := squirrel.Select("*").From("guide_source_programme").Where(fmt.Sprintf("channel = '%s' AND (start >= datetime('now') OR end >= datetime('now')) AND start <= datetime('now', '+7 days')", channelID)).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + + err := db.SQL.Select(&programmes, sql, args...) + if err != nil { + return nil, err + } + + for idx, programme := range programmes { + if unmarshalErr := json.Unmarshal(programme.Data, &programme.XMLTV); unmarshalErr != nil { + return nil, unmarshalErr + } + programmes[idx] = programme + } + return programmes, err +} + +// GetProgrammesForGuideID returns a slice of GuideSourceProgrammes for the given guide ID. +func (db *GuideSourceProgrammeDB) GetProgrammesForGuideID(guideSourceID int) ([]GuideSourceProgramme, error) { + programmes := make([]GuideSourceProgramme, 0) + sql, args, sqlGenErr := squirrel.Select("*").From("guide_source_programme").Where(squirrel.And{squirrel.Eq{"guide_id": guideSourceID}, squirrel.GtOrEq{"start": "datetime('now')"}, squirrel.LtOrEq{"start": "datetime('now', '+6 hours')"}}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + err := db.SQL.Select(&programmes, sql, args...) + if err != nil { + return nil, err + } + for idx, programme := range programmes { + if unmarshalErr := json.Unmarshal(programme.Data, &programme.XMLTV); unmarshalErr != nil { + return nil, unmarshalErr + } + programmes[idx] = programme + } + return programmes, err +} diff --git a/internal/models/lineup.go b/internal/models/lineup.go new file mode 100644 index 0000000..d5d8f79 --- /dev/null +++ b/internal/models/lineup.go @@ -0,0 +1,249 @@ +package models + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/Masterminds/squirrel" + upnp "github.com/NebulousLabs/go-upnp/goupnp" + "github.com/gofrs/uuid" + "github.com/jmoiron/sqlx" +) + +// LineupDB is a struct containing initialized the SQL connection as well as the APICollection. +type LineupDB struct { + SQL *sqlx.DB + Collection *APICollection +} + +func newLineupDB( + SQL *sqlx.DB, + Collection *APICollection, +) *LineupDB { + db := &LineupDB{ + SQL: SQL, + Collection: Collection, + } + return db +} + +func (db *LineupDB) tableName() string { + return "lineup" +} + +// DiscoveryData contains data about telly to expose in the HDHomeRun format for Plex detection. +type DiscoveryData struct { + FriendlyName string + Manufacturer string + ModelName string + ModelNumber string + FirmwareName string + TunerCount int + FirmwareVersion string + DeviceID string + DeviceAuth string + BaseURL string + LineupURL string + DeviceUUID string +} + +// UPNP returns the UPNP representation of the DiscoveryData. +func (d *DiscoveryData) UPNP() upnp.RootDevice { + return upnp.RootDevice{ + SpecVersion: upnp.SpecVersion{ + Major: 1, Minor: 0, + }, + URLBaseStr: d.BaseURL, + Device: upnp.Device{ + DeviceType: "urn:schemas-upnp-org:device:MediaServer:1", + FriendlyName: fmt.Sprintf("HDHomerun (%s)", d.FriendlyName), + Manufacturer: d.Manufacturer, + ManufacturerURL: upnp.URLField{ + Str: "http://www.silicondust.com/", + }, + ModelName: d.ModelName, + ModelNumber: d.ModelNumber, + ModelDescription: fmt.Sprintf("%s %s", d.ModelNumber, d.ModelName), + ModelURL: upnp.URLField{ + Str: "http://www.silicondust.com/", + }, + SerialNumber: d.DeviceID, + UDN: fmt.Sprintf("uuid:%s", strings.ToUpper(d.DeviceUUID)), + PresentationURL: upnp.URLField{ + Str: "/", + }, + }, + } +} + +// Lineup describes a collection of channels exposed to the world with associated configuration. +type Lineup struct { + ID int `db:"id"` + Name string `db:"name"` + SSDP bool `db:"ssdp"` + ListenAddress string `db:"listen_address"` + DiscoveryAddress string `db:"discovery_address"` + Port int `db:"port"` + Tuners int `db:"tuners"` + Manufacturer string `db:"manufacturer"` + ModelName string `db:"model_name"` + ModelNumber string `db:"model_number"` + FirmwareName string `db:"firmware_name"` + FirmwareVersion string `db:"firmware_version"` + DeviceID string `db:"device_id"` + DeviceAuth string `db:"device_auth"` + DeviceUUID string `db:"device_uuid"` + StreamTransport string `db:"stream_transport"` + CreatedAt *time.Time `db:"created_at"` + + Channels []LineupChannel +} + +// GetDiscoveryData returns DiscoveryData for the Lineup. +func (s *Lineup) GetDiscoveryData() DiscoveryData { + baseAddr := fmt.Sprintf("http://%s:%d", s.DiscoveryAddress, s.Port) + return DiscoveryData{ + FriendlyName: s.Name, + Manufacturer: s.Manufacturer, + ModelName: s.ModelName, + ModelNumber: s.ModelNumber, + FirmwareName: s.FirmwareName, + TunerCount: s.Tuners, + FirmwareVersion: s.FirmwareVersion, + DeviceID: s.DeviceID, + DeviceAuth: s.DeviceAuth, + BaseURL: baseAddr, + LineupURL: fmt.Sprintf("%s/lineup.json", baseAddr), + DeviceUUID: s.DeviceUUID, + } +} + +// LineupAPI contains all methods for the User struct +type LineupAPI interface { + InsertLineup(lineupStruct Lineup) (*Lineup, error) + DeleteLineup(lineupID int) (*Lineup, error) + UpdateLineup(lineupID int, description string) (*Lineup, error) + GetLineupByID(id int, withChannels bool) (*Lineup, error) + GetEnabledLineups(withChannels bool) ([]Lineup, error) +} + +const baseLineupQuery string = ` +SELECT + L.id, + L.name, + L.ssdp, + L.listen_address, + L.discovery_address, + L.port, + L.tuners, + L.manufacturer, + L.model_name, + L.model_number, + L.firmware_name, + L.firmware_version, + L.device_id, + L.device_auth, + L.device_uuid, + L.stream_transport, + L.created_at + FROM lineup L` + +// InsertLineup inserts a new Lineup into the database. +func (db *LineupDB) InsertLineup(lineupStruct Lineup) (*Lineup, error) { + lineup := Lineup{} + if lineupStruct.Manufacturer == "" { + lineupStruct.Manufacturer = "Silicondust" + } + if lineupStruct.ModelName == "" { + lineupStruct.ModelName = "HDHomeRun EXTEND" + } + if lineupStruct.ModelNumber == "" { + lineupStruct.ModelNumber = "HDTC-2US" + } + if lineupStruct.FirmwareName == "" { + lineupStruct.FirmwareName = "hdhomeruntc_atsc" + } + if lineupStruct.FirmwareVersion == "" { + lineupStruct.FirmwareVersion = "20150826" + } + if lineupStruct.DeviceID == "" { + bytes := make([]byte, 20) + if _, err := rand.Read(bytes); err != nil { + return &lineup, fmt.Errorf("error when generating random device id: %s", err) + } + lineupStruct.DeviceID = strings.ToUpper(hex.EncodeToString(bytes)[:8]) + } + if lineupStruct.DeviceAuth == "" { + lineupStruct.DeviceAuth = "telly" + } + if lineupStruct.DeviceUUID == "" { + lineupStruct.DeviceUUID = uuid.Must(uuid.NewV4()).String() + } + if lineupStruct.StreamTransport == "" { + lineupStruct.StreamTransport = "http" + } + res, err := db.SQL.NamedExec(` + INSERT INTO lineup (name, ssdp, listen_address, discovery_address, port, tuners, manufacturer, model_name, model_number, firmware_name, firmware_version, device_id, device_auth, device_uuid, stream_transport) + VALUES (:name, :ssdp, :listen_address, :discovery_address, :port, :tuners, :manufacturer, :model_name, :model_number, :firmware_name, :firmware_version, :device_id, :device_auth, :device_uuid, :stream_transport)`, lineupStruct) + if err != nil { + return &lineup, err + } + rowID, rowIDErr := res.LastInsertId() + if rowIDErr != nil { + return &lineup, rowIDErr + } + err = db.SQL.Get(&lineup, "SELECT * FROM lineup WHERE id = $1", rowID) + return &lineup, err +} + +// GetLineupByID returns a single Lineup for the given ID. +func (db *LineupDB) GetLineupByID(id int, withChannels bool) (*Lineup, error) { + var lineup Lineup + sql, args, sqlGenErr := squirrel.Select("*").From("lineup").Where(squirrel.Eq{"id": id}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + err := db.SQL.Get(&lineup, sql, args...) + if withChannels { + channels, channelsErr := db.Collection.LineupChannel.GetChannelsForLineup(lineup.ID, true) + if channelsErr != nil { + return nil, channelsErr + } + lineup.Channels = channels + } + return &lineup, err +} + +// DeleteLineup marks a lineup with the given ID as deleted. +func (db *LineupDB) DeleteLineup(lineupID int) (*Lineup, error) { + lineup := Lineup{} + err := db.SQL.Get(&lineup, `DELETE FROM lineup WHERE id = $1`, lineupID) + return &lineup, err +} + +// UpdateLineup updates a lineup. +func (db *LineupDB) UpdateLineup(lineupID int, description string) (*Lineup, error) { + lineup := Lineup{} + err := db.SQL.Get(&lineup, `UPDATE lineup SET description = $2 WHERE id = $1 RETURNING *`, lineupID, description) + return &lineup, err +} + +// GetEnabledLineups returns all enabled lineups in the database. +func (db *LineupDB) GetEnabledLineups(withChannels bool) ([]Lineup, error) { + lineups := make([]Lineup, 0) + err := db.SQL.Select(&lineups, baseLineupQuery) + if withChannels { + for idx, lineup := range lineups { + channels, channelsErr := db.Collection.LineupChannel.GetChannelsForLineup(lineup.ID, true) + if channelsErr != nil { + return nil, channelsErr + } + lineup.Channels = channels + lineups[idx] = lineup + } + } + return lineups, err +} diff --git a/internal/models/lineup_channel.go b/internal/models/lineup_channel.go new file mode 100644 index 0000000..4af4393 --- /dev/null +++ b/internal/models/lineup_channel.go @@ -0,0 +1,281 @@ +package models + +import ( + "encoding/xml" + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +// LineupChannelDB is a struct containing initialized the SQL connection as well as the APICollection. +type LineupChannelDB struct { + SQL *sqlx.DB + Collection *APICollection +} + +func newLineupChannelDB( + SQL *sqlx.DB, + Collection *APICollection, +) *LineupChannelDB { + db := &LineupChannelDB{ + SQL: SQL, + Collection: Collection, + } + return db +} + +func (db *LineupChannelDB) tableName() string { + return "lineup_channel" +} + +// HDHomeRunLineupItem is a HDHomeRun specification compatible representation of a Track available in the lineup. +type HDHomeRunLineupItem struct { + XMLName xml.Name `xml:"Program" json:"-"` + AudioCodec string `xml:",omitempty" json:",omitempty"` + DRM ConvertibleBoolean `xml:",omitempty" json:",omitempty"` + Favorite ConvertibleBoolean `xml:",omitempty" json:",omitempty"` + GuideName string `xml:",omitempty" json:",omitempty"` + GuideNumber string `xml:",omitempty" json:",omitempty"` + HD ConvertibleBoolean `xml:",omitempty" json:",omitempty"` + URL string `xml:",omitempty" json:",omitempty"` + VideoCodec string `xml:",omitempty" json:",omitempty"` +} + +// LineupChannel is a single channel available in a Lineup. +type LineupChannel struct { + ID int `db:"id"` + LineupID int `db:"lineup_id"` + Title string `db:"title"` + ChannelNumber string `db:"channel_number"` + VideoTrackID int `db:"video_track_id"` + GuideChannelID int `db:"guide_channel_id"` + HighDefinition bool `db:"hd" json:"HD"` + Favorite bool `db:"favorite"` + CreatedAt *time.Time `db:"created_at"` + + VideoTrack *VideoSourceTrack `json:",omitempty"` + GuideChannel *GuideSourceChannel `json:",omitempty"` + HDHR *HDHomeRunLineupItem `json:",omitempty"` + + lineup *Lineup +} + +func (l *LineupChannel) String() string { + return fmt.Sprintf("channel: %s (ch#: %s, video source name: %s, video source provider type: %s)", l.Title, l.ChannelNumber, l.VideoTrack.VideoSource.Name, l.VideoTrack.VideoSource.Provider) +} + +// Fill will insert Lineup, GuideChannel and VideoTrack into the LineupChannel. +func (l *LineupChannel) Fill(api *APICollection) { + if l.lineup == nil { + // Need to get the address and port number to properly fill + lineup, lineupErr := api.Lineup.GetLineupByID(l.LineupID, false) + if lineupErr != nil { + log.WithError(lineupErr).Panicln("error getting lineup during LineupChannel fill") + return + } + + l.lineup = lineup + } + + gChannel, gChannelErr := api.GuideSourceChannel.GetGuideSourceChannelByID(l.GuideChannelID, true) + if gChannelErr != nil { + log.WithError(gChannelErr).Panicln("error getting channel during LineupChannel fill") + return + } + l.GuideChannel = gChannel + vTrack, vTrackErr := api.VideoSourceTrack.GetVideoSourceTrackByID(l.VideoTrackID, true) + if vTrackErr != nil { + log.WithError(vTrackErr).Panicln("error getting track during LineupChannel fill") + return + } + l.VideoTrack = vTrack + l.HDHR = l.HDHomeRunLineupItem() +} + +// HDHomeRunLineupItem returns a HDHomeRunLineupItem for the LineupChannel. +func (l *LineupChannel) HDHomeRunLineupItem() *HDHomeRunLineupItem { + return &HDHomeRunLineupItem{ + DRM: ConvertibleBoolean(false), + GuideName: l.Title, + GuideNumber: l.ChannelNumber, + Favorite: ConvertibleBoolean(l.Favorite), + HD: ConvertibleBoolean(l.HighDefinition), + URL: fmt.Sprintf("http://%s:%d/auto/v%s", l.lineup.DiscoveryAddress, l.lineup.Port, l.ChannelNumber), + } +} + +// LineupChannelAPI contains all methods for the User struct +type LineupChannelAPI interface { + InsertLineupChannel(channelStruct LineupChannel) (*LineupChannel, error) + UpsertLineupChannel(channelStruct LineupChannel) (*LineupChannel, error) + DeleteLineupChannel(channelID string) error + UpdateLineupChannel(channelStruct LineupChannel) (*LineupChannel, error) + GetLineupChannelByID(lineupID int, channelNumber string) (*LineupChannel, error) + GetChannelsForLineup(lineupID int, expanded bool) ([]LineupChannel, error) + GetEnabledChannelsForGuideProvider(providerID int) ([]LineupChannel, error) + GetEnabledChannelsForVideoProvider(providerID int) ([]LineupChannel, error) +} + +// nolint +const baseLineupChannelQuery string = ` +SELECT + C.id, + C.lineup_id, + C.title, + C.channel_number, + C.video_track_id, + C.guide_channel_id, + C.favorite, + C.hd, + C.created_at + FROM lineup_channel C` + +// InsertLineupChannel inserts a new LineupChannel into the database. +func (db *LineupChannelDB) InsertLineupChannel(channelStruct LineupChannel) (*LineupChannel, error) { + channel := LineupChannel{} + res, err := db.SQL.NamedExec(` + INSERT INTO lineup_channel (lineup_id, title, channel_number, video_track_id, guide_channel_id, favorite, hd) + VALUES (:lineup_id, :title, :channel_number, :video_track_id, :guide_channel_id, :favorite, :hd)`, channelStruct) + if err != nil { + return &channel, err + } + rowID, rowIDErr := res.LastInsertId() + if rowIDErr != nil { + return &channel, rowIDErr + } + err = db.SQL.Get(&channel, "SELECT * FROM lineup_channel WHERE id = $1", rowID) + return &channel, err +} + +// UpsertLineupChannel upserts a LineupChannel in the database. +func (db *LineupChannelDB) UpsertLineupChannel(channelStruct LineupChannel) (*LineupChannel, error) { + if channelStruct.ID != 0 { + return db.UpdateLineupChannel(channelStruct) + } + return db.InsertLineupChannel(channelStruct) +} + +// GetLineupChannelByID returns a single LineupChannel for the given ID. +func (db *LineupChannelDB) GetLineupChannelByID(lineupID int, channelNumber string) (*LineupChannel, error) { + var channel LineupChannel + + sql, args, sqlGenErr := squirrel.Select("*").From("lineup_channel").Where(squirrel.Eq{"lineup_id": lineupID, "channel_number": channelNumber}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + + err := db.SQL.Get(&channel, sql, args...) + if err != nil { + return nil, err + } + + channel.Fill(db.Collection) + + return &channel, err +} + +// DeleteLineupChannel marks a channel with the given ID as deleted. +func (db *LineupChannelDB) DeleteLineupChannel(channelID string) error { + _, err := db.SQL.Exec(`DELETE FROM lineup_channel WHERE id = ?`, channelID) + return err +} + +// UpdateLineupChannel updates a channel. +func (db *LineupChannelDB) UpdateLineupChannel(channelStruct LineupChannel) (*LineupChannel, error) { + channel := LineupChannel{} + _, err := db.SQL.NamedExec(`UPDATE lineup_channel SET lineup_id = :lineup_id, title = :title, channel_number = :channel_number, video_track_id = :video_track_id, guide_channel_id = :guide_channel_id, favorite = :favorite, hd =:hd WHERE id = :id`, channelStruct) + if err != nil { + return &channel, err + } + err = db.SQL.Get(&channel, "SELECT * FROM lineup_channel WHERE id = $1", channelStruct.ID) + return &channel, err +} + +// GetChannelsForLineup returns a slice of LineupChannels for the given lineup ID. +func (db *LineupChannelDB) GetChannelsForLineup(lineupID int, expanded bool) ([]LineupChannel, error) { + channels := make([]LineupChannel, 0) + sql, args, sqlGenErr := squirrel.Select("*").From("lineup_channel").Where(squirrel.Eq{"lineup_id": lineupID}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + err := db.SQL.Select(&channels, sql, args...) + if err != nil { + return nil, err + } + if expanded { + // Need to get the address and port number to properly fill + lineup, lineupErr := db.Collection.Lineup.GetLineupByID(lineupID, false) + if lineupErr != nil { + return nil, lineupErr + } + for idx, channel := range channels { + channel.lineup = lineup + channel.Fill(db.Collection) + channels[idx] = channel + } + } + return channels, nil +} + +// GetEnabledChannelsForGuideProvider returns a slice of LineupChannels for the given guide provider ID. +func (db *LineupChannelDB) GetEnabledChannelsForGuideProvider(providerID int) ([]LineupChannel, error) { + channels := make([]LineupChannel, 0) + + inQuery := squirrel.Select("id").From("guide_source_channel").Where(squirrel.Eq{"guide_id": providerID}) + + // Using DebugSqlizer is unsafe but Squirrel doesn't support WHERE IN subqueries. + sql, args, sqlGenErr := squirrel.Select("*").From("lineup_channel").Where(fmt.Sprintf("guide_channel_id IN (%s)", squirrel.DebugSqlizer(inQuery))).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + + err := db.SQL.Select(&channels, sql, args...) + if err != nil { + return nil, err + } + + if len(channels) > 0 { + // Need to get the address and port number to properly fill + lineup, lineupErr := db.Collection.Lineup.GetLineupByID(channels[0].LineupID, false) + if lineupErr != nil { + return nil, lineupErr + } + for idx, channel := range channels { + channel.lineup = lineup + channel.Fill(db.Collection) + channels[idx] = channel + } + } + return channels, err +} + +// GetEnabledChannelsForVideoProvider returns a slice of LineupChannels for the given video provider ID. +func (db *LineupChannelDB) GetEnabledChannelsForVideoProvider(providerID int) ([]LineupChannel, error) { + channels := make([]LineupChannel, 0) + + inQuery := squirrel.Select("id").From("video_source_track").Where(squirrel.Eq{"video_source_id": providerID}) + + // Using DebugSqlizer is unsafe but Squirrel doesn't support WHERE IN subqueries. + sql, args, sqlGenErr := squirrel.Select("*").From("lineup_channel").Where(fmt.Sprintf("video_track_id IN (%s)", squirrel.DebugSqlizer(inQuery))).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + + err := db.SQL.Select(&channels, sql, args...) + if err != nil { + return nil, err + } + // Need to get the address and port number to properly fill + lineup, lineupErr := db.Collection.Lineup.GetLineupByID(channels[0].LineupID, false) + if lineupErr != nil { + return nil, lineupErr + } + for idx, channel := range channels { + channel.lineup = lineup + channel.Fill(db.Collection) + channels[idx] = channel + } + return channels, err +} diff --git a/internal/models/main.go b/internal/models/main.go new file mode 100644 index 0000000..2126352 --- /dev/null +++ b/internal/models/main.go @@ -0,0 +1,37 @@ +package models + +import ( + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/sirupsen/logrus" +) + +var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) // nolint + +// APICollection is a struct containing all models. +type APICollection struct { + GuideSource GuideSourceAPI + GuideSourceChannel GuideSourceChannelAPI + GuideSourceProgramme GuideSourceProgrammeAPI + Lineup LineupAPI + LineupChannel LineupChannelAPI + VideoSource VideoSourceAPI + VideoSourceTrack VideoSourceTrackAPI +} + +var log = &logrus.Logger{} + +// NewAPICollection returns an initialized APICollection struct. +func NewAPICollection(db *sqlx.DB, logger *logrus.Logger) *APICollection { + log = logger + api := &APICollection{} + + api.GuideSource = newGuideSourceDB(db, api) + api.GuideSourceChannel = newGuideSourceChannelDB(db, api) + api.GuideSourceProgramme = newGuideSourceProgrammeDB(db, api) + api.Lineup = newLineupDB(db, api) + api.LineupChannel = newLineupChannelDB(db, api) + api.VideoSource = newVideoSourceDB(db, api) + api.VideoSourceTrack = newVideoSourceTrackDB(db, api) + return api +} diff --git a/internal/models/types.go b/internal/models/types.go new file mode 100644 index 0000000..9cb21d3 --- /dev/null +++ b/internal/models/types.go @@ -0,0 +1,59 @@ +package models + +import ( + "encoding/json" + "encoding/xml" + "fmt" +) + +// ConvertibleBoolean is a helper type to allow JSON documents using 0/1 or "true" and "false" be converted to bool. +type ConvertibleBoolean bool + +// MarshalJSON returns a 0 or 1 depending on bool state. +func (bit ConvertibleBoolean) MarshalJSON() ([]byte, error) { + var bitSetVar int8 + if bit { + bitSetVar = 1 + } + + return json.Marshal(bitSetVar) +} + +// UnmarshalJSON converts a 0, 1, true or false into a bool +func (bit *ConvertibleBoolean) UnmarshalJSON(data []byte) error { + asString := string(data) + if asString == "1" || asString == "true" { + *bit = true + } else if asString == "0" || asString == "false" { + *bit = false + } else { + return fmt.Errorf("Boolean unmarshal error: invalid input %s", asString) + } + return nil +} + +// MarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 +func (bit *ConvertibleBoolean) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + var bitSetVar int8 + if *bit { + bitSetVar = 1 + } + + return e.EncodeElement(bitSetVar, start) +} + +// UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 +func (bit *ConvertibleBoolean) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var asString string + if decodeErr := d.DecodeElement(&asString, &start); decodeErr != nil { + return decodeErr + } + if asString == "1" || asString == "true" { + *bit = true + } else if asString == "0" || asString == "false" { + *bit = false + } else { + return fmt.Errorf("Boolean unmarshal error: invalid input %s", asString) + } + return nil +} diff --git a/internal/models/video_source.go b/internal/models/video_source.go new file mode 100644 index 0000000..db47bca --- /dev/null +++ b/internal/models/video_source.go @@ -0,0 +1,157 @@ +package models + +import ( + "time" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/tellytv/telly/internal/videoproviders" +) + +// VideoSourceDB is a struct containing initialized the SQL connection as well as the APICollection. +type VideoSourceDB struct { + SQL *sqlx.DB + Collection *APICollection +} + +func newVideoSourceDB( + SQL *sqlx.DB, + Collection *APICollection, +) *VideoSourceDB { + db := &VideoSourceDB{ + SQL: SQL, + Collection: Collection, + } + return db +} + +func (db *VideoSourceDB) tableName() string { + return "video_source" +} + +// VideoSource is a source of video streams. +type VideoSource struct { + ID int `db:"id"` + Name string `db:"name"` + Provider string `db:"provider"` + Username string `db:"username"` + Password string `db:"password"` + BaseURL string `db:"base_url"` + M3UURL string `db:"m3u_url"` + MaxStreams int `db:"max_streams"` + UpdateFrequency string `db:"update_frequency"` + ImportedAt *time.Time `db:"imported_at"` + + Tracks []VideoSourceTrack `db:"tracks"` +} + +// ProviderConfiguration returns an initialized videoproviders.Configuration for the VideoSource. +func (v *VideoSource) ProviderConfiguration() *videoproviders.Configuration { + return &videoproviders.Configuration{ + Name: v.Name, + Provider: v.Provider, + Username: v.Username, + Password: v.Password, + BaseURL: v.BaseURL, + M3UURL: v.M3UURL, + } +} + +// VideoSourceAPI contains all methods for the User struct +type VideoSourceAPI interface { + InsertVideoSource(videoSourceStruct VideoSource) (*VideoSource, error) + DeleteVideoSource(videoSourceID int) error + UpdateVideoSource(videoSourceID int, videoSourceStruct VideoSource) (*VideoSource, error) + GetVideoSourceByID(id int) (*VideoSource, error) + GetAllVideoSources(includeTracks bool) ([]VideoSource, error) +} + +const baseVideoSourceQuery string = ` +SELECT + V.id, + V.name, + V.provider, + V.username, + V.password, + V.base_url, + V.m3u_url, + V.max_streams, + V.update_frequency, + V.imported_at + FROM video_source V` + +// InsertVideoSource inserts a new VideoSource into the database. +func (db *VideoSourceDB) InsertVideoSource(videoSourceStruct VideoSource) (*VideoSource, error) { + videoSource := VideoSource{} + + if videoSourceStruct.UpdateFrequency == "" { + videoSourceStruct.UpdateFrequency = "@daily" + } + + res, err := db.SQL.NamedExec(` + INSERT INTO video_source (name, provider, username, password, base_url, m3u_url, max_streams, update_frequency) + VALUES (:name, :provider, :username, :password, :base_url, :m3u_url, :max_streams, :update_frequency);`, videoSourceStruct) + if err != nil { + return &videoSource, err + } + rowID, rowIDErr := res.LastInsertId() + if rowIDErr != nil { + return &videoSource, rowIDErr + } + err = db.SQL.Get(&videoSource, "SELECT * FROM video_source WHERE id = $1", rowID) + return &videoSource, err +} + +// GetVideoSourceByID returns a single VideoSource for the given ID. +func (db *VideoSourceDB) GetVideoSourceByID(id int) (*VideoSource, error) { + var videoSource VideoSource + + sql, args, sqlGenErr := squirrel.Select("*").From("video_source").Where(squirrel.Eq{"id": id}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + + err := db.SQL.Get(&videoSource, sql, args...) + return &videoSource, err +} + +// DeleteVideoSource marks a videoSource with the given ID as deleted. +func (db *VideoSourceDB) DeleteVideoSource(videoSourceID int) error { + _, err := db.SQL.Exec(`DELETE FROM video_source WHERE id = $1`, videoSourceID) + return err +} + +// UpdateVideoSource updates a videoSource. +func (db *VideoSourceDB) UpdateVideoSource(videoSourceID int, videoSourceStruct VideoSource) (*VideoSource, error) { + videoSourceStruct.ID = videoSourceID + + _, err := db.SQL.NamedQuery(` + UPDATE video_source + SET name = :name, provider = :provider, username = :username, password = :password, + base_url = :base_url, m3u_url = :m3u_url, max_streams = :max_streams, update_frequency = :update_frequency + WHERE id = :id`, videoSourceStruct) + if err != nil { + return nil, err + } + + return &videoSourceStruct, nil +} + +// GetAllVideoSources returns all video sources in the database. +func (db *VideoSourceDB) GetAllVideoSources(includeTracks bool) ([]VideoSource, error) { + sources := make([]VideoSource, 0) + err := db.SQL.Select(&sources, baseVideoSourceQuery) + if includeTracks { + newSources := make([]VideoSource, 0) + for _, source := range sources { + allTracks, tracksErr := db.Collection.VideoSourceTrack.GetTracksForVideoSource(source.ID) + if tracksErr != nil { + return nil, tracksErr + } + source.Tracks = append(source.Tracks, allTracks...) + newSources = append(newSources, source) + } + return newSources, nil + } + return sources, err +} diff --git a/internal/models/video_source_track.go b/internal/models/video_source_track.go new file mode 100644 index 0000000..e20692d --- /dev/null +++ b/internal/models/video_source_track.go @@ -0,0 +1,131 @@ +package models + +import ( + "time" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +// VideoSourceTrackDB is a struct containing initialized the SQL connection as well as the APICollection. +type VideoSourceTrackDB struct { + SQL *sqlx.DB + Collection *APICollection +} + +func newVideoSourceTrackDB( + SQL *sqlx.DB, + Collection *APICollection, +) *VideoSourceTrackDB { + db := &VideoSourceTrackDB{ + SQL: SQL, + Collection: Collection, + } + return db +} + +func (db *VideoSourceTrackDB) tableName() string { + return "video_source_track" +} + +// VideoSourceTrack is a single stream available from a video source. +type VideoSourceTrack struct { + ID int `db:"id"` + VideoSourceID int `db:"video_source_id"` + Name string `db:"name"` + StreamID int `db:"stream_id"` + Logo string `db:"logo"` + Type string `db:"type"` + Category string `db:"category"` + EPGID string `db:"epg_id"` + ImportedAt *time.Time `db:"imported_at"` + + VideoSource *VideoSource + VideoSourceName string +} + +// VideoSourceTrackAPI contains all methods for the User struct +type VideoSourceTrackAPI interface { + InsertVideoSourceTrack(trackStruct VideoSourceTrack) (*VideoSourceTrack, error) + DeleteVideoSourceTrack(trackID int) (*VideoSourceTrack, error) + UpdateVideoSourceTrack(providerID, trackID int, trackStruct VideoSourceTrack) error + GetVideoSourceTrackByID(id int, expanded bool) (*VideoSourceTrack, error) + GetTracksForVideoSource(videoSourceID int) ([]VideoSourceTrack, error) +} + +// nolint +const baseVideoSourceTrackQuery string = ` +SELECT + T.id, + T.video_source_id, + T.name, + T.stream_id, + T.logo, + T.type, + T.category, + T.epg_id, + T.imported_at + FROM video_source_track T` + +// InsertVideoSourceTrack inserts a new VideoSourceTrack into the database. +func (db *VideoSourceTrackDB) InsertVideoSourceTrack(trackStruct VideoSourceTrack) (*VideoSourceTrack, error) { + track := VideoSourceTrack{} + res, err := db.SQL.NamedExec(` + INSERT OR REPLACE INTO video_source_track (video_source_id, name, stream_id, logo, type, category, epg_id) + VALUES (:video_source_id, :name, :stream_id, :logo, :type, :category, :epg_id);`, trackStruct) + if err != nil { + return &track, err + } + rowID, rowIDErr := res.LastInsertId() + if rowIDErr != nil { + return &track, rowIDErr + } + err = db.SQL.Get(&track, "SELECT * FROM video_source_track WHERE id = $1", rowID) + return &track, err +} + +// GetVideoSourceTrackByID returns a single VideoSourceTrack for the given ID. +func (db *VideoSourceTrackDB) GetVideoSourceTrackByID(id int, expanded bool) (*VideoSourceTrack, error) { + var track VideoSourceTrack + + sql, args, sqlGenErr := squirrel.Select("*").From("video_source_track").Where(squirrel.Eq{"id": id}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + + err := db.SQL.Get(&track, sql, args...) + if expanded { + video, videoErr := db.Collection.VideoSource.GetVideoSourceByID(track.VideoSourceID) + if videoErr != nil { + return nil, videoErr + } + track.VideoSource = video + } + return &track, err +} + +// DeleteVideoSourceTrack marks a track with the given ID as deleted. +func (db *VideoSourceTrackDB) DeleteVideoSourceTrack(trackID int) (*VideoSourceTrack, error) { + track := VideoSourceTrack{} + err := db.SQL.Get(&track, `DELETE FROM video_source_track WHERE id = $1`, trackID) + return &track, err +} + +// UpdateVideoSourceTrack updates a track. +func (db *VideoSourceTrackDB) UpdateVideoSourceTrack(providerID, streamID int, trackStruct VideoSourceTrack) error { + _, err := db.SQL.Exec(`UPDATE video_source_track SET category = ?, epg_id = ? WHERE video_source_id = ? AND stream_id = ?`, trackStruct.Category, trackStruct.EPGID, providerID, streamID) + return err +} + +// GetTracksForVideoSource returns a slice of VideoSourceTracks for the given video source ID. +func (db *VideoSourceTrackDB) GetTracksForVideoSource(videoSourceID int) ([]VideoSourceTrack, error) { + tracks := make([]VideoSourceTrack, 0) + + sql, args, sqlGenErr := squirrel.Select("*").From("video_source_track").Where(squirrel.Eq{"video_source_id": videoSourceID}).ToSql() + if sqlGenErr != nil { + return nil, sqlGenErr + } + + err := db.SQL.Select(&tracks, sql, args...) + return tracks, err +} diff --git a/internal/streamsuite/stream.go b/internal/streamsuite/stream.go new file mode 100644 index 0000000..debb12f --- /dev/null +++ b/internal/streamsuite/stream.go @@ -0,0 +1,148 @@ +package streamsuite + +import ( + "fmt" + "io" + "net" + "net/http" + "os" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/tellytv/telly/internal/metrics" + "github.com/tellytv/telly/internal/models" +) + +var ( + log = &logrus.Logger{ + Out: os.Stderr, + Formatter: &logrus.TextFormatter{ + FullTimestamp: true, + }, + Hooks: make(logrus.LevelHooks), + Level: logrus.DebugLevel, + } +) + +const ( + // BufferSize is the size of the content buffer we will use. + BufferSize = 1024 * 8 +) + +// Stream describes a single active video stream in telly. +type Stream struct { + UUID string + Channel *models.LineupChannel + StreamURL string + + Transport StreamTransport + StartTime *time.Time + PromLabels []string + StopNow chan bool `json:"-"` + LastWroteAt *time.Time + + streamData io.ReadCloser +} + +// Start will mark the stream as playing and begin playback. +func (s *Stream) Start(c *gin.Context) { + ctx := c.Request.Context() + + now := time.Now() + s.StartTime = &now + metrics.ActiveStreams.WithLabelValues(s.PromLabels...).Inc() + + log.Infoln("Transcoding stream via", s.Transport.Type()) + sd, streamErr := s.Transport.Start(ctx, s.StreamURL) + if streamErr != nil { + if httpErr, ok := streamErr.(httpError); ok { + c.AbortWithError(httpErr.StatusCode, httpErr) + return + } + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error when starting streaming via %s: %s", s.Transport.Type(), streamErr)) + return + } + + s.streamData = sd + + clientGone := c.Writer.CloseNotify() + + for key, value := range s.Transport.Headers() { + c.Writer.Header()[key] = value + } + + buffer := make([]byte, BufferSize) + + writer := wrappedWriter{c.Writer} + +forLoop: + for { + select { + case <-s.StopNow: + break forLoop + case <-clientGone: + case <-ctx.Done(): + log.Debugln("Stream client is disconnected, returning!") + break forLoop + default: + n, err := s.streamData.Read(buffer) + + if n == 0 { + log.Debugln("Read 0 bytes from stream source, returning") + break forLoop + } + + if err != nil { + log.WithError(err).Errorln("Received error while reading from stream source") + break forLoop + } + + data := buffer[:n] + if _, respWriteErr := writer.Write(data); respWriteErr != nil { + if respWriteErr == io.EOF || respWriteErr == io.ErrUnexpectedEOF || respWriteErr == io.ErrClosedPipe { + log.Debugln("CAUGHT IO ERR") + } + log.WithError(respWriteErr).Errorln("Error while writing to connected stream client") + break forLoop + } + c.Writer.Flush() + } + } + + s.Stop() + +} + +// Stop will tear down the stream. +func (s *Stream) Stop() { + metrics.ActiveStreams.WithLabelValues(s.PromLabels...).Dec() + + if closeErr := s.streamData.Close(); closeErr != nil { + log.WithError(closeErr).Errorf("error when closing stream via %s", s.Transport.Type()) + return + } + + if stopErr := s.Transport.Stop(); stopErr != nil { + log.WithError(stopErr).Errorf("error when cleaning up stream via %s", s.Transport.Type()) + return + } +} + +type wrappedWriter struct { + writer io.Writer +} + +func (w wrappedWriter) Write(p []byte) (int, error) { + n, err := w.writer.Write(p) + if err != nil { + // Filter out broken pipe (user pressed "stop") errors + if nErr, ok := err.(*net.OpError); ok { + if nErr.Err == syscall.EPIPE { + return n, nil + } + } + } + return n, err +} diff --git a/internal/streamsuite/transports.go b/internal/streamsuite/transports.go new file mode 100644 index 0000000..566fe18 --- /dev/null +++ b/internal/streamsuite/transports.go @@ -0,0 +1,145 @@ +package streamsuite + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + + "github.com/prometheus/common/version" +) + +// StreamTransport is a method to acquire a video source. +type StreamTransport interface { + Type() string + Headers() http.Header + Start(ctx context.Context, streamURL string) (io.ReadCloser, error) + Stop() error +} + +// FFMPEG is a transport that uses FFMPEG to process the video stream. +type FFMPEG struct { + run *exec.Cmd +} + +// MarshalJSON returns the string type of transport. +func (f FFMPEG) MarshalJSON() ([]byte, error) { + return json.Marshal(f.Type()) +} + +// Type describes the type of transport. +func (f FFMPEG) Type() string { + return "FFMPEG" +} + +// Headers returns HTTP headers to add to the outbound request, if any. +func (f FFMPEG) Headers() http.Header { + return nil +} + +// Start will begin the stream. +func (f FFMPEG) Start(ctx context.Context, streamURL string) (io.ReadCloser, error) { + f.run = exec.CommandContext(ctx, "ffmpeg", "-re", "-i", streamURL, "-codec", "copy", "-f", "mpegts", "-tune", "zerolatency", "pipe:1") // nolint + streamData, stdErr := f.run.StdoutPipe() + if stdErr != nil { + return nil, stdErr + } + + if startErr := f.run.Start(); startErr != nil { + return nil, startErr + } + + return streamData, nil +} + +// Stop kills the stream +func (f FFMPEG) Stop() error { + return f.run.Process.Kill() +} + +// HTTP is a transport that simply "restreams" the video from the source with a small buffer. +type HTTP struct { + req *http.Request + resp *http.Response +} + +// MarshalJSON returns the string type of transport. +func (h HTTP) MarshalJSON() ([]byte, error) { + return json.Marshal(h.Type()) +} + +// Type describes the type of transport. +func (h HTTP) Type() string { + return "HTTP" +} + +// Headers returns HTTP headers to add to the outbound request, if any. +func (h HTTP) Headers() http.Header { + if h.resp == nil { + return nil + } + return h.resp.Header +} + +// Start will begin the stream. +func (h *HTTP) Start(ctx context.Context, streamURL string) (io.ReadCloser, error) { + streamReq, reqErr := http.NewRequest("GET", streamURL, nil) + if reqErr != nil { + return nil, newHTTPError(reqErr, http.StatusInternalServerError, nil) + } + + streamReq = streamReq.WithContext(ctx) + + streamReq.Header.Set("User-Agent", fmt.Sprintf("telly/%s", version.Version)) + + h.req = streamReq + + resp, respErr := http.DefaultClient.Do(streamReq) + if respErr != nil { + return nil, newHTTPError(respErr, 0, nil) + } + + h.resp = resp + + if resp.StatusCode > 399 { + return nil, newHTTPError(nil, resp.StatusCode, resp.Body) + } + + return resp.Body, nil +} + +// Stop kills the stream +func (h HTTP) Stop() error { + return nil +} + +type httpError struct { + OriginalError error + StatusCode int + Contents string +} + +func newHTTPError(err error, code int, reader io.Reader) httpError { + buf := &bytes.Buffer{} + if reader != nil { + if _, copyErr := io.Copy(buf, reader); copyErr != nil { + return httpError{OriginalError: err, StatusCode: code} + } + } + + return httpError{ + OriginalError: err, + StatusCode: code, + Contents: buf.String(), + } +} + +func (h httpError) Error() string { + if h.OriginalError != nil { + return h.OriginalError.Error() + } + return fmt.Sprintf("unexpected status code %d, received contents: %s", h.StatusCode, h.Contents) +} diff --git a/internal/utils/main.go b/internal/utils/main.go new file mode 100644 index 0000000..d99d8fe --- /dev/null +++ b/internal/utils/main.go @@ -0,0 +1,296 @@ +package utils + +import ( + "compress/gzip" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "os" + "regexp" + "strings" + + "github.com/spf13/viper" + "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/xmltv" +) + +var ( + // SafeStringsRegex will match any usernames, passwords or tokens in a string. + SafeStringsRegex = regexp.MustCompile(`(?m)(username|password|token)=[\w=]+(&?)`) + + // StringSafer will replace sensitive values (username, password and token) with safed values. + StringSafer = func(input string) string { + ret := input + if strings.HasPrefix(input, "username=") { + ret = "username=REDACTED" + } else if strings.HasPrefix(input, "password=") { + ret = "password=REDACTED" + } else if strings.HasPrefix(input, "token=") { + ret = "token=bm90Zm9yeW91" // "notforyou" + } + if strings.HasSuffix(input, "&") { + return fmt.Sprintf("%s&", ret) + } + return ret + } +) + +// GetTCPAddr attempts to convert a string found via viper to a net.TCPAddr. Will panic on error. +func GetTCPAddr(key string) *net.TCPAddr { + addr, addrErr := net.ResolveTCPAddr("tcp", viper.GetString(key)) + if addrErr != nil { + panic(fmt.Errorf("error parsing address %s: %s", viper.GetString(key), addrErr)) + } + return addr +} + +// GetM3U is a helper function to download/open and parse a M3U Plus file. +func GetM3U(path string) (*m3uplus.Playlist, error) { + // safePath := SafeStringsRegex.ReplaceAllStringFunc(path, StringSafer) + + file, _, err := GetFile(path) + if err != nil { + return nil, fmt.Errorf("error while opening m3u file: %s", err) + } + + rawPlaylist, decodeErr := m3uplus.Decode(file) + if decodeErr != nil { + return nil, fmt.Errorf("error while decoding m3u file: %s", decodeErr) + } + + if closeM3UErr := file.Close(); closeM3UErr != nil { + return nil, fmt.Errorf("error when closing m3u reader: %s", closeM3UErr) + } + + return rawPlaylist, nil +} + +// GetXMLTV is a helper function to download/open and parse a XMLTV file. +func GetXMLTV(path string) (*xmltv.TV, error) { + // safePath := SafeStringsRegex.ReplaceAllStringFunc(path, StringSafer) + + file, _, err := GetFile(path) + if err != nil { + return nil, err + } + + decoder := xml.NewDecoder(file) + tvSetup := new(xmltv.TV) + if err := decoder.Decode(tvSetup); err != nil { + return nil, fmt.Errorf("could not decode xmltv programme: %s", err) + } + + if closeXMLErr := file.Close(); closeXMLErr != nil { + return nil, fmt.Errorf("error when closing xml reader: %s", closeXMLErr) + } + + return tvSetup, nil +} + +// GetFile is a helper function to download/open and parse a file. +func GetFile(path string) (io.ReadCloser, string, error) { + transport := "disk" + + if strings.HasPrefix(strings.ToLower(path), "http") { + + transport = "http" + + req, reqErr := http.NewRequest("GET", path, nil) + if reqErr != nil { + return nil, transport, reqErr + } + + // For whatever reason, some providers only allow access from a "real" User-Agent. + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36") + + resp, err := http.Get(path) // nolint + if err != nil { + return nil, transport, err + } + + if strings.HasSuffix(strings.ToLower(path), ".gz") || resp.Header.Get("Content-Type") == "application/x-gzip" { + // log.Infof("File (%s) is gzipp'ed, ungzipping now, this might take a while", path) + gz, gzErr := gzip.NewReader(resp.Body) + if gzErr != nil { + return nil, transport, gzErr + } + + return gz, transport, nil + } + + return resp.Body, transport, nil + } + + file, fileErr := os.Open(path) // nolint + if fileErr != nil { + return nil, transport, fileErr + } + + return file, transport, nil +} + +// ChunkStringSlice will return a slice of slice of strings for the given chunkSize. +func ChunkStringSlice(sl []string, chunkSize int) [][]string { + var divided [][]string + + for i := 0; i < len(sl); i += chunkSize { + end := i + chunkSize + + if end > len(sl) { + end = len(sl) + } + + divided = append(divided, sl[i:end]) + } + return divided +} + +// Contains returns true if the given element "e" is found inside the slice of strings "s". +func Contains(s []string, e string) bool { + for _, ss := range s { + if e == ss { + return true + } + } + return false +} + +// GetStringMapKeys returns a slice of strings for the keys of a map. +func GetStringMapKeys(s map[string]struct{}) []string { + keys := make([]string, 0) + for key := range s { + keys = append(keys, key) + } + return keys +} + +// Difference returns the elements in a that aren't in b +func Difference(a, b []string) []string { + mb := map[string]bool{} + for _, x := range b { + mb[x] = true + } + ab := []string{} + for _, x := range a { + if _, ok := mb[x]; !ok { + ab = append(ab, x) + } + } + return ab +} + +// From https://github.com/stoewer/go-strcase + +// KebabCase converts a string into kebab case. +func KebabCase(s string) string { + return lowerDelimiterCase(s, '-') +} + +// SnakeCase converts a string into snake case. +func SnakeCase(s string) string { + return lowerDelimiterCase(s, '_') +} + +// isLower checks if a character is lower case. More precisely it evaluates if it is +// in the range of ASCII character 'a' to 'z'. +func isLower(ch rune) bool { + return ch >= 'a' && ch <= 'z' +} + +// toLower converts a character in the range of ASCII characters 'A' to 'Z' to its lower +// case counterpart. Other characters remain the same. +func toLower(ch rune) rune { + if ch >= 'A' && ch <= 'Z' { + return ch + 32 + } + return ch +} + +// isUpper checks if a character is upper case. More precisely it evaluates if it is +// in the range of ASCII characters 'A' to 'Z'. +func isUpper(ch rune) bool { + return ch >= 'A' && ch <= 'Z' +} + +// toUpper converts a character in the range of ASCII characters 'a' to 'z' to its lower +// case counterpart. Other characters remain the same. +func toUpper(ch rune) rune { // nolint + if ch >= 'a' && ch <= 'z' { + return ch - 32 + } + return ch +} + +// isSpace checks if a character is some kind of whitespace. +func isSpace(ch rune) bool { + return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' +} + +// isDelimiter checks if a character is some kind of whitespace or '_' or '-'. +func isDelimiter(ch rune) bool { + return ch == '-' || ch == '_' || isSpace(ch) +} + +// lowerDelimiterCase converts a string into snake_case or kebab-case depending on +// the delimiter passed in as second argument. +func lowerDelimiterCase(s string, delimiter rune) string { + s = strings.TrimSpace(s) + buffer := make([]rune, 0, len(s)+3) + + var prev rune + var curr rune + for _, next := range s { + if isDelimiter(curr) { + if !isDelimiter(prev) { + buffer = append(buffer, delimiter) + } + } else if isUpper(curr) { + if isLower(prev) || (isUpper(prev) && isLower(next)) { + buffer = append(buffer, delimiter) + } + buffer = append(buffer, toLower(curr)) + } else if curr != 0 { + buffer = append(buffer, curr) + } + prev = curr + curr = next + } + + if len(s) > 0 { + if isUpper(curr) && isLower(prev) && prev != 0 { + buffer = append(buffer, delimiter) + } + buffer = append(buffer, toLower(curr)) + } + + return string(buffer) +} + +// PadNumberWithZeros will pad the given value integer with 0's until expectedLength is met. +func PadNumberWithZeros(value int, expectedLength int) string { + padded := fmt.Sprintf("%02d", value) + valLength := CountDigits(value) + if valLength != expectedLength { + repeatLength := expectedLength - valLength + if repeatLength < 0 { + repeatLength = 0 + } + return fmt.Sprintf("%s%d", strings.Repeat("0", repeatLength), value) + } + return padded +} + +// CountDigits will count the number of digits in an integer. +func CountDigits(i int) int { + count := 0 + if i == 0 { + count = 1 + } + for i != 0 { + i /= 10 + count = count + 1 + } + return count +} diff --git a/internal/videoproviders/m3u.go b/internal/videoproviders/m3u.go new file mode 100644 index 0000000..7eb9452 --- /dev/null +++ b/internal/videoproviders/m3u.go @@ -0,0 +1,153 @@ +package videoproviders + +import ( + "fmt" + "strconv" + "strings" + + "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/utils" +) + +// M3U is a VideoProvider supporting M3U files. +type M3U struct { + BaseConfig Configuration + + Playlist *m3uplus.Playlist + channels map[int]Channel + categoriesStrCheck []string + categories []Category + seenFormats []string +} + +func newM3U(config *Configuration) (VideoProvider, error) { + m3u := &M3U{BaseConfig: *config} + + if loadErr := m3u.Refresh(); loadErr != nil { + return nil, loadErr + } + + return m3u, nil +} + +// Name returns the name of the VideoProvider. +func (m *M3U) Name() string { + return "M3U" +} + +// Categories returns a slice of Category that the provider has available. +func (m *M3U) Categories() ([]Category, error) { + return m.categories, nil +} + +// Formats returns a slice of strings containing the valid video formats. +func (m *M3U) Formats() ([]string, error) { + return m.seenFormats, nil +} + +// Channels returns a slice of Channel that the provider has available. +func (m *M3U) Channels() ([]Channel, error) { + outputChannels := make([]Channel, 0) + for _, channel := range m.channels { + outputChannels = append(outputChannels, channel) + } + return outputChannels, nil +} + +// StreamURL returns a fully formed URL to a video stream for the given streamID and wantedFormat. +func (m *M3U) StreamURL(streamID int, wantedFormat string) (string, error) { + if val, ok := m.channels[streamID]; ok { + return val.streamURL, nil + } + return "", fmt.Errorf("that channel id (%d) does not exist in the video source lineup", streamID) +} + +// Refresh causes the provider to request the latest information. +func (m *M3U) Refresh() error { + playlist, m3uErr := utils.GetM3U(m.BaseConfig.M3UURL) + if m3uErr != nil { + return fmt.Errorf("error when reading m3u: %s", m3uErr) + } + m.Playlist = playlist + + for _, track := range playlist.Tracks { + streamURL := streamNumberRegex(strings.ToLower(track.URI), -1) + + if len(streamURL) == 0 { + fmt.Println("Unable to process M3U track, continuing", track.URI) + continue + } + + channelID, channelIDErr := strconv.Atoi(streamURL[0][1]) + if channelIDErr != nil { + return fmt.Errorf("error when extracting channel id from m3u track: %s", channelIDErr) + } + + if !utils.Contains(m.seenFormats, streamURL[0][2]) { + m.seenFormats = append(m.seenFormats, streamURL[0][2]) + } + + nameVal := track.Name + + if val, ok := track.Tags["tvg-name"]; ok { + nameVal = val + } + + if m.BaseConfig.NameKey != "" { + if val, ok := track.Tags[m.BaseConfig.NameKey]; ok { + nameVal = val + } + } + + logoVal := track.Tags["tvg-logo"] + if m.BaseConfig.LogoKey != "" { + if val, ok := track.Tags[m.BaseConfig.LogoKey]; ok { + logoVal = val + } + } + + categoryVal := track.Tags["group-title"] + if m.BaseConfig.CategoryKey != "" { + if val, ok := track.Tags[m.BaseConfig.CategoryKey]; ok { + categoryVal = val + } + } + + if !utils.Contains(m.categoriesStrCheck, categoryVal) { + m.categoriesStrCheck = append(m.categoriesStrCheck, categoryVal) + m.categories = append(m.categories, Category{ + Name: categoryVal, + Type: "live", + }) + } + + epgIDVal := track.Tags["tvg-id"] + if m.BaseConfig.EPGIDKey != "" { + if val, ok := track.Tags[m.BaseConfig.EPGIDKey]; ok { + epgIDVal = val + } + } + + if m.channels == nil { + m.channels = make(map[int]Channel) + } + + m.channels[channelID] = Channel{ + Name: nameVal, + StreamID: channelID, + Logo: logoVal, + Type: LiveStream, + Category: categoryVal, + EPGID: epgIDVal, + + streamURL: track.URI, + } + } + + return nil +} + +// Configuration returns the base configuration backing the provider +func (m *M3U) Configuration() Configuration { + return m.BaseConfig +} diff --git a/internal/videoproviders/main.go b/internal/videoproviders/main.go new file mode 100644 index 0000000..25a91ca --- /dev/null +++ b/internal/videoproviders/main.go @@ -0,0 +1,84 @@ +// Package videoproviders is a telly internal package to provide video stream information. +package videoproviders + +import ( + "regexp" + "strings" +) + +var streamNumberRegex = regexp.MustCompile(`/(\d+).(mp4|mkv|avi|ts|.*.m3u8|\z)`).FindAllStringSubmatch + +// var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString +// var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString +// var hdRegex = regexp.MustCompile(`hd|4k`) + +// Configuration is the basic configuration struct for videoproviders with generic values for specific providers. +type Configuration struct { + Name string `json:"-"` + Provider string + + // Only used for Xtream provider + Username string + Password string + BaseURL string + + // Only used for M3U provider + M3UURL string + NameKey string + LogoKey string + CategoryKey string + EPGIDKey string +} + +// GetProvider returns an initialized VideoProvider for the Configuration. +func (i *Configuration) GetProvider() (VideoProvider, error) { + switch strings.ToLower(i.Provider) { + case "xtream", "xstream": + return newXtreamCodes(i) + default: + return newM3U(i) + } +} + +// Category describes a grouping of streams. +type Category struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// ChannelType is used for enumerating the ChannelType field in Channel. +type ChannelType string + +const ( + // LiveStream is the constant describing a live stream. + LiveStream ChannelType = "live" + // VODStream is the constant describing a video on demand stream. + VODStream = "vod" + // SeriesStream is the constant describing a TV series stream. + SeriesStream = "series" +) + +// Channel describes a channel available in the providers lineup with necessary pieces parsed into fields. +type Channel struct { + Name string + StreamID int + Logo string + Type ChannelType + Category string + EPGID string + + // Only needed for M3U provider + streamURL string +} + +// VideoProvider describes a IPTV provider configuration. +type VideoProvider interface { + Name() string + Categories() ([]Category, error) + Formats() ([]string, error) + Channels() ([]Channel, error) + StreamURL(streamID int, wantedFormat string) (string, error) + + Refresh() error + Configuration() Configuration +} diff --git a/internal/videoproviders/xtream.go b/internal/videoproviders/xtream.go new file mode 100644 index 0000000..781379e --- /dev/null +++ b/internal/videoproviders/xtream.go @@ -0,0 +1,117 @@ +package videoproviders + +import ( + "fmt" + + xc "github.com/tellytv/go.xtream-codes" +) + +// XtreamCodes is a VideoProvider supporting Xtream-Codes IPTV servers. +type XtreamCodes struct { + BaseConfig Configuration + + client xc.XtreamClient + + categories map[int]xc.Category + streams map[int]xc.Stream + channels []Channel +} + +func newXtreamCodes(config *Configuration) (VideoProvider, error) { + xc := &XtreamCodes{BaseConfig: *config} + if loadErr := xc.Refresh(); loadErr != nil { + return nil, loadErr + } + return xc, nil +} + +// Name returns the name of the VideoProvider. +func (x *XtreamCodes) Name() string { + return "Xtream Codes Server" +} + +// Categories returns a slice of Category that the provider has available. +func (x *XtreamCodes) Categories() ([]Category, error) { + outputCats := make([]Category, 0) + for _, cat := range x.categories { + outputCats = append(outputCats, Category{ + Name: cat.Name, + Type: cat.Type, + }) + } + return outputCats, nil +} + +// Formats returns a slice of strings containing the valid video formats. +func (x *XtreamCodes) Formats() ([]string, error) { + return x.client.UserInfo.AllowedOutputFormats, nil +} + +// Channels returns a slice of Channel that the provider has available. +func (x *XtreamCodes) Channels() ([]Channel, error) { + return x.channels, nil +} + +// StreamURL returns a fully formed URL to a video stream for the given streamID and wantedFormat. +// Refresh causes the provider to request the latest information. +// Configuration returns the base configuration backing the provider +func (x *XtreamCodes) StreamURL(streamID int, wantedFormat string) (string, error) { + return x.client.GetStreamURL(streamID, wantedFormat) +} + +// Refresh causes the provider to request the latest information. +func (x *XtreamCodes) Refresh() error { + client, clientErr := xc.NewClient(x.BaseConfig.Username, x.BaseConfig.Password, x.BaseConfig.BaseURL) + if clientErr != nil { + return fmt.Errorf("error creating xtream codes client: %s", clientErr) + } + x.client = *client + + if x.categories == nil { + x.categories = make(map[int]xc.Category) + } + + if x.streams == nil { + x.streams = make(map[int]xc.Stream) + } + + for _, xType := range []string{"live", "vod", "series"} { + cats, catsErr := x.client.GetCategories(xType) + if catsErr != nil { + return fmt.Errorf("error getting %s categories: %s", xType, catsErr) + } + for _, cat := range cats { + x.categories[cat.ID] = cat + } + + streams, streamsErr := x.client.GetStreams(xType, "") + if streamsErr != nil { + return fmt.Errorf("error getting %s streams: %s", xType, streamsErr) + } + for _, stream := range streams { + x.streams[stream.ID] = stream + } + } + + for _, stream := range x.streams { + categoryName := "" + if val, ok := x.categories[stream.CategoryID]; ok { + categoryName = val.Name + } + x.channels = append(x.channels, Channel{ + Name: stream.Name, + StreamID: stream.ID, + Logo: stream.Icon, + Type: ChannelType(stream.Type), + Category: categoryName, + EPGID: stream.EPGChannelID, + }) + } + + return nil +} + +// Configuration returns the base configuration backing the provider +func (x *XtreamCodes) Configuration() Configuration { + return x.BaseConfig +} diff --git a/internal/xmltv/example.xml b/internal/xmltv/example.xml new file mode 100644 index 0000000..f71df21 --- /dev/null +++ b/internal/xmltv/example.xml @@ -0,0 +1,182 @@ + + + + + + 13 KERA + 13 KERA TX42822:- + 13 + 13 KERA fcc + KERA + KERA + PBS Affiliate + + + + 11 KTVT + 11 KTVT TX42822:- + 11 + 11 KTVT fcc + KTVT + KTVT + CBS Affiliate + + + + NOW on PBS + Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East. + 20080711 + Newsmagazine + Interview + Public affairs + Series + EP01006886.0028 + 427 + + + + + + Mystery! + Foyle's War, Series IV: Bleak Midwinter + Foyle investigates an explosion at a munitions factory, which he comes to believe may have been premeditated. + 20070701 + Anthology + Mystery + Series + EP00003026.0665 + 2705 + + + + + + Mystery! + Foyle's War, Series IV: Casualties of War + The murder of a prominent scientist may have been due to a gambling debt. + 20070708 + Anthology + Mystery + Series + EP00003026.0666 + 2706 + + + + + + BBC World News + International issues. + News + Series + SH00315789.0000 + + + + + Sit and Be Fit + 20070924 + Exercise + Series + EP00003847.0074 + 901 + + + + + + The Early Show + Republican candidate John McCain; premiere of the film "The Dark Knight." + 20080715 + Talk + News + Series + EP00337003.2361 + + + + + Rachael Ray + Actresses Kim Raver, Brooke Shields and Lindsay Price ("Lipstick Jungle"); women in their 40s tell why they got breast implants; a 30-minute meal. + + Rachael Ray + + 20080306 + Talk + Series + EP00847333.0303 + 2119 + + + + + + The Price Is Right + Contestants bid for prizes then compete for fabulous showcases. + + Bart Eskander + Roger Dobkowitz + Drew Carey + + Game show + Series + SH00004372.0000 + + + + TV-G + + + + Jeopardy! + + Alex Trebek + + 20080715 + Game show + Series + EP00002348.1700 + 5507 + + + TV-G + + + + The Young and the Restless + Sabrina Offers Victoria a Truce + Jeff thinks Kyon stole the face cream; Nikki asks Nick to give David a chance; Amber begs Adrian to go to Australia. + + Peter Bergman + Eric Braeden + Jeanne Cooper + Melody Thomas Scott + + 20080715 + Soap + Series + EP00004422.1359 + 8937 + + + + TV-14 + + + diff --git a/internal/xmltv/icetv_xmltv.dtd b/internal/xmltv/icetv_xmltv.dtd new file mode 100644 index 0000000..88c7a8d --- /dev/null +++ b/internal/xmltv/icetv_xmltv.dtd @@ -0,0 +1,607 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/xmltv/xmltv.dtd b/internal/xmltv/xmltv.dtd new file mode 100644 index 0000000..3c4812e --- /dev/null +++ b/internal/xmltv/xmltv.dtd @@ -0,0 +1,575 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/xmltv/xmltv.go b/internal/xmltv/xmltv.go new file mode 100644 index 0000000..cc8cc51 --- /dev/null +++ b/internal/xmltv/xmltv.go @@ -0,0 +1,276 @@ +// Package xmltv provides structures for parsing XMLTV data. +package xmltv + +import ( + "encoding/xml" + "fmt" + "io" + "strings" + "time" + + "golang.org/x/net/html/charset" +) + +// Time that holds the time which is parsed from XML +type Time struct { + time.Time +} + +// MarshalXMLAttr is used to marshal a Go time.Time into the XMLTV Format. +func (t *Time) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { + return xml.Attr{ + Name: name, + Value: t.Format("20060102150405 -0700"), + }, nil +} + +// UnmarshalXMLAttr is used to unmarshal a time in the XMLTV format to a time.Time. +func (t *Time) UnmarshalXMLAttr(attr xml.Attr) error { + fmtStr := "20060102150405" + if strings.Contains(attr.Value, " ") { + fmtStr = "20060102150405 -0700" + } + t1, err := time.Parse(fmtStr, attr.Value) + if err != nil { + return err + } + + *t = Time{t1} + return nil +} + +// Date is the XMLTV specific formatting of a date (YYYYMMDD/20060102) +type Date time.Time + +// MarshalXML is used to marshal a Go time.Time into the XMLTV Date Format. +func (p Date) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + t := time.Time(p) + if t.IsZero() { + return e.EncodeElement(nil, start) + } + return e.EncodeElement(t.Format("20060102"), start) +} + +// UnmarshalXML is used to unmarshal a time in the XMLTV Date format to a time.Time. +func (p *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) { + var content string + if e := d.DecodeElement(&content, &start); e != nil { + return fmt.Errorf("get the type Date field of %s error", start.Name.Local) + } + + dateFormat := "20060102" + + if len(content) == 4 { + dateFormat = "2006" + } + + if strings.Contains(content, "|") { + content = strings.Split(content, "|")[0] + dateFormat = "2006" + } + + v, e := time.Parse(dateFormat, content) + if e != nil { + return fmt.Errorf("the type Date field of %s is not a time, value is: %s", start.Name.Local, content) + } + *p = Date(v) + return nil +} + +// MarshalJSON is used to marshal a Go time.Time into the XMLTV Date Format. +func (p Date) MarshalJSON() ([]byte, error) { + t := time.Time(p) + str := "\"" + t.Format("20060102") + "\"" + + return []byte(str), nil +} + +// UnmarshalJSON is used to unmarshal a time in the XMLTV Date format to a time.Time. +func (p *Date) UnmarshalJSON(text []byte) (err error) { + strDate := string(text[1 : 8+1]) + + v, e := time.Parse("20060102", strDate) + if e != nil { + return fmt.Errorf("Date should be a time, error value is: %s", strDate) + } + *p = Date(v) + return nil +} + +// TV is the root element. +type TV struct { + XMLName xml.Name `xml:"tv" json:"-" db:"-"` + Channels []Channel `xml:"channel" json:"channels" db:"channels"` + Programmes []Programme `xml:"programme" json:"programmes" db:"programmes"` + Date string `xml:"date,attr,omitempty" json:"date,omitempty" db:"date,omitempty"` + SourceInfoURL string `xml:"source-info-url,attr,omitempty" json:"sourceInfoURL,omitempty" db:"source_info_url,omitempty"` + SourceInfoName string `xml:"source-info-name,attr,omitempty" json:"sourceInfoName,omitempty" db:"source_info_name,omitempty"` + SourceDataURL string `xml:"source-data-url,attr,omitempty" json:"sourceDataURL,omitempty" db:"source_data_url,omitempty"` + GeneratorInfoName string `xml:"generator-info-name,attr,omitempty" json:"generatorInfoName,omitempty" db:"generator_info_name,omitempty"` + GeneratorInfoURL string `xml:"generator-info-url,attr,omitempty" json:"generatorInfoURL,omitempty" db:"generator_info_url,omitempty"` +} + +// LoadXML loads the XMLTV XML from file. +func (t *TV) LoadXML(f io.Reader) error { + decoder := xml.NewDecoder(f) + decoder.CharsetReader = charset.NewReaderLabel + + err := decoder.Decode(&t) + return err +} + +// Channel details of a channel +type Channel struct { + XMLName xml.Name `xml:"channel" json:"-" db:"-"` + DisplayNames []CommonElement `xml:"display-name" json:"displayNames" db:"display_names"` + Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty" db:"icons,omitempty"` + URLs []string `xml:"url,omitempty" json:"urls,omitempty" db:"urls,omitempty"` + ID string `xml:"id,attr" json:"id,omitempty" db:"id,omitempty"` + LCN string `xml:"lcn" json:"lcn,omitempty" db:"lcn,omitempty"` // LCN is the local channel number. Plex will show it in place of the channel ID if it exists. +} + +// Programme details of a single programme transmission +type Programme struct { + XMLName xml.Name `xml:"programme" json:"-" db:"-"` + ID string `xml:"id,attr,omitempty" json:"id,omitempty" db:"id,omitempty"` // not defined by standard, but often present + Titles []CommonElement `xml:"title" json:"titles" db:"titles"` + SecondaryTitles []CommonElement `xml:"sub-title,omitempty" json:"secondaryTitles,omitempty" db:"secondary_titles,omitempty"` + Descriptions []CommonElement `xml:"desc,omitempty" json:"descriptions,omitempty" db:"descriptions,omitempty"` + Credits *Credits `xml:"credits,omitempty" json:"credits,omitempty" db:"credits,omitempty"` + Date Date `xml:"date,omitempty" json:"date,omitempty" db:"date,omitempty"` + Categories []CommonElement `xml:"category,omitempty" json:"categories,omitempty" db:"categories,omitempty"` + Keywords []CommonElement `xml:"keyword,omitempty" json:"keywords,omitempty" db:"keywords,omitempty"` + Languages []CommonElement `xml:"language,omitempty" json:"languages,omitempty" db:"languages,omitempty"` + OrigLanguages []CommonElement `xml:"orig-language,omitempty" json:"origLanguages,omitempty" db:"orig_languages,omitempty"` + Length *Length `xml:"length,omitempty" json:"length,omitempty" db:"length,omitempty"` + Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty" db:"icons,omitempty"` + URLs []string `xml:"url,omitempty" json:"urls,omitempty" db:"urls,omitempty"` + Countries []CommonElement `xml:"country,omitempty" json:"countries,omitempty" db:"countries,omitempty"` + EpisodeNums []EpisodeNum `xml:"episode-num,omitempty" json:"episodeNums,omitempty" db:"episode_nums,omitempty"` + Video *Video `xml:"video,omitempty" json:"video,omitempty" db:"video,omitempty"` + Audio *Audio `xml:"audio,omitempty" json:"audio,omitempty" db:"audio,omitempty"` + PreviouslyShown *PreviouslyShown `xml:"previously-shown,omitempty" json:"previouslyShown,omitempty" db:"previously_shown,omitempty"` + Premiere *CommonElement `xml:"premiere,omitempty" json:"premiere,omitempty" db:"premiere,omitempty"` + LastChance *CommonElement `xml:"last-chance,omitempty" json:"lastChance,omitempty" db:"last_chance,omitempty"` + New *ElementPresent `xml:"new" json:"new,omitempty" db:"new,omitempty"` + Subtitles []Subtitle `xml:"subtitles,omitempty" json:"subtitles,omitempty" db:"subtitles,omitempty"` + Ratings []Rating `xml:"rating,omitempty" json:"ratings,omitempty" db:"ratings,omitempty"` + StarRatings []Rating `xml:"star-rating,omitempty" json:"starRatings,omitempty" db:"star_ratings,omitempty"` + Reviews []Review `xml:"review,omitempty" json:"reviews,omitempty" db:"reviews,omitempty"` + Start *Time `xml:"start,attr" json:"start" db:"start"` + Stop *Time `xml:"stop,attr,omitempty" json:"stop,omitempty" db:"stop,omitempty"` + PDCStart *Time `xml:"pdc-start,attr,omitempty" json:"pdcStart,omitempty" db:"pdc_start,omitempty"` + VPSStart *Time `xml:"vps-start,attr,omitempty" json:"vpsStart,omitempty" db:"vps_start,omitempty"` + Showview string `xml:"showview,attr,omitempty" json:"showview,omitempty" db:"showview,omitempty"` + Videoplus string `xml:"videoplus,attr,omitempty" json:"videoplus,omitempty" db:"videoplus,omitempty"` + Channel string `xml:"channel,attr" json:"channel" db:"channel"` + Clumpidx string `xml:"clumpidx,attr,omitempty" json:"clumpidx,omitempty" db:"clumpidx,omitempty"` +} + +// CommonElement element structure that is common, i.e. Italy +type CommonElement struct { + Lang string `xml:"lang,attr,omitempty" json:"lang,omitempty" db:"lang,omitempty" ` + Value string `xml:",chardata" json:"value,omitempty" db:"value,omitempty"` +} + +// ElementPresent used to determine if element is present or not +type ElementPresent bool + +// MarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 +func (c *ElementPresent) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if c == nil { + return e.EncodeElement(nil, start) + } + return e.EncodeElement("", start) +} + +// UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 +func (c *ElementPresent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var v string + if decodeErr := d.DecodeElement(&v, &start); decodeErr != nil { + return decodeErr + } + *c = true + return nil +} + +// Icon associated with the element that contains it +type Icon struct { + Source string `xml:"src,attr" json:"source" db:"source"` + Width int `xml:"width,attr,omitempty" json:"width,omitempty" db:"width,omitempty"` + Height int `xml:"height,attr,omitempty" json:"height,omitempty" db:"height,omitempty"` +} + +// Credits for the programme +type Credits struct { + Directors []string `xml:"director,omitempty" json:"directors,omitempty" db:"directors,omitempty"` + Actors []Actor `xml:"actor,omitempty" json:"actors,omitempty" db:"actors,omitempty"` + Writers []string `xml:"writer,omitempty" json:"writers,omitempty" db:"writers,omitempty"` + Adapters []string `xml:"adapter,omitempty" json:"adapters,omitempty" db:"adapters,omitempty"` + Producers []string `xml:"producer,omitempty" json:"producers,omitempty" db:"producers,omitempty"` + Composers []string `xml:"composer,omitempty" json:"composers,omitempty" db:"composers,omitempty"` + Editors []string `xml:"editor,omitempty" json:"editors,omitempty" db:"editors,omitempty"` + Presenters []string `xml:"presenter,omitempty" json:"presenters,omitempty" db:"presenters,omitempty"` + Commentators []string `xml:"commentator,omitempty" json:"commentators,omitempty" db:"commentators,omitempty"` + Guests []string `xml:"guest,omitempty" json:"guests,omitempty" db:"guests,omitempty"` +} + +// Actor in a programme +type Actor struct { + Role string `xml:"role,attr,omitempty" json:"role,omitempty" db:"role,omitempty"` + Value string `xml:",chardata" json:"value" db:"value"` +} + +// Length of the programme +type Length struct { + Units string `xml:"units,attr" json:"units" db:"units"` + Value string `xml:",chardata" json:"value" db:"value"` +} + +// EpisodeNum of the programme +type EpisodeNum struct { + System string `xml:"system,attr,omitempty" json:"system,omitempty" db:"system,omitempty"` + Value string `xml:",chardata" json:"value" db:"value"` +} + +// Video details of the programme +type Video struct { + Present string `xml:"present,omitempty" json:"present,omitempty" db:"present,omitempty"` + Colour string `xml:"colour,omitempty" json:"colour,omitempty" db:"colour,omitempty"` + Aspect string `xml:"aspect,omitempty" json:"aspect,omitempty" db:"aspect,omitempty"` + Quality string `xml:"quality,omitempty" json:"quality,omitempty" db:"quality,omitempty"` +} + +// Audio details of the programme +type Audio struct { + Present string `xml:"present,omitempty" json:"present,omitempty" db:"present,omitempty"` + Stereo string `xml:"stereo,omitempty" json:"stereo,omitempty" db:"stereo,omitempty"` +} + +// PreviouslyShown When and where the programme was last shown, if known. +type PreviouslyShown struct { + Start Time `xml:"start,attr,omitempty" json:"start,omitempty" db:"start,omitempty"` + Channel string `xml:"channel,attr,omitempty" json:"channel,omitempty" db:"channel,omitempty"` +} + +// Subtitle in a programme +type Subtitle struct { + Language *CommonElement `xml:"language,omitempty" json:"language,omitempty" db:"language,omitempty"` + Type string `xml:"type,attr,omitempty" json:"type,omitempty" db:"type,omitempty"` +} + +// Rating of a programme +type Rating struct { + Value string `xml:"value" json:"value" db:"value"` + Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty" db:"icons,omitempty"` + System string `xml:"system,attr,omitempty" json:"system,omitempty" db:"system,omitempty"` +} + +// Review of a programme +type Review struct { + Value string `xml:",chardata" json:"value" db:"value"` + Type string `xml:"type" json:"type" db:"type"` + Source string `xml:"source,omitempty" json:"source,omitempty" db:"source,omitempty"` + Reviewer string `xml:"reviewer,omitempty" json:"reviewer,omitempty" db:"reviewer,omitempty"` + Lang string `xml:"lang,omitempty" json:"lang,omitempty" db:"lang,omitempty"` +} diff --git a/internal/xmltv/xmltv_test.go b/internal/xmltv/xmltv_test.go new file mode 100644 index 0000000..f54a6b1 --- /dev/null +++ b/internal/xmltv/xmltv_test.go @@ -0,0 +1,143 @@ +package xmltv + +import ( + "encoding/xml" + "fmt" + "io" + "os" + "reflect" + "testing" + "time" + + "github.com/kr/pretty" +) + +func dummyReader(charset string, input io.Reader) (io.Reader, error) { + return input, nil +} + +func TestDecode(t *testing.T) { + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Example downloaded from http://wiki.xmltv.org/index.php/internal/xmltvFormat + // One may check it with `xmllint --noout --dtdvalid xmltv.dtd example.xml` + f, err := os.Open(fmt.Sprintf("%s/example.xml", dir)) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + var tv TV + dec := xml.NewDecoder(f) + dec.CharsetReader = dummyReader + err = dec.Decode(&tv) + if err != nil { + t.Fatal(err) + } + + ch := Channel{ + XMLName: xml.Name{Space: "", Local: "channel"}, + ID: "I10436.labs.zap2it.com", + DisplayNames: []CommonElement{ + { + Value: "13 KERA", + }, + { + Value: "13 KERA TX42822:-", + }, + { + Value: "13", + }, + { + Value: "13 KERA fcc", + }, + { + Value: "KERA", + }, + { + Value: "KERA", + }, + { + Value: "PBS Affiliate", + }, + }, + Icons: []Icon{ + { + Source: `file://C:\Perl\site/share/xmltv/icons/KERA.gif`, + }, + }, + } + if !reflect.DeepEqual(ch, tv.Channels[0]) { + t.Errorf("\texpected: %# v\n\t\tactual: %# v\n", pretty.Formatter(ch), pretty.Formatter(tv.Channels[0])) + } + + loc := time.FixedZone("", -6*60*60) + date := time.Date(2008, 07, 11, 0, 0, 0, 0, time.UTC) + pr := Programme{ + XMLName: xml.Name{Space: "", Local: "programme"}, + ID: "someId", + Date: Date(date), + Channel: "I10436.labs.zap2it.com", + Start: &Time{time.Date(2008, 07, 15, 0, 30, 0, 0, loc)}, + Stop: &Time{time.Date(2008, 07, 15, 1, 0, 0, 0, loc)}, + Titles: []CommonElement{ + { + Lang: "en", + Value: "NOW on PBS", + }, + }, + Descriptions: []CommonElement{ + { + Lang: "en", + Value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.", + }, + }, + Categories: []CommonElement{ + { + Lang: "en", + Value: "Newsmagazine", + }, + { + Lang: "en", + Value: "Interview", + }, + { + Lang: "en", + Value: "Public affairs", + }, + { + Lang: "en", + Value: "Series", + }, + }, + EpisodeNums: []EpisodeNum{ + { + System: "dd_progid", + Value: "EP01006886.0028", + }, + { + System: "onscreen", + Value: "427", + }, + }, + Audio: &Audio{ + Stereo: "stereo", + }, + PreviouslyShown: &PreviouslyShown{ + Start: Time{time.Date(2008, 07, 11, 0, 0, 0, 0, time.UTC)}, + }, + Subtitles: []Subtitle{ + { + Type: "teletext", + }, + }, + } + if !reflect.DeepEqual(pr, tv.Programmes[0]) { + expected := fmt.Sprintf("\texpected: %# v\n\t\t\texpected start: %s\n\t\t\texpected stop : %s", pretty.Formatter(pr), pr.Start, pr.Stop) + actual := fmt.Sprintf("\tactual: %# v\n\t\t\tactual start: %s\n\t\t\tactual stop: %s", pretty.Formatter(tv.Programmes[0]), tv.Programmes[0].Start, tv.Programmes[0].Stop) + t.Errorf("%s\n%s\n", expected, actual) + } +} diff --git a/main.go b/main.go index 896263f..8bdddf0 100644 --- a/main.go +++ b/main.go @@ -1,192 +1,187 @@ package main import ( - "encoding/base64" + "encoding/json" + fflag "flag" "fmt" - "io" - "net/http" + "net" "os" - "strconv" "strings" - "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/version" + "github.com/robfig/cron" "github.com/sirupsen/logrus" - "github.com/tellytv/telly/m3u" - kingpin "gopkg.in/alecthomas/kingpin.v2" + flag "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/tellytv/telly/internal/api" + "github.com/tellytv/telly/internal/commands" + "github.com/tellytv/telly/internal/context" + "github.com/tellytv/telly/internal/models" + "github.com/tellytv/telly/internal/utils" ) var ( - log = logrus.New() - opts = config{} - - exposedChannels = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "exposed_channels_total", - Help: "Number of exposed channels.", + namespace = "telly" + log = &logrus.Logger{ + Out: os.Stderr, + Formatter: &logrus.TextFormatter{ + FullTimestamp: true, }, - ) + Hooks: make(logrus.LevelHooks), + Level: logrus.DebugLevel, + } ) func main() { // Discovery flags - kingpin.Flag("discovery.device-id", "8 digits used to uniquely identify the device. $(TELLY_DISCOVERY_DEVICE_ID)").Envar("TELLY_DISCOVERY_DEVICE_ID").Default("12345678").IntVar(&opts.DeviceID) - kingpin.Flag("discovery.device-friendly-name", "Name exposed via discovery. Useful if you are running two instances of telly and want to differentiate between them $(TELLY_DISCOVERY_DEVICE_FRIENDLY_NAME)").Envar("TELLY_DISCOVERY_DEVICE_FRIENDLY_NAME").Default("telly").StringVar(&opts.FriendlyName) - kingpin.Flag("discovery.device-auth", "Only change this if you know what you're doing $(TELLY_DISCOVERY_DEVICE_AUTH)").Envar("TELLY_DISCOVERY_DEVICE_AUTH").Default("telly123").Hidden().StringVar(&opts.DeviceAuth) - kingpin.Flag("discovery.device-manufacturer", "Manufacturer exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MANUFACTURER)").Envar("TELLY_DISCOVERY_DEVICE_MANUFACTURER").Default("Silicondust").StringVar(&opts.Manufacturer) - kingpin.Flag("discovery.device-model-number", "Model number exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MODEL_NUMBER)").Envar("TELLY_DISCOVERY_DEVICE_MODEL_NUMBER").Default("HDTC-2US").StringVar(&opts.ModelNumber) - kingpin.Flag("discovery.device-firmware-name", "Firmware name exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_NAME)").Envar("TELLY_DISCOVERY_DEVICE_FIRMWARE_NAME").Default("hdhomeruntc_atsc").StringVar(&opts.FirmwareName) - kingpin.Flag("discovery.device-firmware-version", "Firmware version exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_VERSION)").Envar("TELLY_DISCOVERY_DEVICE_FIRMWARE_VERSION").Default("20150826").StringVar(&opts.FirmwareVersion) - kingpin.Flag("discovery.ssdp", "Turn on SSDP announcement of telly to the local network $(TELLY_DISCOVERY_SSDP)").Envar("TELLY_DISCOVERY_SSDP").Default("true").BoolVar(&opts.SSDP) + flag.String("discovery.device-id", "12345678", "8 alpha-numeric characters used to uniquely identify the device. $(TELLY_DISCOVERY_DEVICE_ID)") + flag.String("discovery.device-friendly-name", "telly", "Name exposed via discovery. Useful if you are running two instances of telly and want to differentiate between them $(TELLY_DISCOVERY_DEVICE_FRIENDLY_NAME)") + flag.String("discovery.device-auth", "telly123", "Only change this if you know what you're doing $(TELLY_DISCOVERY_DEVICE_AUTH)") + flag.String("discovery.device-manufacturer", "Silicondust", "Manufacturer exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MANUFACTURER)") + flag.String("discovery.device-model-number", "HDTC-2US", "Model number exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MODEL_NUMBER)") + flag.String("discovery.device-firmware-name", "hdhomeruntc_atsc", "Firmware name exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_NAME)") + flag.String("discovery.device-firmware-version", "20150826", "Firmware version exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_VERSION)") + flag.Bool("discovery.ssdp", true, "Turn on SSDP announcement of telly to the local network $(TELLY_DISCOVERY_SSDP)") // Regex/filtering flags - kingpin.Flag("filter.regex-inclusive", "Whether the provided regex is inclusive (whitelisting) or exclusive (blacklisting). If true (--filter.regex-inclusive), only channels matching the provided regex pattern will be exposed. If false (--no-filter.regex-inclusive), only channels NOT matching the provided pattern will be exposed. $(TELLY_FILTER_REGEX_MODE)").Envar("TELLY_FILTER_REGEX_MODE").Default("false").BoolVar(&opts.RegexInclusive) - kingpin.Flag("filter.regex", "Use regex to filter for channels that you want. A basic example would be .*UK.*. $(TELLY_FILTER_REGEX)").Envar("TELLY_FILTER_REGEX").Default(".*").RegexpVar(&opts.Regex) + flag.Bool("filter.regex-inclusive", false, "Whether the provided regex is inclusive (whitelisting) or exclusive (blacklisting). If true (--filter.regex-inclusive), only channels matching the provided regex pattern will be exposed. If false (--no-filter.regex-inclusive), only channels NOT matching the provided pattern will be exposed. $(TELLY_FILTER_REGEX_INCLUSIVE)") + flag.String("filter.regex", ".*", "Use regex to filter for channels that you want. A basic example would be .*UK.*. $(TELLY_FILTER_REGEX)") // Web flags - kingpin.Flag("web.listen-address", "Address to listen on for web interface and telemetry $(TELLY_WEB_LISTEN_ADDRESS)").Envar("TELLY_WEB_LISTEN_ADDRESS").Default("localhost:6077").TCPVar(&opts.ListenAddress) - kingpin.Flag("web.base-address", "The address to expose via discovery. Useful with reverse proxy $(TELLY_WEB_BASE_ADDRESS)").Envar("TELLY_WEB_BASE_ADDRESS").Default("localhost:6077").TCPVar(&opts.BaseAddress) + flag.StringP("web.listen-address", "l", ":6077", "Address to listen on for web interface, API and telemetry $(TELLY_WEB_LISTEN_ADDRESS)") // Log flags - kingpin.Flag("log.level", "Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, fatal] $(TELLY_LOG_LEVEL)").Envar("TELLY_LOG_LEVEL").Default(logrus.InfoLevel.String()).StringVar(&opts.LogLevel) - kingpin.Flag("log.requests", "Log HTTP requests $(TELLY_LOG_REQUESTS)").Envar("TELLY_LOG_REQUESTS").Default("false").BoolVar(&opts.LogRequests) - - // IPTV flags - kingpin.Flag("iptv.playlist", "Location of playlist M3U file. Can be on disk or a URL. $(TELLY_IPTV_PLAYLIST)").Envar("TELLY_IPTV_PLAYLIST").Default("iptv.m3u").StringVar(&opts.M3UPath) - kingpin.Flag("iptv.streams", "Number of concurrent streams allowed $(TELLY_IPTV_STREAMS)").Envar("TELLY_IPTV_STREAMS").Default("1").IntVar(&opts.ConcurrentStreams) - kingpin.Flag("iptv.direct", "If true, stream URLs will not be obfuscated to hide them from Plex. $(TELLY_IPTV_DIRECT)").Envar("TELLY_IPTV_DIRECT").Default("false").BoolVar(&opts.DirectMode) - kingpin.Flag("iptv.starting-channel", "The channel number to start exposing from. $(TELLY_IPTV_STARTING_CHANNEL)").Envar("TELLY_IPTV_STARTING_CHANNEL").Default("10000").IntVar(&opts.StartingChannel) + flag.String("log.level", logrus.InfoLevel.String(), "Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, fatal] $(TELLY_LOG_LEVEL)") + flag.Bool("log.requests", false, "Log HTTP requests $(TELLY_LOG_REQUESTS)") - kingpin.Version(version.Print("telly")) - kingpin.HelpFlag.Short('h') - kingpin.Parse() + // Misc flags + flag.StringP("config.file", "c", "", "Path to your config file. If not set, configuration is searched for in the current working directory, $HOME/.telly/ and /etc/telly/. If provided, it will override all other arguments and environment variables. $(TELLY_CONFIG_FILE)") + flag.StringP("database.file", "d", "./telly.db", "Path to the SQLite3 database. If not set, defaults to telly.db. $(TELLY_DATABASE_FILE)") + flag.Bool("version", false, "Show application version") - log.Infoln("Starting telly", version.Info()) - log.Infoln("Build context", version.BuildContext()) + flag.CommandLine.AddGoFlagSet(fflag.CommandLine) - prometheus.MustRegister(version.NewCollector("telly"), exposedChannels) - - level, parseLevelErr := logrus.ParseLevel(opts.LogLevel) - if parseLevelErr != nil { - log.WithError(parseLevelErr).Panicln("error setting log level!") + flag.Parse() + if bindErr := viper.BindPFlags(flag.CommandLine); bindErr != nil { + log.WithError(bindErr).Panicln("error binding flags to viper") } - log.SetLevel(level) - - opts.DeviceUUID = fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", opts.DeviceID) - if opts.BaseAddress.IP.IsUnspecified() { - log.Panicln("base URL is set to 0.0.0.0, this will not work. please use the --web.base-address option and set it to the (local) ip address telly is running on.") + if flag.Lookup("version").Changed { + fmt.Println(version.Print(namespace)) + os.Exit(0) } - if opts.ListenAddress.IP.IsUnspecified() && opts.BaseAddress.IP.IsLoopback() { - log.Warnln("You are listening on all interfaces but your base URL is localhost (meaning Plex will try and load localhost to access your streams) - is this intended?") + if flag.Lookup("config.file").Changed { + viper.SetConfigFile(flag.Lookup("config.file").Value.String()) + } else { + viper.SetConfigName("config") + viper.AddConfigPath("/etc/telly/") + viper.AddConfigPath("$HOME/.telly") + viper.AddConfigPath("/telly") // Docker exposes this as a volume + viper.AddConfigPath(".") + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.SetEnvPrefix(namespace) + viper.AutomaticEnv() } - if opts.M3UPath == "iptv.m3u" { - log.Warnln("using default m3u option, 'iptv.m3u'. launch telly with the --iptv.playlist=yourfile.m3u option to change this!") - } - - m3uReader, readErr := getM3U(opts) - if readErr != nil { - log.WithError(readErr).Panicln("error getting m3u") - } - - playlist, err := m3u.Decode(m3uReader) + err := viper.ReadInConfig() if err != nil { - log.WithError(err).Panicln("unable to parse m3u file") + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + log.WithError(err).Panicln("fatal error while reading config file:") + } } - channels, filterErr := filterTracks(playlist.Tracks) - if filterErr != nil { - log.WithError(filterErr).Panicln("error during filtering of channels, check your regex and try again") + level, parseLevelErr := logrus.ParseLevel(viper.GetString("log.level")) + if parseLevelErr != nil { + log.WithError(parseLevelErr).Panicln("error setting log level!") } + log.SetLevel(level) - log.Debugln("Building lineup") + log.Infoln("telly is preparing to go live", version.Info()) + log.Debugln("Build context", version.BuildContext()) - opts.lineup = buildLineup(opts, channels) + validateConfig() - channelCount := len(channels) - exposedChannels.Set(float64(channelCount)) - log.Infof("found %d channels", channelCount) + viper.Set("discovery.device-uuid", fmt.Sprintf("%s-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetString("discovery.device-id"))) - if channelCount > 420 { - log.Warnln("telly has loaded more than 420 channels. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You have been warned!") + if log.Level == logrus.DebugLevel { + js, jsErr := json.MarshalIndent(viper.AllSettings(), "", " ") + if jsErr != nil { + log.WithError(jsErr).Panicln("error marshal indenting viper config to JSON") + } + log.Debugf("Loaded configuration %s", js) } - opts.FriendlyName = fmt.Sprintf("HDHomerun (%s)", opts.FriendlyName) - - serve(opts) -} + cc, err := context.NewCContext(log) + if err != nil { + log.WithError(err).Panicln("Couldn't create context") + } -func buildLineup(opts config, channels []Track) []LineupItem { - lineup := make([]LineupItem, 0) - gn := opts.StartingChannel + lineups, lineupsErr := cc.API.Lineup.GetEnabledLineups(true) + if lineupsErr != nil { + log.WithError(lineupsErr).Panicln("Error getting all enabled lineups") + } - for _, track := range channels { + c := cron.New() - var finalName string - if track.TvgName == "" { - finalName = track.Name - } else { - finalName = track.TvgName - } + for _, lineup := range lineups { + api.StartTuner(cc, &lineup) - // base64 url - fullTrackURI := track.URI - if !opts.DirectMode { - trackURI := base64.StdEncoding.EncodeToString([]byte(track.URI)) - fullTrackURI = fmt.Sprintf("http://%s/stream/%s", opts.BaseAddress.String(), trackURI) + videoProviders := make(map[int]*models.VideoSource) + guideProviders := make(map[int]*models.GuideSource) + for _, channel := range lineup.Channels { + videoProviders[channel.VideoTrack.VideoSource.ID] = channel.VideoTrack.VideoSource + guideProviders[channel.GuideChannel.GuideSource.ID] = channel.GuideChannel.GuideSource } - if strings.Contains(track.URI, ".m3u8") { - log.Warnln("your .m3u contains .m3u8's. Plex has either stopped supporting m3u8 or it is a bug in a recent version - please use .ts! telly will automatically convert these in a future version. See telly github issue #108") + for _, videoSource := range videoProviders { + if videoSource.UpdateFrequency == "" { + continue + } + commands.StartFireVideoUpdates(cc, videoSource) + if addErr := c.AddFunc(videoSource.UpdateFrequency, func() { commands.StartFireVideoUpdates(cc, videoSource) }); addErr != nil { + log.WithError(addErr).Errorln("error when adding video source to scheduled background jobs") + } } - lu := LineupItem{ - GuideNumber: strconv.Itoa(gn), - GuideName: finalName, - URL: fullTrackURI, + for _, guideSource := range guideProviders { + if guideSource.UpdateFrequency == "" { + continue + } + commands.StartFireGuideUpdates(cc, guideSource) + if addErr := c.AddFunc(guideSource.UpdateFrequency, func() { commands.StartFireGuideUpdates(cc, guideSource) }); addErr != nil { + log.WithError(addErr).Errorln("error when adding guide source to scheduled background jobs") + } } - - lineup = append(lineup, lu) - - gn = gn + 1 } - return lineup -} + c.Start() -func getM3U(opts config) (io.Reader, error) { - if strings.HasPrefix(strings.ToLower(opts.M3UPath), "http") { - log.Debugf("Downloading M3U from %s", opts.M3UPath) - resp, err := http.Get(opts.M3UPath) - if err != nil { - return nil, err - } - //defer resp.Body.Close() + api.ServeAPI(cc) +} - return resp.Body, nil +func validateConfig() { + if !(viper.IsSet("source")) { + log.Warnln("There is no source element in the configuration, the config file is likely missing.") } - log.Debugf("Reading M3U file %s...", opts.M3UPath) - - return os.Open(opts.M3UPath) -} - -func filterTracks(tracks []*m3u.Track) ([]Track, error) { - allowedTracks := make([]Track, 0) + var addrErr error + if _, addrErr = net.ResolveTCPAddr("tcp", viper.GetString("web.listenaddress")); addrErr != nil { + log.WithError(addrErr).Panic("Error when parsing Listen address, please check the address and try again.") + return + } - for _, oldTrack := range tracks { - track := Track{Track: oldTrack} - if unmarshalErr := oldTrack.UnmarshalTags(&track); unmarshalErr != nil { - return nil, unmarshalErr - } + if _, addrErr = net.ResolveTCPAddr("tcp", viper.GetString("web.base-address")); addrErr != nil { + log.WithError(addrErr).Panic("Error when parsing Base addresses, please check the address and try again.") + return + } - if opts.Regex.MatchString(track.Name) == opts.RegexInclusive { - allowedTracks = append(allowedTracks, track) - } + if utils.GetTCPAddr("web.base-address").IP.IsUnspecified() { + log.Panicln("base URL is set to 0.0.0.0, this will not work. please use the --web.baseaddress option and set it to the (local) ip address telly is running on.") } - return allowedTracks, nil + if utils.GetTCPAddr("web.listenaddress").IP.IsUnspecified() && utils.GetTCPAddr("web.base-address").IP.IsLoopback() { + log.Warnln("You are listening on all interfaces but your base URL is localhost (meaning Plex will try and load localhost to access your streams) - is this intended?") + } } diff --git a/migrations/20180905174455-initial.sql b/migrations/20180905174455-initial.sql new file mode 100644 index 0000000..42dc99e --- /dev/null +++ b/migrations/20180905174455-initial.sql @@ -0,0 +1,113 @@ + +-- +migrate Up + +CREATE TABLE IF NOT EXISTS video_source ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + provider VARCHAR(64) NULL, + username VARCHAR(64) NULL, + password VARCHAR(64) NULL, + base_url TEXT, + m3u_url TEXT, + max_streams INTEGER, + update_frequency TEXT DEFAULT '@daily', + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS video_source_track ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + video_source_id INTEGER, + name TEXT, + stream_id INTEGER, + logo TEXT, + type TEXT, + category TEXT, + epg_id TEXT, + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(video_source_id) REFERENCES video_source(id) +); + +CREATE TABLE IF NOT EXISTS guide_source ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + provider VARCHAR(64) NULL, + username VARCHAR(64) NULL, + password VARCHAR(64) NULL, + xmltv_url TEXT, + provider_data TEXT, + update_frequency TEXT DEFAULT '@daily', + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS guide_source_channel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guide_id INTEGER, + xmltv_id TEXT, + provider_data TEXT, + data TEXT, + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT channel_unique UNIQUE (guide_id, xmltv_id), + FOREIGN KEY(guide_id) REFERENCES guide_source(id) +); + +CREATE TABLE IF NOT EXISTS guide_source_programme ( + guide_id INT, + channel TEXT, + start TIMESTAMP, + end TIMESTAMP, + date DATE, + provider_data TEXT, + data TEXT, + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT programme_unique UNIQUE (guide_id, channel, start, end), + FOREIGN KEY(guide_id) REFERENCES guide_source(id) +); + +CREATE TABLE IF NOT EXISTS lineup ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + ssdp BOOLEAN DEFAULT TRUE, + listen_address TEXT DEFAULT '127.0.0.1', + discovery_address TEXT DEFAULT '127.0.0.1', + port INTEGER, + tuners INTEGER, + manufacturer TEXT DEFAULT 'Silicondust', + model_name TEXT DEFAULT 'HDHomeRun EXTEND', + model_number TEXT DEFAULT 'HDTC-2US', + firmware_name TEXT DEFAULT 'hdhomeruntc_atsc', + firmware_version TEXT DEFAULT '20150826', + device_id TEXT DEFAULT '12345678', + device_auth TEXT DEFAULT 'telly', + device_uuid TEXT DEFAULT '12345678-AE2A-4E54-BBC9-33AF7D5D6A92', + stream_transport TEXT DEFAULT 'http', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS lineup_channel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lineup_id INTEGER, + title TEXT, + channel_number TEXT, + video_track_id INTEGER, + guide_channel_id INTEGER, + hd BOOLEAN, + favorite BOOLEAN, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(lineup_id) REFERENCES lineup(id), + FOREIGN KEY(video_track_id) REFERENCES video_source_track(id), + FOREIGN KEY(guide_channel_id) REFERENCES guide_source_channel(id) +); + + +-- +migrate Down + +DROP TABLE video_source; +DROP TABLE video_source_track; +DROP TABLE guide_source; +DROP TABLE guide_source_channel; +DROP TABLE lineup; +DROP TABLE lineup_channel; diff --git a/migrations/20180913140221-AddVideoTrackUniqueConstraint.sql b/migrations/20180913140221-AddVideoTrackUniqueConstraint.sql new file mode 100644 index 0000000..ad17622 --- /dev/null +++ b/migrations/20180913140221-AddVideoTrackUniqueConstraint.sql @@ -0,0 +1,6 @@ + +-- +migrate Up + +CREATE UNIQUE INDEX track_unique ON video_source_track(video_source_id, stream_id); + +-- +migrate Down diff --git a/migrations/dbconfig.yml b/migrations/dbconfig.yml new file mode 100644 index 0000000..953c99c --- /dev/null +++ b/migrations/dbconfig.yml @@ -0,0 +1,11 @@ +development: + dialect: sqlite3 + datasource: telly.db + dir: migrations + table: migrations + +production: + dialect: sqlite3 + datasource: telly.db + dir: migrations + table: migrations diff --git a/routes.go b/routes.go deleted file mode 100644 index 854e155..0000000 --- a/routes.go +++ /dev/null @@ -1,162 +0,0 @@ -package main - -import ( - "encoding/base64" - "fmt" - "net/http" - "time" - - "github.com/gin-gonic/gin" - ssdp "github.com/koron/go-ssdp" - "github.com/sirupsen/logrus" - ginprometheus "github.com/zsais/go-gin-prometheus" -) - -func serve(opts config) { - discoveryData := opts.DiscoveryData() - - log.Debugln("creating device xml") - upnp := discoveryData.UPNP() - - log.Debugln("creating webserver routes") - - gin.SetMode(gin.ReleaseMode) - - router := gin.New() - router.Use(gin.Recovery()) - - if opts.LogRequests { - router.Use(ginrus()) - } - - p := ginprometheus.NewPrometheus("http") - p.Use(router) - - router.GET("/", deviceXML(upnp)) - router.GET("/discover.json", discovery(discoveryData)) - router.GET("/lineup_status.json", lineupStatus(LineupStatus{ - ScanInProgress: 0, - ScanPossible: 1, - Source: "Cable", - SourceList: []string{"Cable"}, - })) - router.GET("/lineup.post", func(c *gin.Context) { - c.AbortWithStatus(http.StatusNotImplemented) - }) - router.GET("/device.xml", deviceXML(upnp)) - router.GET("/lineup.json", lineup(opts.lineup)) - router.GET("/stream/:channelID", stream) - - if opts.SSDP { - log.Debugln("advertising telly service on network via UPNP/SSDP") - if ssdpErr := setupSSDP(opts.BaseAddress.String(), opts.FriendlyName, opts.DeviceUUID); ssdpErr != nil { - log.WithError(ssdpErr).Errorln("telly cannot advertise over ssdp") - } - } - - log.Infof("Listening and serving HTTP on %s", opts.ListenAddress) - if err := router.Run(opts.ListenAddress.String()); err != nil { - log.WithError(err).Panicln("Error starting up web server") - } -} - -func deviceXML(deviceXML UPNP) gin.HandlerFunc { - return func(c *gin.Context) { - c.XML(http.StatusOK, deviceXML) - } -} - -func discovery(data DiscoveryData) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, data) - } -} - -func lineupStatus(status LineupStatus) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, status) - } -} - -func lineup(lineup []LineupItem) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, lineup) - } -} - -func stream(c *gin.Context) { - - channelID := c.Param("channelID") - - log.Debugf("Parsing URI %s to %s", c.Request.RequestURI, channelID) - - decodedStreamURI, decodeErr := base64.StdEncoding.DecodeString(channelID) - if decodeErr != nil { - log.WithError(decodeErr).Errorf("Invalid base64: %s", channelID) - c.AbortWithError(http.StatusBadRequest, decodeErr) // nolint: errcheck - return - } - - log.Debugln("Redirecting to:", string(decodedStreamURI)) - c.Redirect(http.StatusMovedPermanently, string(decodedStreamURI)) -} - -func ginrus() gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - // some evil middlewares modify this values - path := c.Request.URL.Path - c.Next() - - end := time.Now() - latency := end.Sub(start) - end = end.UTC() - - logFields := logrus.Fields{ - "status": c.Writer.Status(), - "method": c.Request.Method, - "path": path, - "ipAddress": c.ClientIP(), - "latency": latency, - "userAgent": c.Request.UserAgent(), - "time": end.Format(time.RFC3339), - } - - entry := log.WithFields(logFields) - - if len(c.Errors) > 0 { - // Append error field if this is an erroneous request. - entry.Error(c.Errors.String()) - } else { - entry.Info() - } - } -} - -func setupSSDP(baseAddress, deviceName, deviceUUID string) error { - log.Debugf("Advertising telly as %s (%s)", deviceName, deviceUUID) - - adv, err := ssdp.Advertise( - "upnp:rootdevice", - fmt.Sprintf("uuid:%s::upnp:rootdevice", deviceUUID), - fmt.Sprintf("http://%s/device.xml", baseAddress), - deviceName, - 1800) - - if err != nil { - return err - } - - go func(advertiser *ssdp.Advertiser) { - aliveTick := time.Tick(15 * time.Second) - - for { - <-aliveTick - if err := advertiser.Alive(); err != nil { - log.WithError(err).Panicln("error when sending ssdp heartbeat") - } - } - }(adv) - - return nil -} diff --git a/structs.go b/structs.go deleted file mode 100644 index 01eb301..0000000 --- a/structs.go +++ /dev/null @@ -1,136 +0,0 @@ -package main - -import ( - "encoding/xml" - "fmt" - "net" - "regexp" - "strconv" - - "github.com/tellytv/telly/m3u" -) - -type config struct { - RegexInclusive bool - Regex *regexp.Regexp - - DirectMode bool - M3UPath string - ConcurrentStreams int - StartingChannel int - - DeviceAuth string - DeviceID int - DeviceUUID string - FriendlyName string - Manufacturer string - ModelNumber string - FirmwareName string - FirmwareVersion string - SSDP bool - - LogRequests bool - LogLevel string - - ListenAddress *net.TCPAddr - BaseAddress *net.TCPAddr - - lineup []LineupItem -} - -func (c *config) DiscoveryData() DiscoveryData { - return DiscoveryData{ - FriendlyName: c.FriendlyName, - Manufacturer: c.Manufacturer, - ModelNumber: c.ModelNumber, - FirmwareName: c.FirmwareName, - TunerCount: c.ConcurrentStreams, - FirmwareVersion: c.FirmwareVersion, - DeviceID: strconv.Itoa(c.DeviceID), - DeviceAuth: c.DeviceAuth, - BaseURL: fmt.Sprintf("http://%s", c.BaseAddress), - LineupURL: fmt.Sprintf("http://%s/lineup.json", c.BaseAddress), - } -} - -// DiscoveryData contains data about telly to expose in the HDHomeRun format for Plex detection. -type DiscoveryData struct { - FriendlyName string - Manufacturer string - ModelNumber string - FirmwareName string - TunerCount int - FirmwareVersion string - DeviceID string - DeviceAuth string - BaseURL string - LineupURL string -} - -// UPNP returns the UPNP representation of the DiscoveryData. -func (d *DiscoveryData) UPNP() UPNP { - return UPNP{ - SpecVersion: upnpVersion{ - Major: 1, Minor: 0, - }, - URLBase: d.BaseURL, - Device: upnpDevice{ - DeviceType: "urn:schemas-upnp-org:device:MediaServer:1", - FriendlyName: d.FriendlyName, - Manufacturer: d.Manufacturer, - ModelName: d.ModelNumber, - ModelNumber: d.ModelNumber, - UDN: fmt.Sprintf("uuid:%s", d.DeviceID), - }, - } -} - -// LineupStatus exposes the status of the channel lineup. -type LineupStatus struct { - ScanInProgress int - ScanPossible int - Source string - SourceList []string -} - -// LineupItem is a single channel found in the playlist. -type LineupItem struct { - GuideNumber string - GuideName string - URL string -} - -// Track describes a single M3U segment. This struct includes m3u.Track as well as specific IPTV fields we want to get. -type Track struct { - *m3u.Track - Catchup string `m3u:"catchup"` - CatchupDays string `m3u:"catchup-days"` - CatchupSource string `m3u:"catchup-source"` - GroupTitle string `m3u:"group-title"` - TvgID string `m3u:"tvg-id"` - TvgLogo string `m3u:"tvg-logo"` - TvgName string `m3u:"tvg-name"` -} - -type upnpVersion struct { - Major int32 `xml:"major"` - Minor int32 `xml:"minor"` -} - -type upnpDevice struct { - DeviceType string `xml:"deviceType"` - FriendlyName string `xml:"friendlyName"` - Manufacturer string `xml:"manufacturer"` - ModelName string `xml:"modelName"` - ModelNumber string `xml:"modelNumber"` - SerialNumber string `xml:"serialNumber"` - UDN string `xml:"UDN"` -} - -// UPNP describes the UPNP/SSDP XML. -type UPNP struct { - XMLName xml.Name `xml:"urn:schemas-upnp-org:device-1-0 root"` - SpecVersion upnpVersion `xml:"specVersion"` - URLBase string `xml:"URLBase"` - Device upnpDevice `xml:"device"` -}