├── .dockerignore ├── .github ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ └── Bug_report.md ├── move.yml ├── stale.yml └── workflows │ ├── go-2.yml │ └── go.yml ├── .gitignore ├── .gitmodules ├── .gometalinter.json ├── .promu.yml ├── Dockerfile ├── Dockerfile.ffmpeg ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── NOTICE ├── README.md ├── VERSION ├── a_main-packr.go ├── go.mod ├── go.sum ├── internal ├── go-gin-prometheus │ └── middleware.go ├── m3uplus │ └── main.go ├── providers │ ├── area51.go │ ├── custom.go │ ├── eternal.go │ ├── hellraiser.go │ ├── iptv-epg.go │ ├── iris.go │ ├── main.go │ └── tnt.go └── xmltv │ ├── example.xml │ ├── xmltv.dtd │ ├── xmltv.go │ └── xmltv_test.go ├── lineup.go ├── main.go ├── routes.go ├── structs.go └── utils.go /.dockerignore: -------------------------------------------------------------------------------- 1 | *.m3u 2 | build/* 3 | .DS_Store 4 | /.GOPATH 5 | /bin 6 | *.xml 7 | vendor/ 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | **telly release with the issue:** 10 | 13 | 14 | 15 | **Last working telly release (if known):** 16 | 17 | 18 | **Operating environment (Docker/Windows/Linux/QNAP, etc.):** 19 | 22 | 23 | **Description of problem:** 24 | 25 | 26 | 27 | **Contents of `telly.config.toml` [if you're using a version above 1.1]:** 28 | - REMEMBER TO DELETE ANY CREDENTIALS IN CONFIG FILES OR COMMAND LINES 29 | ```toml 30 | 31 | ``` 32 | 33 | **Command line used to run telly [if applicable]:** 34 | - REMEMBER TO DELETE ANY CREDENTIALS IN CONFIG FILES OR COMMAND LINES 35 | ``` 36 | 37 | ``` 38 | 39 | **Docker run command used to run telly [if applicable]:** 40 | - REMEMBER TO DELETE ANY CREDENTIALS IN CONFIG FILES OR COMMAND LINES 41 | ``` 42 | 43 | ``` 44 | 45 | **telly or docker log:** 46 | ``` 47 | 48 | ``` 49 | 50 | **Additional information:** 51 | 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 14 | 15 | **telly release with the issue:** 16 | 19 | 20 | 21 | **Last working telly release (if known):** 22 | 23 | 24 | **Operating environment (Docker/Windows/Linux/QNAP, etc.):** 25 | 28 | 29 | **Description of problem:** 30 | 31 | 32 | 33 | **Contents of `telly.config.toml` [if you're using a version above 1.1]:** 34 | - REMEMBER TO DELETE ANY CREDENTIALS IN CONFIG FILES OR COMMAND LINES 35 | ```toml 36 | 37 | ``` 38 | 39 | **Command line used to run telly [if applicable]:** 40 | - REMEMBER TO DELETE ANY CREDENTIALS IN CONFIG FILES OR COMMAND LINES 41 | ``` 42 | 43 | ``` 44 | 45 | **Docker run command used to run telly [if applicable]:** 46 | - REMEMBER TO DELETE ANY CREDENTIALS IN CONFIG FILES OR COMMAND LINES 47 | ``` 48 | 49 | ``` 50 | 51 | **telly or docker log:** 52 | ``` 53 | 54 | ``` 55 | 56 | **Additional information:** 57 | -------------------------------------------------------------------------------- /.github/move.yml: -------------------------------------------------------------------------------- 1 | # Configuration for move-issues - https://github.com/dessant/move-issues 2 | 3 | # Delete the command comment when it contains no other content 4 | deleteCommand: true 5 | 6 | # Close the source issue after moving 7 | closeSourceIssue: true 8 | 9 | # Lock the source issue after moving 10 | lockSourceIssue: false 11 | 12 | # Mention issue and comment authors 13 | mentionAuthors: true 14 | 15 | # Preserve mentions in the issue content 16 | keepContentMentions: false 17 | 18 | # Set custom aliases for targets 19 | # aliases: 20 | # r: repo 21 | # or: owner/repo 22 | 23 | # Repository to extend settings from 24 | # _extends: repo 25 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 14 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 3 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/go-2.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Latest Release 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | jobs: 14 | lint: 15 | name: Lint files 16 | runs-on: 'ubuntu-latest' 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-go@v2 20 | with: 21 | go-version: '1.20' 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v2.5.2 24 | with: 25 | version: latest 26 | test: 27 | name: Run tests 28 | runs-on: 'ubuntu-latest' 29 | needs: lint 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions/setup-go@v2 33 | with: 34 | go-version: '1.20' 35 | - run: go test -v -cover 36 | release: 37 | name: Create Release 38 | runs-on: 'ubuntu-latest' 39 | needs: test 40 | strategy: 41 | matrix: 42 | goosarch: 43 | # - 'aix/ppc64' 44 | # - 'android/386' 45 | # - 'android/amd64' 46 | # - 'android/arm' 47 | # - 'android/arm64' 48 | - 'darwin/amd64' 49 | - 'darwin/arm64' 50 | # - 'dragonfly/amd64' 51 | # - 'freebsd/386' 52 | # - 'freebsd/amd64' 53 | # - 'freebsd/arm' 54 | # - 'freebsd/arm64' 55 | # - 'illumos/amd64' 56 | # - 'ios/amd64' 57 | # - 'ios/arm64' 58 | # - 'js/wasm' 59 | # - 'linux/386' 60 | - 'linux/amd64' 61 | # - 'linux/arm' 62 | - 'linux/arm64' 63 | # - 'linux/mips' 64 | # - 'linux/mips64' 65 | # - 'linux/mips64le' 66 | # - 'linux/mipsle' 67 | # - 'linux/ppc64' 68 | # - 'linux/ppc64le' 69 | # - 'linux/riscv64' 70 | # - 'linux/s390x' 71 | # - 'netbsd/386' 72 | # - 'netbsd/amd64' 73 | # - 'netbsd/arm' 74 | # - 'netbsd/arm64' 75 | # - 'openbsd/386' 76 | # - 'openbsd/amd64' 77 | # - 'openbsd/arm' 78 | # - 'openbsd/arm64' 79 | # - 'openbsd/mips64' 80 | # - 'plan9/386' 81 | # - 'plan9/amd64' 82 | # - 'plan9/arm' 83 | # - 'solaris/amd64' 84 | # - 'windows/386' 85 | - 'windows/amd64' 86 | - 'windows/arm' 87 | steps: 88 | - name: Checkout code 89 | uses: actions/checkout@v2 90 | with: 91 | fetch-depth: 0 92 | - uses: actions/setup-go@v2 93 | with: 94 | go-version: '1.20' 95 | - name: Get OS and arch info 96 | run: | 97 | GOOSARCH=${{matrix.goosarch}} 98 | GOOS=${GOOSARCH%/*} 99 | GOARCH=${GOOSARCH#*/} 100 | BINARY_NAME=${{github.repository}}-$GOOS-$GOARCH 101 | echo "BINARY_NAME=$BINARY_NAME" >> $GITHUB_ENV 102 | echo "GOOS=$GOOS" >> $GITHUB_ENV 103 | echo "GOARCH=$GOARCH" >> $GITHUB_ENV 104 | - name: Build 105 | run: | 106 | go build -o "$BINARY_NAME" -v 107 | - name: Release Notes 108 | run: 109 | git log $(git describe HEAD~ --tags --abbrev=0)..HEAD --pretty='format:* %h %s%n * %an <%ae>' --no-merges >> ".github/RELEASE-TEMPLATE.md" 110 | - name: Release with Notes 111 | uses: softprops/action-gh-release@v1 112 | with: 113 | body_path: ".github/RELEASE-TEMPLATE.md" 114 | draft: true 115 | files: ${{env.BINARY_NAME}} 116 | env: 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Build Telly 5 | 6 | on: 7 | push: 8 | branches: [ "dev" ] 9 | pull_request: 10 | branches: [ "dev" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.20' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.m3u 2 | build/* 3 | telly 4 | .DS_Store 5 | /.GOPATH 6 | /bin 7 | /*.xml 8 | vendor/ 9 | /.build 10 | /.release 11 | /.tarballs 12 | *.tar.gz 13 | telly.config.* 14 | .idea/* 15 | telly.db 16 | a_main-packr.go -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "frontend"] 2 | path = frontend 3 | url = https://github.com/tellytv/frontend.git 4 | -------------------------------------------------------------------------------- /.gometalinter.json: -------------------------------------------------------------------------------- 1 | { 2 | "Enable": [ 3 | "deadcode", 4 | "errcheck", 5 | "gochecknoinits", 6 | "goconst", 7 | "gofmt", 8 | "goimports", 9 | "golint", 10 | "gosec", 11 | "gotype", 12 | "gotypex", 13 | "ineffassign", 14 | "interfacer", 15 | "megacheck", 16 | "misspell", 17 | "nakedret", 18 | "safesql", 19 | "structcheck", 20 | "test", 21 | "testify", 22 | "unconvert", 23 | "unparam", 24 | "varcheck", 25 | "vet", 26 | "vetshadow" 27 | ], 28 | "Deadline": "5m", 29 | "Sort": [ 30 | "path", 31 | "linter" 32 | ], 33 | "Vendor": true 34 | } 35 | -------------------------------------------------------------------------------- /.promu.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | path: github.com/tellytv/telly 3 | build: 4 | flags: -a -tags netgo 5 | ldflags: | 6 | -X github.com/prometheus/common/version.Version={{.Version}} 7 | -X github.com/prometheus/common/version.Revision={{.Revision}} 8 | -X github.com/prometheus/common/version.Branch={{.Branch}} 9 | -X github.com/prometheus/common/version.BuildUser={{user}}@{{host}} 10 | -X github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} 11 | tarball: 12 | files: 13 | - LICENSE 14 | - NOTICE 15 | crossbuild: 16 | platforms: 17 | - linux/386 18 | - linux/amd64 19 | - linux/arm 20 | - linux/arm64 21 | - darwin/amd64 22 | - windows/amd64 23 | - windows/386 24 | - freebsd/amd64 25 | - freebsd/386 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY .build/linux-amd64/telly ./app 3 | EXPOSE 6077 4 | ENTRYPOINT ["./app"] 5 | 6 | 7 | -------------------------------------------------------------------------------- /Dockerfile.ffmpeg: -------------------------------------------------------------------------------- 1 | FROM jrottenberg/ffmpeg:4.0-alpine 2 | COPY --from=tellytv/telly:dev /app /app 3 | EXPOSE 6077 4 | ENTRYPOINT ["/app"] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tom Bowditch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | * Tom Bowditch 2 | * Robbie Trencheny 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO := go 2 | GOPATH ?= $(HOME)/go 3 | PROMU := $(GOPATH)/bin/promu 4 | 5 | PREFIX ?= $(shell pwd) 6 | BIN_DIR ?= $(shell pwd) 7 | DOCKER_IMAGE_NAME ?= telly 8 | DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) 9 | 10 | 11 | all: format build test 12 | 13 | style: 14 | @echo ">> checking code style" 15 | @! gofmt -d $(shell find . -path ./vendor -prune -o -name '*.go' -print) | grep '^' 16 | 17 | test: 18 | @echo ">> running tests" 19 | @$(GO) test -short ./... 20 | 21 | format: 22 | @echo ">> formatting code" 23 | @$(GO) fmt ./... 24 | 25 | vet: 26 | @echo ">> vetting code" 27 | @$(GO) vet ./... 28 | 29 | cross: promu 30 | @echo ">> crossbuilding binaries" 31 | @$(PROMU) crossbuild 32 | 33 | tarballs: promu cross 34 | @echo ">> creating release tarballs" 35 | @$(PROMU) crossbuild tarballs 36 | 37 | build: promu 38 | @echo ">> building binaries" 39 | @$(PROMU) build --prefix $(PREFIX) 40 | 41 | tarball: promu 42 | @echo ">> building release tarball" 43 | @$(PROMU) tarball $(BIN_DIR) 44 | 45 | docker: cross 46 | @echo ">> building docker image" 47 | @docker build -t "$(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG)" . 48 | 49 | promu: 50 | @GO111MODULE=off \ 51 | GOOS=$(shell uname -s | tr A-Z a-z) \ 52 | GOARCH=$(subst x86_64,amd64,$(patsubst i%86,386,$(shell uname -m))) \ 53 | $(GO) get -u github.com/prometheus/promu 54 | 55 | 56 | .PHONY: all style format build test vet tarball docker promu 57 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | An IPTV proxy for Plex Live DVR. 2 | Copyright 2018 Tom Bowditch. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # telly 2 | 3 | IPTV proxy for Plex Live written in Golang 4 | 5 | Please refer to the [Wiki](https://github.com/tellytv/telly/wiki) for the most current documentation. 6 | 7 | ## This readme refers to version 1.1.x . It does not apply to versions other than that. 8 | 9 | The [Wiki](https://github.com/tellytv/telly/wiki) includes walkthroughs for most platforms that go into more detail than listed below: 10 | 11 | ## THIS IS A DEVELOPMENT BRANCH 12 | 13 | It is under active development and things may change quickly and dramatically. Please join the discord server if you use this branch and be prepared for some tinkering and breakage. 14 | 15 | # Configuration 16 | 17 | Here's an example configuration file. **You will need to create this file.** It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. 18 | 19 | > NOTE "the directory telly is running from" is your CURRENT WORKING DIRECTORY. For example, if telly and its config file file are in `/opt/telly/` and you run telly from your home directory, telly will not find its config file because it will be looking for it in your home directory. If this makes little sense to you, use one of the other two locations OR cd into the directory where telly is located before running it from the command line. 20 | 21 | > ATTENTION Windows users: be sure that there isn’t a hidden extension on the file. Telly can't read its config file if it's named something like `telly.config.toml.txt`. 22 | 23 | ```toml 24 | # THIS SECTION IS REQUIRED ######################################################################## 25 | [Discovery] # most likely you won't need to change anything here 26 | Device-Auth = "telly123" # These settings are all related to how telly identifies 27 | Device-ID = "12345678" # itself to Plex. 28 | Device-UUID = "" 29 | Device-Firmware-Name = "hdhomeruntc_atsc" 30 | Device-Firmware-Version = "20150826" 31 | Device-Friendly-Name = "telly" 32 | Device-Manufacturer = "Silicondust" 33 | Device-Model-Number = "HDTC-2US" 34 | SSDP = true 35 | 36 | # Note on running multiple instances of telly 37 | # There are three things that make up a "key" for a given Telly Virtual DVR: 38 | # Device-ID [required], Device-UUID [optional], and port [required] 39 | # When you configure your additional telly instances, change: 40 | # the Device-ID [above] AND 41 | # the Device-UUID [above, if you're entering one] AND 42 | # the port [below in the "Web" section] 43 | 44 | # THIS SECTION IS REQUIRED ######################################################################## 45 | [IPTV] 46 | Streams = 1 # number of simultaneous streams that the telly virtual DVR will provide 47 | # This is often 1, but is set by your iptv provider; for example, 48 | # Vaders provides 5 49 | Starting-Channel = 10000 # When telly assigns channel numbers it will start here 50 | XMLTV-Channels = true # if true, any channel numbers specified in your M3U file will be used. 51 | # FFMpeg = true # if this is uncommented, streams are buffered through ffmpeg; 52 | # ffmpeg must be installed and on your $PATH 53 | # if you want to use this with Docker, be sure you use the correct docker image 54 | # if you DO NOT WANT TO USE FFMPEG leave this commented; DO NOT SET IT TO FALSE 55 | 56 | # THIS SECTION IS REQUIRED ######################################################################## 57 | [Log] 58 | Level = "info" # Only log messages at or above the given level. [debug, info, warn, error, fatal] 59 | Requests = true # Log HTTP requests made to telly 60 | 61 | # THIS SECTION IS REQUIRED ######################################################################## 62 | [Web] 63 | Base-Address = "0.0.0.0:6077" # Set this to the IP address of the machine telly runs on 64 | Listen-Address = "0.0.0.0:6077" # this can stay as-is 65 | 66 | # THIS SECTION IS NOT USEFUL ====================================================================== 67 | #[SchedulesDirect] # If you have a Schedules Direct account, fill in details and then 68 | # UNCOMMENT THIS SECTION 69 | # Username = "" # This is under construction; no provider 70 | # Password = "" # works with it at this time 71 | 72 | # AT LEAST ONE SOURCE IS REQUIRED ################################################################# 73 | # NONE OF THESE EXAMPLES WORK AS-IS; IF YOU DON'T CHANGE IT, DELETE IT ############################ 74 | [[Source]] 75 | Name = "" # Name is optional and is used mostly for logging purposes 76 | Provider = "Custom" # DO NOT CHANGE THIS IF YOU ARE ENTERING URLS OR FILE PATHS 77 | # "Custom" is telly's internal identifier for this 'Provider' 78 | # If you change it to "NAMEOFPROVIDER" telly's reaction will be 79 | # "I don't recognize a provider called 'NAMEOFPROVIDER'." 80 | M3U = "http://myprovider.com/playlist.m3u" # These can be either URLs or fully-qualified paths. 81 | EPG = "http://myprovider.com/epg.xml" 82 | # THE FOLLOWING KEYS ARE OPTIONAL IN THEORY, REQUIRED IN PRACTICE 83 | Filter = "Sports|Premium Movies|United States.*|USA" 84 | FilterKey = "group-title" # FilterKey normally defaults to whatever the provider file says is best, 85 | # otherwise you must set this. 86 | FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. 87 | Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided 88 | # END TELLY CONFIG ############################################################################### 89 | ``` 90 | 91 | # FFMpeg 92 | 93 | Telly can buffer the streams to Plex through ffmpeg. This has the potential for several benefits, but today it primarily: 94 | 95 | 1. Allows support for stream formats that may cause problems for Plex directly. 96 | 1. Eliminates the use of redirects and makes it possible for telly to report exactly why a given stream failed. 97 | 98 | To take advantage of this, ffmpeg must be installed and available in your path. 99 | 100 | # Docker 101 | 102 | There are two different docker images available: 103 | 104 | ## tellytv/telly:dev 105 | The standard docker image for the dev branch 106 | 107 | ## tellytv/telly:dev-ffmpeg 108 | This docker image has ffmpeg preinstalled. If you want to use the ffmpeg feature, use this image. It may be safest to use this image generally, since it is not much larger than the standard image and allows you to turn the ffmpeg features on and off without requiring changes to your docker run command. The examples below use this image. 109 | 110 | ## `docker run` 111 | ``` 112 | docker run -d \ 113 | --name='telly' \ 114 | --net='bridge' \ 115 | -e TZ="America/Chicago" \ 116 | -p '6077:6077/tcp' \ 117 | -v /host/path/to/telly.config.toml:/etc/telly/telly.config.toml \ 118 | --restart unless-stopped \ 119 | tellytv/telly:dev-ffmpeg 120 | ``` 121 | 122 | ## docker-compose 123 | ``` 124 | telly: 125 | image: tellytv/telly:dev-ffmpeg 126 | ports: 127 | - "6077:6077" 128 | environment: 129 | - TZ=Europe/Amsterdam 130 | volumes: 131 | - /host/path/to/telly.config.toml:/etc/telly/telly.config.toml 132 | restart: unless-stopped 133 | ``` 134 | 135 | # Troubleshooting 136 | 137 | 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. 138 | 139 | # Social 140 | 141 | We have [a Discord server you can join!](https://discord.gg/bnNC8qX) 142 | 143 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.1.0.8 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tellytv/telly 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/beorn7/perks v1.0.0 7 | github.com/fsnotify/fsnotify v1.4.7 8 | github.com/gin-contrib/cors v0.0.0-20170318125340-cf4846e6a636 9 | github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 10 | github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07 11 | github.com/go-logfmt/logfmt v0.4.0 // indirect 12 | github.com/gobuffalo/depgen v0.1.1 // indirect 13 | github.com/gobuffalo/genny v0.1.1 // indirect 14 | github.com/gobuffalo/gogen v0.1.1 // indirect 15 | github.com/gobuffalo/packr v1.25.0 16 | github.com/gogo/protobuf v1.2.1 // indirect 17 | github.com/golang/protobuf v1.3.1 18 | github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce 19 | github.com/karrick/godirwalk v1.10.0 // indirect 20 | github.com/kisielk/errcheck v1.2.0 // indirect 21 | github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b 22 | github.com/kr/pretty v0.1.0 23 | github.com/kr/pty v1.1.4 // indirect 24 | github.com/kr/text v0.1.0 25 | github.com/magiconair/properties v1.8.0 26 | github.com/markbates/grift v1.0.1 // indirect 27 | github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c 28 | github.com/matttproud/golang_protobuf_extensions v1.0.1 29 | github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699 30 | github.com/pelletier/go-toml v1.2.0 31 | github.com/pkg/errors v0.8.1 32 | github.com/prometheus/client_golang v0.9.2 33 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 34 | github.com/prometheus/common v0.3.0 35 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 36 | github.com/prometheus/promu v0.3.0 // indirect 37 | github.com/sirupsen/logrus v1.4.1 38 | github.com/spf13/afero v1.1.1 39 | github.com/spf13/cast v1.2.0 40 | github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834 41 | github.com/spf13/pflag v1.0.3 42 | github.com/spf13/viper v1.1.0 43 | github.com/stretchr/objx v0.2.0 // indirect 44 | github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77 45 | github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022 46 | golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284 47 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c 48 | golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862 49 | golang.org/x/text v0.3.2 50 | golang.org/x/tools v0.0.0-20190509014725-d996b19ee77c // indirect 51 | gopkg.in/go-playground/validator.v8 v8.18.1 52 | gopkg.in/yaml.v2 v2.2.2 53 | ) 54 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= 2 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 3 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= 4 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 5 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= 6 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 7 | github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= 8 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 12 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 13 | github.com/gin-contrib/cors v0.0.0-20170318125340-cf4846e6a636 h1:oGgJA7DJphAc81EMHZ+2G7Ai2xyg5eoq7bbqzCsiWFc= 14 | github.com/gin-contrib/cors v0.0.0-20170318125340-cf4846e6a636/go.mod h1:cw+u9IsAkC16e42NtYYVCLsHYXE98nB3M7Dr9mLSeH4= 15 | github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cougNAV9szl6CVoj2RYwzS3DpUQNtlY= 16 | github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= 17 | github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07 h1:cZPJWzd2oNeoS0oJM2TlN9rl0OnCgUr10gC8Q4mH+6M= 18 | github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= 19 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 20 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 21 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 22 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 23 | github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= 24 | github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= 25 | github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= 26 | github.com/gobuffalo/depgen v0.1.1/go.mod h1:65EOv3g7CMe4kc8J1Ds+l2bjcwrWKGXkE4/vpRRLPWY= 27 | github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= 28 | github.com/gobuffalo/envy v1.7.0 h1:GlXgaiBkmrYMHco6t4j7SacKO4XUjvh5pwXh0f4uxXU= 29 | github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= 30 | github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= 31 | github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= 32 | github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= 33 | github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= 34 | github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= 35 | github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= 36 | github.com/gobuffalo/genny v0.1.1 h1:iQ0D6SpNXIxu52WESsD+KoQ7af2e3nCfnSBoSF/hKe0= 37 | github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= 38 | github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= 39 | github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= 40 | github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= 41 | github.com/gobuffalo/gogen v0.1.1 h1:dLg+zb+uOyd/mKeQUYIbwbNmfRsr9hd/WtYWepmayhI= 42 | github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= 43 | github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2 h1:8thhT+kUJMTMy3HlX4+y9Da+BNJck+p109tqqKp7WDs= 44 | github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= 45 | github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= 46 | github.com/gobuffalo/mapi v1.0.2 h1:fq9WcL1BYrm36SzK6+aAnZ8hcp+SrmnDyAxhNx8dvJk= 47 | github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= 48 | github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= 49 | github.com/gobuffalo/packd v0.1.0 h1:4sGKOD8yaYJ+dek1FDkwcxCHA40M4kfKgFHx8N2kwbU= 50 | github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= 51 | github.com/gobuffalo/packr v1.13.2 h1:fQmeSiOMhl+4U+da7VmX2AjdcCaSOi5IvnqsSXdKYmQ= 52 | github.com/gobuffalo/packr v1.13.2/go.mod h1:qdqw8AgJyKw60qj56fnEBiS9fIqqCaP/vWJQvR4Jcss= 53 | github.com/gobuffalo/packr v1.25.0 h1:NtPK45yOKFdTKHTvRGKL+UIKAKmJVWIVJOZBDI/qEdY= 54 | github.com/gobuffalo/packr v1.25.0/go.mod h1:NqsGg8CSB2ZD+6RBIRs18G7aZqdYDlYNNvsSqP6T4/U= 55 | github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= 56 | github.com/gobuffalo/packr/v2 v2.1.0/go.mod h1:n90ZuXIc2KN2vFAOQascnPItp9A2g9QYSvYvS3AjQEM= 57 | github.com/gobuffalo/packr/v2 v2.2.0 h1:Ir9W9XIm9j7bhhkKE9cokvtTl1vBm62A/fene/ZCj6A= 58 | github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= 59 | github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754 h1:tpom+2CJmpzAWj5/VEHync2rJGi+epHNIeRSWjzGA+4= 60 | github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= 61 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 62 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 63 | github.com/golang/protobuf v1.1.0 h1:0iH4Ffd/meGoXqF2lSAhZHt8X+cPgkfn/cb6Cce5Vpc= 64 | github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 65 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 66 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 67 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 68 | github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce h1:xdsDDbiBDQTKASoGEZ+pEmF1OnWuu8AQ9I8iNbHNeno= 69 | github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= 70 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 71 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 72 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 73 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 74 | github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= 75 | github.com/karrick/godirwalk v1.10.0 h1:fb2G3xs9hsG0CmH6fnx6sxTsvNeDQtcsIegljcXRQGU= 76 | github.com/karrick/godirwalk v1.10.0/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= 77 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 78 | github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= 79 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 80 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 81 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 82 | github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b h1:wxtKgYHEncAU00muMD06dzLiahtGM1eouRNOzVV7tdQ= 83 | github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b/go.mod h1:5Ky9EC2xfoUKUor0Hjgi2BJhCSXJfMOFlmyYrVKGQMk= 84 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 85 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 86 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 87 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 88 | github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 89 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 90 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 91 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 92 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 93 | github.com/markbates/grift v1.0.1/go.mod h1:aC7s7OfCOzc2WCafmTm7wI3cfGFA/8opYhdTGlIAmmo= 94 | github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2 h1:JgVTCPf0uBVcUSWpyXmGpgOc62nK5HWUBKAGc3Qqa5k= 95 | github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= 96 | github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= 97 | github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= 98 | github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c h1:AHfQR/s6GNi92TOh+kfGworqDvTxj2rMsS+Hca87nck= 99 | github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 100 | github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 101 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 102 | github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699 h1:KXZJFdun9knAVAR8tg/aHJEr5DgtcbqyvzacK+CDCaI= 103 | github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 104 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 105 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 106 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 107 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 108 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 109 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 110 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 111 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 112 | github.com/prometheus/client_golang v0.8.0 h1:1921Yw9Gc3iSc4VQh3PIoOqgPCZS7G/4xQNVUp8Mda8= 113 | github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 114 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 115 | github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= 116 | github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= 117 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= 118 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 119 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= 120 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 121 | github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e h1:n/3MEhJQjQxrOUCzh1Y3Re6aJUUWRp2M9+Oc3eVn/54= 122 | github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 123 | github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 124 | github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 125 | github.com/prometheus/common v0.3.0 h1:taZ4h8Tkxv2kNyoSctBvfXEHmBmxrwmIidZTIaHons4= 126 | github.com/prometheus/common v0.3.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 127 | github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 h1:agujYaXJSxSo18YNX3jzl+4G6Bstwt+kqv47GS12uL0= 128 | github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 129 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 130 | github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 131 | github.com/prometheus/procfs v0.0.0-20190209105433-f8d8b3f739bd/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 132 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY= 133 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 134 | github.com/prometheus/promu v0.3.0 h1:ecIZ1FIjQ+PAneA6g0KpUa7FDimozQtDjzI2rW0Pmh0= 135 | github.com/prometheus/promu v0.3.0/go.mod h1:+NXvSS3J95z3ZmFZP0DXUt+g/I6zyK1CQoBJKkjzX4k= 136 | github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 137 | github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 138 | github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk= 139 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 140 | github.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s= 141 | github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= 142 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 143 | github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 144 | github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= 145 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 146 | github.com/spf13/afero v1.1.1 h1:Lt3ihYMlE+lreX1GS4Qw4ZsNpYQLxIXKBTEOXm3nt6I= 147 | github.com/spf13/afero v1.1.1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 148 | github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg= 149 | github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= 150 | github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= 151 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 152 | github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834 h1:kJI9pPzfsULT/72wy7mxkRQZPtKWgFdCA2RTGZ4v8/E= 153 | github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 154 | github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc= 155 | github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 156 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 157 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 158 | github.com/spf13/viper v1.1.0 h1:V7OZpY8i3C1x/pDmU0zNNlfVoDz112fSYvtWMjjS3f4= 159 | github.com/spf13/viper v1.1.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= 160 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 161 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 162 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 163 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 164 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 165 | github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77 h1:eZWUcYXkpSpcwKyc/GXRMv+l4pGf47wQp5QCplO/66o= 166 | github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77/go.mod h1:pBZcxidsU285nwpDZ3NQIONgAyOo4wiUoOutTMu7KU4= 167 | github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022 h1:wIYK3i9zY6ZBcWw4GFvoPVwtb45iEm8KyOVmDhSLvsE= 168 | github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ= 169 | golang.org/x/crypto v0.0.0-20180808211826-de0752318171 h1:vYogbvSFj2YXcjQxFHu/rASSOt9sLytpCaSkiwQ135I= 170 | golang.org/x/crypto v0.0.0-20180808211826-de0752318171/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 171 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 172 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 173 | golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 174 | golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284 h1:rlLehGeYg6jfoyz/eDqDU1iRXLKfR42nnNh57ytKEWo= 175 | golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 176 | golang.org/x/net v0.0.0-20180811021610-c39426892332 h1:efGso+ep0DjyCBJPjvoz0HI6UldX4Md2F1rZFe1ir0E= 177 | golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 178 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 179 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 180 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 181 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 182 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw= 183 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 184 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 185 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 186 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 187 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 188 | golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 189 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= 190 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 191 | golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0 h1:8H8QZJ30plJyIVj60H3lr8TZGIq2Fh3Cyrs/ZNg1foU= 192 | golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 193 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 194 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 195 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 196 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 197 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 198 | golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 199 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b h1:ag/x1USPSsqHud38I9BAC88qdNLDHHtQ4mlgQIZPPNA= 200 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 201 | golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862 h1:rM0ROo5vb9AdYJi1110yjWGMej9ITfKddS89P3Fkhug= 202 | golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 203 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 204 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 205 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 206 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 207 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 208 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 209 | golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 210 | golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 211 | golang.org/x/tools v0.0.0-20190404132500-923d25813098/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 212 | golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 213 | golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 214 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 215 | golang.org/x/tools v0.0.0-20190509014725-d996b19ee77c h1:FsgttePhaNW32agh7vOjhKj0IuEmI/TmGumOc4z9yEs= 216 | golang.org/x/tools v0.0.0-20190509014725-d996b19ee77c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 217 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 218 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 219 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 220 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 221 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 222 | gopkg.in/go-playground/validator.v8 v8.18.1 h1:F8SLY5Vqesjs1nI1EL4qmF1PQZ1sitsmq0rPYXLyfGU= 223 | gopkg.in/go-playground/validator.v8 v8.18.1/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= 224 | gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556 h1:hKXbLW5oaJoQgs8KrzTLdF4PoHi+0oQPgea9TNtvE3E= 225 | gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 226 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 227 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 228 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 229 | -------------------------------------------------------------------------------- /internal/go-gin-prometheus/middleware.go: -------------------------------------------------------------------------------- 1 | // Package ginprometheus provides a Logrus logger for Gin requests. Slightly modified to remove spammy logs. 2 | // For more info see https://github.com/zsais/go-gin-prometheus/pull/22. 3 | package ginprometheus 4 | 5 | import ( 6 | "bytes" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/prometheus/client_golang/prometheus" 15 | "github.com/prometheus/client_golang/prometheus/promhttp" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | var defaultMetricPath = "/metrics" 20 | 21 | // Standard default metrics 22 | // counter, counter_vec, gauge, gauge_vec, 23 | // histogram, histogram_vec, summary, summary_vec 24 | var reqCnt = &Metric{ 25 | ID: "reqCnt", 26 | Name: "requests_total", 27 | Description: "How many HTTP requests processed, partitioned by status code and HTTP method.", 28 | Type: "counter_vec", 29 | Args: []string{"code", "method", "handler", "host", "url"}} 30 | 31 | var reqDur = &Metric{ 32 | ID: "reqDur", 33 | Name: "request_duration_seconds", 34 | Description: "The HTTP request latencies in seconds.", 35 | Type: "summary"} 36 | 37 | var resSz = &Metric{ 38 | ID: "resSz", 39 | Name: "response_size_bytes", 40 | Description: "The HTTP response sizes in bytes.", 41 | Type: "summary"} 42 | 43 | var reqSz = &Metric{ 44 | ID: "reqSz", 45 | Name: "request_size_bytes", 46 | Description: "The HTTP request sizes in bytes.", 47 | Type: "summary"} 48 | 49 | var standardMetrics = []*Metric{ 50 | reqCnt, 51 | reqDur, 52 | resSz, 53 | reqSz, 54 | } 55 | 56 | /* 57 | RequestCounterURLLabelMappingFn is a function which can be supplied to the middleware to control 58 | the cardinality of the request counter's "url" label, which might be required in some contexts. 59 | For instance, if for a "/customer/:name" route you don't want to generate a time series for every 60 | possible customer name, you could use this function: 61 | func(c *gin.Context) string { 62 | url := c.Request.URL.String() 63 | for _, p := range c.Params { 64 | if p.Key == "name" { 65 | url = strings.Replace(url, p.Value, ":name", 1) 66 | break 67 | } 68 | } 69 | return url 70 | } 71 | which would map "/customer/alice" and "/customer/bob" to their template "/customer/:name". 72 | */ 73 | type RequestCounterURLLabelMappingFn func(c *gin.Context) string 74 | 75 | // Metric is a definition for the name, description, type, ID, and 76 | // prometheus.Collector type (i.e. CounterVec, Summary, etc) of each metric 77 | type Metric struct { 78 | MetricCollector prometheus.Collector 79 | ID string 80 | Name string 81 | Description string 82 | Type string 83 | Args []string 84 | } 85 | 86 | // Prometheus contains the metrics gathered by the instance and its path 87 | type Prometheus struct { 88 | reqCnt *prometheus.CounterVec 89 | reqDur, reqSz, resSz prometheus.Summary 90 | router *gin.Engine 91 | listenAddress string 92 | Ppg PrometheusPushGateway 93 | 94 | MetricsList []*Metric 95 | MetricsPath string 96 | 97 | ReqCntURLLabelMappingFn RequestCounterURLLabelMappingFn 98 | } 99 | 100 | // PrometheusPushGateway contains the configuration for pushing to a Prometheus pushgateway (optional) 101 | type PrometheusPushGateway struct { 102 | 103 | // Push interval in seconds 104 | PushIntervalSeconds time.Duration 105 | 106 | // Push Gateway URL in format http://domain:port 107 | // where JOBNAME can be any string of your choice 108 | PushGatewayURL string 109 | 110 | // Local metrics URL where metrics are fetched from, this could be ommited in the future 111 | // if implemented using prometheus common/expfmt instead 112 | MetricsURL string 113 | 114 | // pushgateway job name, defaults to "gin" 115 | Job string 116 | } 117 | 118 | // NewPrometheus generates a new set of metrics with a certain subsystem name 119 | func NewPrometheus(subsystem string, customMetricsList ...[]*Metric) *Prometheus { 120 | 121 | var metricsList []*Metric 122 | 123 | if len(customMetricsList) > 1 { 124 | panic("Too many args. NewPrometheus( string, ).") 125 | } else if len(customMetricsList) == 1 { 126 | metricsList = customMetricsList[0] 127 | } 128 | 129 | for _, metric := range standardMetrics { 130 | metricsList = append(metricsList, metric) 131 | } 132 | 133 | p := &Prometheus{ 134 | MetricsList: metricsList, 135 | MetricsPath: defaultMetricPath, 136 | ReqCntURLLabelMappingFn: func(c *gin.Context) string { 137 | return c.Request.URL.String() // i.e. by default do nothing, i.e. return URL as is 138 | }, 139 | } 140 | 141 | p.registerMetrics(subsystem) 142 | 143 | return p 144 | } 145 | 146 | // SetPushGateway sends metrics to a remote pushgateway exposed on pushGatewayURL 147 | // every pushIntervalSeconds. Metrics are fetched from metricsURL 148 | func (p *Prometheus) SetPushGateway(pushGatewayURL, metricsURL string, pushIntervalSeconds time.Duration) { 149 | p.Ppg.PushGatewayURL = pushGatewayURL 150 | p.Ppg.MetricsURL = metricsURL 151 | p.Ppg.PushIntervalSeconds = pushIntervalSeconds 152 | p.startPushTicker() 153 | } 154 | 155 | // SetPushGatewayJob job name, defaults to "gin" 156 | func (p *Prometheus) SetPushGatewayJob(j string) { 157 | p.Ppg.Job = j 158 | } 159 | 160 | // SetListenAddress for exposing metrics on address. If not set, it will be exposed at the 161 | // same address of the gin engine that is being used 162 | func (p *Prometheus) SetListenAddress(address string) { 163 | p.listenAddress = address 164 | if p.listenAddress != "" { 165 | p.router = gin.Default() 166 | } 167 | } 168 | 169 | // SetListenAddressWithRouter for using a separate router to expose metrics. (this keeps things like GET /metrics out of 170 | // your content's access log). 171 | func (p *Prometheus) SetListenAddressWithRouter(listenAddress string, r *gin.Engine) { 172 | p.listenAddress = listenAddress 173 | if len(p.listenAddress) > 0 { 174 | p.router = r 175 | } 176 | } 177 | 178 | func (p *Prometheus) setMetricsPath(e *gin.Engine) { 179 | 180 | if p.listenAddress != "" { 181 | p.router.GET(p.MetricsPath, prometheusHandler()) 182 | p.runServer() 183 | } else { 184 | e.GET(p.MetricsPath, prometheusHandler()) 185 | } 186 | } 187 | 188 | func (p *Prometheus) setMetricsPathWithAuth(e *gin.Engine, accounts gin.Accounts) { 189 | 190 | if p.listenAddress != "" { 191 | p.router.GET(p.MetricsPath, gin.BasicAuth(accounts), prometheusHandler()) 192 | p.runServer() 193 | } else { 194 | e.GET(p.MetricsPath, gin.BasicAuth(accounts), prometheusHandler()) 195 | } 196 | 197 | } 198 | 199 | func (p *Prometheus) runServer() { 200 | if p.listenAddress != "" { 201 | go p.router.Run(p.listenAddress) 202 | } 203 | } 204 | 205 | func (p *Prometheus) getMetrics() []byte { 206 | response, _ := http.Get(p.Ppg.MetricsURL) 207 | 208 | defer response.Body.Close() 209 | body, _ := ioutil.ReadAll(response.Body) 210 | 211 | return body 212 | } 213 | 214 | func (p *Prometheus) getPushGatewayURL() string { 215 | h, _ := os.Hostname() 216 | if p.Ppg.Job == "" { 217 | p.Ppg.Job = "gin" 218 | } 219 | return p.Ppg.PushGatewayURL + "/metrics/job/" + p.Ppg.Job + "/instance/" + h 220 | } 221 | 222 | func (p *Prometheus) sendMetricsToPushGateway(metrics []byte) { 223 | req, err := http.NewRequest("POST", p.getPushGatewayURL(), bytes.NewBuffer(metrics)) 224 | client := &http.Client{} 225 | if _, err = client.Do(req); err != nil { 226 | log.WithError(err).Errorln("Error sending to push gateway") 227 | } 228 | } 229 | 230 | func (p *Prometheus) startPushTicker() { 231 | ticker := time.NewTicker(time.Second * p.Ppg.PushIntervalSeconds) 232 | go func() { 233 | for range ticker.C { 234 | p.sendMetricsToPushGateway(p.getMetrics()) 235 | } 236 | }() 237 | } 238 | 239 | // NewMetric associates prometheus.Collector based on Metric.Type 240 | func NewMetric(m *Metric, subsystem string) prometheus.Collector { 241 | var metric prometheus.Collector 242 | switch m.Type { 243 | case "counter_vec": 244 | metric = prometheus.NewCounterVec( 245 | prometheus.CounterOpts{ 246 | Subsystem: subsystem, 247 | Name: m.Name, 248 | Help: m.Description, 249 | }, 250 | m.Args, 251 | ) 252 | case "counter": 253 | metric = prometheus.NewCounter( 254 | prometheus.CounterOpts{ 255 | Subsystem: subsystem, 256 | Name: m.Name, 257 | Help: m.Description, 258 | }, 259 | ) 260 | case "gauge_vec": 261 | metric = prometheus.NewGaugeVec( 262 | prometheus.GaugeOpts{ 263 | Subsystem: subsystem, 264 | Name: m.Name, 265 | Help: m.Description, 266 | }, 267 | m.Args, 268 | ) 269 | case "gauge": 270 | metric = prometheus.NewGauge( 271 | prometheus.GaugeOpts{ 272 | Subsystem: subsystem, 273 | Name: m.Name, 274 | Help: m.Description, 275 | }, 276 | ) 277 | case "histogram_vec": 278 | metric = prometheus.NewHistogramVec( 279 | prometheus.HistogramOpts{ 280 | Subsystem: subsystem, 281 | Name: m.Name, 282 | Help: m.Description, 283 | }, 284 | m.Args, 285 | ) 286 | case "histogram": 287 | metric = prometheus.NewHistogram( 288 | prometheus.HistogramOpts{ 289 | Subsystem: subsystem, 290 | Name: m.Name, 291 | Help: m.Description, 292 | }, 293 | ) 294 | case "summary_vec": 295 | metric = prometheus.NewSummaryVec( 296 | prometheus.SummaryOpts{ 297 | Subsystem: subsystem, 298 | Name: m.Name, 299 | Help: m.Description, 300 | }, 301 | m.Args, 302 | ) 303 | case "summary": 304 | metric = prometheus.NewSummary( 305 | prometheus.SummaryOpts{ 306 | Subsystem: subsystem, 307 | Name: m.Name, 308 | Help: m.Description, 309 | }, 310 | ) 311 | } 312 | return metric 313 | } 314 | 315 | func (p *Prometheus) registerMetrics(subsystem string) { 316 | 317 | for _, metricDef := range p.MetricsList { 318 | metric := NewMetric(metricDef, subsystem) 319 | if err := prometheus.Register(metric); err != nil { 320 | log.WithError(err).Errorf("%s could not be registered in Prometheus", metricDef.Name) 321 | } 322 | switch metricDef { 323 | case reqCnt: 324 | p.reqCnt = metric.(*prometheus.CounterVec) 325 | case reqDur: 326 | p.reqDur = metric.(prometheus.Summary) 327 | case resSz: 328 | p.resSz = metric.(prometheus.Summary) 329 | case reqSz: 330 | p.reqSz = metric.(prometheus.Summary) 331 | } 332 | metricDef.MetricCollector = metric 333 | } 334 | } 335 | 336 | // Use adds the middleware to a gin engine. 337 | func (p *Prometheus) Use(e *gin.Engine) { 338 | e.Use(p.handlerFunc()) 339 | p.setMetricsPath(e) 340 | } 341 | 342 | // UseWithAuth adds the middleware to a gin engine with BasicAuth. 343 | func (p *Prometheus) UseWithAuth(e *gin.Engine, accounts gin.Accounts) { 344 | e.Use(p.handlerFunc()) 345 | p.setMetricsPathWithAuth(e, accounts) 346 | } 347 | 348 | func (p *Prometheus) handlerFunc() gin.HandlerFunc { 349 | return func(c *gin.Context) { 350 | if c.Request.URL.String() == p.MetricsPath { 351 | c.Next() 352 | return 353 | } 354 | 355 | start := time.Now() 356 | reqSz := computeApproximateRequestSize(c.Request) 357 | 358 | c.Next() 359 | 360 | status := strconv.Itoa(c.Writer.Status()) 361 | elapsed := float64(time.Since(start)) / float64(time.Second) 362 | resSz := float64(c.Writer.Size()) 363 | 364 | p.reqDur.Observe(elapsed) 365 | url := p.ReqCntURLLabelMappingFn(c) 366 | p.reqCnt.WithLabelValues(status, c.Request.Method, c.HandlerName(), c.Request.Host, url).Inc() 367 | p.reqSz.Observe(float64(reqSz)) 368 | p.resSz.Observe(resSz) 369 | } 370 | } 371 | 372 | func prometheusHandler() gin.HandlerFunc { 373 | h := promhttp.Handler() 374 | return func(c *gin.Context) { 375 | h.ServeHTTP(c.Writer, c.Request) 376 | } 377 | } 378 | 379 | // From https://github.com/DanielHeckrath/gin-prometheus/blob/master/gin_prometheus.go 380 | func computeApproximateRequestSize(r *http.Request) int { 381 | s := 0 382 | if r.URL != nil { 383 | s = len(r.URL.String()) 384 | } 385 | 386 | s += len(r.Method) 387 | s += len(r.Proto) 388 | for name, values := range r.Header { 389 | s += len(name) 390 | for _, value := range values { 391 | s += len(value) 392 | } 393 | } 394 | s += len(r.Host) 395 | 396 | // N.B. r.Form and r.MultipartForm are assumed to be included in r.URL. 397 | 398 | if r.ContentLength != -1 { 399 | s += int(r.ContentLength) 400 | } 401 | return s 402 | } 403 | -------------------------------------------------------------------------------- /internal/m3uplus/main.go: -------------------------------------------------------------------------------- 1 | // Package m3uplus provides a M3U Plus parser. 2 | package m3uplus 3 | 4 | import ( 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "net/url" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/mitchellh/mapstructure" 14 | ) 15 | 16 | // Playlist is a type that represents an m3u playlist containing 0 or more tracks 17 | type Playlist struct { 18 | Tracks []Track 19 | } 20 | 21 | // Track represents an m3u track 22 | type Track struct { 23 | Name string 24 | Length float64 25 | URI *url.URL 26 | Tags map[string]string 27 | Raw string 28 | LineNumber int 29 | } 30 | 31 | // UnmarshalTags will decode the Tags map into a struct containing fields with `m3u` tags matching map keys. 32 | func (t *Track) UnmarshalTags(v interface{}) error { 33 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 34 | TagName: "m3u", 35 | Result: &v, 36 | }) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return decoder.Decode(t.Tags) 42 | } 43 | 44 | // Decode parses an m3u playlist in the given io.Reader and returns a Playlist 45 | func Decode(r io.Reader) (*Playlist, error) { 46 | playlist := &Playlist{} 47 | buf := new(bytes.Buffer) 48 | _, err := buf.ReadFrom(r) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | if decErr := decode(playlist, buf); decErr != nil { 54 | return nil, decErr 55 | } 56 | 57 | return playlist, nil 58 | } 59 | 60 | func decode(playlist *Playlist, buf *bytes.Buffer) error { 61 | var eof bool 62 | var line string 63 | var err error 64 | 65 | lineNum := 0 66 | 67 | for !eof { 68 | lineNum = lineNum + 1 69 | if line, err = buf.ReadString('\n'); err == io.EOF { 70 | eof = true 71 | } else if err != nil { 72 | return err 73 | } 74 | 75 | if lineNum == 1 && !strings.HasPrefix(strings.TrimSpace(line), "#EXTM3U") { 76 | return fmt.Errorf("malformed M3U provided") 77 | } 78 | 79 | if err = decodeLine(playlist, line, lineNum); err != nil { 80 | return err 81 | } 82 | } 83 | return nil 84 | } 85 | 86 | func decodeLine(playlist *Playlist, line string, lineNumber int) error { 87 | line = strings.TrimSpace(line) 88 | 89 | switch { 90 | case strings.HasPrefix(line, "#EXTINF:"): 91 | track := Track{ 92 | Raw: line, 93 | LineNumber: lineNumber, 94 | } 95 | 96 | track.Length, track.Name, track.Tags = decodeInfoLine(line) 97 | 98 | playlist.Tracks = append(playlist.Tracks, track) 99 | 100 | case IsUrl(line): 101 | uri, _ := url.Parse(line) 102 | playlist.Tracks[len(playlist.Tracks)-1].URI = uri 103 | } 104 | 105 | return nil 106 | } 107 | 108 | // From https://stackoverflow.com/questions/25747580/ensure-a-uri-is-valid/25747925#25747925 109 | func IsUrl(str string) bool { 110 | u, err := url.Parse(str) 111 | return err == nil && u.Scheme != "" && u.Host != "" 112 | } 113 | 114 | var infoRegex = regexp.MustCompile(`([^\s="]+)=(?:"(.*?)"|(\d+))(?:,([.*^,]))?|#EXTINF:(-?\d*\s*)|,(.*)`) 115 | 116 | func decodeInfoLine(line string) (float64, string, map[string]string) { 117 | matches := infoRegex.FindAllStringSubmatch(line, -1) 118 | var err error 119 | durationFloat := 0.0 120 | durationStr := strings.TrimSpace(matches[0][len(matches[0])-2]) 121 | if durationStr != "-1" && len(durationStr) > 0 { 122 | if durationFloat, err = strconv.ParseFloat(durationStr, 64); err != nil { 123 | panic(fmt.Errorf("Duration parsing error: %s", err)) 124 | } 125 | } 126 | 127 | titleIndex := len(matches) - 1 128 | title := matches[titleIndex][len(matches[titleIndex])-1] 129 | 130 | keyMap := make(map[string]string) 131 | 132 | for _, match := range matches[1 : len(matches)-1] { 133 | val := match[2] 134 | if val == "" { // If empty string find a number in [3] 135 | val = match[3] 136 | } 137 | keyMap[strings.ToLower(match[1])] = val 138 | } 139 | 140 | return durationFloat, title, keyMap 141 | } 142 | -------------------------------------------------------------------------------- /internal/providers/area51.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | m3u "github.com/tellytv/telly/internal/m3uplus" 8 | "github.com/tellytv/telly/internal/xmltv" 9 | ) 10 | 11 | // http://iptv-area-51.tv:2095/get.php?username=username&password=password&type=m3uplus&output=ts 12 | // http://iptv-area-51.tv:2095/xmltv.php?username=username&password=password 13 | 14 | type area51 struct { 15 | BaseConfig Configuration 16 | } 17 | 18 | func newArea51(config *Configuration) (Provider, error) { 19 | return &area51{*config}, nil 20 | } 21 | 22 | func (i *area51) Name() string { 23 | return "Area51" 24 | } 25 | 26 | func (i *area51) PlaylistURL() string { 27 | return fmt.Sprintf("http://iptv-area-51.tv:2095/get.php?username=%s&password=%s&type=m3u_plus&output=ts", i.BaseConfig.Username, i.BaseConfig.Password) 28 | } 29 | 30 | func (i *area51) EPGURL() string { 31 | return fmt.Sprintf("http://iptv-area-51.tv:2095/xmltv.php?username=%s&password=%s", i.BaseConfig.Username, i.BaseConfig.Password) 32 | } 33 | 34 | // ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. 35 | func (i *area51) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { 36 | nameVal := track.Name 37 | if i.BaseConfig.NameKey != "" { 38 | nameVal = track.Tags[i.BaseConfig.NameKey] 39 | } 40 | 41 | logoVal := track.Tags["tvg-logo"] 42 | if i.BaseConfig.LogoKey != "" { 43 | logoVal = track.Tags[i.BaseConfig.LogoKey] 44 | } 45 | 46 | pChannel := &ProviderChannel{ 47 | Name: nameVal, 48 | Logo: logoVal, 49 | Number: 0, 50 | StreamURL: track.URI.String(), 51 | StreamID: 0, 52 | HD: strings.Contains(strings.ToLower(track.Name), "hd"), 53 | StreamFormat: "Unknown", 54 | Track: track, 55 | OnDemand: false, 56 | } 57 | 58 | epgVal := track.Tags["tvg-id"] 59 | if i.BaseConfig.EPGMatchKey != "" { 60 | epgVal = track.Tags[i.BaseConfig.EPGMatchKey] 61 | } 62 | 63 | if xmlChan, ok := channelMap[epgVal]; ok { 64 | pChannel.EPGMatch = epgVal 65 | pChannel.EPGChannel = &xmlChan 66 | } 67 | 68 | return pChannel, nil 69 | } 70 | 71 | func (i *area51) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { 72 | return &programme 73 | } 74 | 75 | func (i *area51) Configuration() Configuration { 76 | return i.BaseConfig 77 | } 78 | 79 | func (i *area51) RegexKey() string { 80 | return "group-title" 81 | } 82 | -------------------------------------------------------------------------------- /internal/providers/custom.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | 10 | log "github.com/sirupsen/logrus" 11 | m3u "github.com/tellytv/telly/internal/m3uplus" 12 | "github.com/tellytv/telly/internal/xmltv" 13 | ) 14 | 15 | type customProvider struct { 16 | BaseConfig Configuration 17 | } 18 | 19 | func newCustomProvider(config *Configuration) (Provider, error) { 20 | return &customProvider{*config}, nil 21 | } 22 | 23 | func (i *customProvider) Name() string { 24 | return i.BaseConfig.Name 25 | } 26 | 27 | func (i *customProvider) PlaylistURL() string { 28 | return i.BaseConfig.M3U 29 | } 30 | 31 | func (i *customProvider) EPGURL() string { 32 | return i.BaseConfig.EPG 33 | } 34 | 35 | // ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. 36 | func (i *customProvider) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { 37 | channelVal := track.Tags["tvg-chno"] 38 | if i.BaseConfig.ChannelNumberKey != "" { 39 | channelVal = track.Tags[i.BaseConfig.ChannelNumberKey] 40 | } 41 | 42 | chanNum := 0 43 | 44 | if channelNumber, channelNumberErr := strconv.Atoi(channelVal); channelNumberErr == nil { 45 | chanNum = channelNumber 46 | } 47 | 48 | nameVal := track.Name 49 | if i.BaseConfig.NameKey != "" { 50 | nameVal = track.Tags[i.BaseConfig.NameKey] 51 | } 52 | 53 | logoVal := track.Tags["tvg-logo"] 54 | if i.BaseConfig.LogoKey != "" { 55 | logoVal = track.Tags[i.BaseConfig.LogoKey] 56 | } 57 | 58 | pChannel := &ProviderChannel{ 59 | Name: nameVal, 60 | Logo: logoVal, 61 | Number: chanNum, 62 | StreamURL: track.URI.String(), 63 | StreamID: chanNum, 64 | HD: strings.Contains(strings.ToLower(track.Name), "hd"), 65 | StreamFormat: "Unknown", 66 | Track: track, 67 | OnDemand: false, 68 | } 69 | 70 | // If Udpxy is set in the provider configuration and StreamURL is a multicast stream, 71 | // rewrite the URL to point to the Udpxy instance. 72 | if i.BaseConfig.Udpxy != "" { 73 | trackURI, err := url.Parse(pChannel.StreamURL) 74 | if err != nil { 75 | return nil, err 76 | } 77 | if IP := net.ParseIP(trackURI.Hostname()); IP != nil && IP.IsMulticast() { 78 | pChannel.StreamURL = fmt.Sprintf("http://%s/udp/%s/", i.BaseConfig.Udpxy, trackURI.Host) 79 | log.Debugf("Multicast stream detected and udpxy is configured, track URL rewritten from %s to %s", track.URI, pChannel.StreamURL) 80 | } 81 | } 82 | 83 | epgVal := track.Tags["tvg-id"] 84 | if i.BaseConfig.EPGMatchKey != "" { 85 | epgVal = track.Tags[i.BaseConfig.EPGMatchKey] 86 | } 87 | 88 | if xmlChan, ok := channelMap[epgVal]; ok { 89 | pChannel.EPGMatch = epgVal 90 | pChannel.EPGChannel = &xmlChan 91 | } 92 | 93 | return pChannel, nil 94 | } 95 | 96 | func (i *customProvider) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { 97 | return &programme 98 | } 99 | 100 | func (i *customProvider) Configuration() Configuration { 101 | return i.BaseConfig 102 | } 103 | 104 | func (i *customProvider) RegexKey() string { 105 | return i.BaseConfig.FilterKey 106 | } 107 | -------------------------------------------------------------------------------- /internal/providers/eternal.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | // M3U:http://live.eternaltv.net:25461/get.php?username=xxxxxxx&password=xxxxxx&output=ts&type=m3uplus 4 | // XMLTV: http://live.eternaltv.net:25461/xmltv.php?username=xxxxx&password=xxxxx&type=m3uplus&output=ts 5 | -------------------------------------------------------------------------------- /internal/providers/hellraiser.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | // Playlist URL: http://liquidit.info:8080/get.php?username=xxxx&password=xxxxxxx&type=m3uplus&output=ts 4 | // XMLTV URL: http://liquidit.info:8080/xmltv.php?username=xxxxxx&password=xxxxxx 5 | -------------------------------------------------------------------------------- /internal/providers/iptv-epg.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | m3u "github.com/tellytv/telly/internal/m3uplus" 9 | "github.com/tellytv/telly/internal/xmltv" 10 | ) 11 | 12 | // M3U: http://iptv-epg.com/.m3u 13 | // XMLTV: http://iptv-epg.com/.xml 14 | 15 | type iptvepg struct { 16 | BaseConfig Configuration 17 | } 18 | 19 | func newIPTVEPG(config *Configuration) (Provider, error) { 20 | return &iptvepg{*config}, nil 21 | } 22 | 23 | func (i *iptvepg) Name() string { 24 | return "IPTV-EPG" 25 | } 26 | 27 | func (i *iptvepg) PlaylistURL() string { 28 | return fmt.Sprintf("http://iptv-epg.com/%s.m3u", i.BaseConfig.Username) 29 | } 30 | 31 | func (i *iptvepg) EPGURL() string { 32 | return fmt.Sprintf("http://iptv-epg.com/%s.xml", i.BaseConfig.Password) 33 | } 34 | 35 | // ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. 36 | func (i *iptvepg) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { 37 | channelVal := track.Tags["tvg-chno"] 38 | if i.BaseConfig.ChannelNumberKey != "" { 39 | channelVal = track.Tags[i.BaseConfig.ChannelNumberKey] 40 | } 41 | 42 | channelNumber, channelNumberErr := strconv.Atoi(channelVal) 43 | if channelNumberErr != nil { 44 | return nil, channelNumberErr 45 | } 46 | 47 | nameVal := track.Name 48 | if i.BaseConfig.NameKey != "" { 49 | nameVal = track.Tags[i.BaseConfig.NameKey] 50 | } 51 | 52 | logoVal := track.Tags["tvg-logo"] 53 | if i.BaseConfig.LogoKey != "" { 54 | logoVal = track.Tags[i.BaseConfig.LogoKey] 55 | } 56 | 57 | pChannel := &ProviderChannel{ 58 | Name: nameVal, 59 | Logo: logoVal, 60 | Number: channelNumber, 61 | StreamURL: track.URI.String(), 62 | StreamID: channelNumber, 63 | HD: strings.Contains(strings.ToLower(track.Name), "hd"), 64 | StreamFormat: "Unknown", 65 | Track: track, 66 | OnDemand: false, 67 | } 68 | 69 | epgVal := track.Tags["tvg-id"] 70 | if i.BaseConfig.EPGMatchKey != "" { 71 | epgVal = track.Tags[i.BaseConfig.EPGMatchKey] 72 | } 73 | 74 | if xmlChan, ok := channelMap[epgVal]; ok { 75 | pChannel.EPGMatch = epgVal 76 | pChannel.EPGChannel = &xmlChan 77 | } 78 | 79 | return pChannel, nil 80 | } 81 | 82 | func (i *iptvepg) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { 83 | return &programme 84 | } 85 | 86 | func (i *iptvepg) Configuration() Configuration { 87 | return i.BaseConfig 88 | } 89 | 90 | func (i *iptvepg) RegexKey() string { 91 | return "group-title" 92 | } 93 | -------------------------------------------------------------------------------- /internal/providers/iris.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | m3u "github.com/tellytv/telly/internal/m3uplus" 8 | "github.com/tellytv/telly/internal/xmltv" 9 | ) 10 | 11 | // http://irislinks.net:83/get.php?username=username&password=password&type=m3uplus&output=ts 12 | // http://irislinks.net:83/xmltv.php?username=username&password=password 13 | 14 | type iris struct { 15 | BaseConfig Configuration 16 | } 17 | 18 | func newIris(config *Configuration) (Provider, error) { 19 | return &iris{*config}, nil 20 | } 21 | 22 | func (i *iris) Name() string { 23 | return "Iris" 24 | } 25 | 26 | func (i *iris) PlaylistURL() string { 27 | return fmt.Sprintf("http://irislinks.net:83/get.php?username=%s&password=%s&type=m3u_plus&output=ts", i.BaseConfig.Username, i.BaseConfig.Password) 28 | } 29 | 30 | func (i *iris) EPGURL() string { 31 | return fmt.Sprintf("http://irislinks.net:83/xmltv.php?username=%s&password=%s", i.BaseConfig.Username, i.BaseConfig.Password) 32 | } 33 | 34 | // ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. 35 | func (i *iris) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { 36 | nameVal := track.Name 37 | if i.BaseConfig.NameKey != "" { 38 | nameVal = track.Tags[i.BaseConfig.NameKey] 39 | } 40 | 41 | logoVal := track.Tags["tvg-logo"] 42 | if i.BaseConfig.LogoKey != "" { 43 | logoVal = track.Tags[i.BaseConfig.LogoKey] 44 | } 45 | 46 | pChannel := &ProviderChannel{ 47 | Name: nameVal, 48 | Logo: logoVal, 49 | Number: 0, 50 | StreamURL: track.URI.String(), 51 | StreamID: 0, 52 | HD: strings.Contains(strings.ToLower(track.Name), "hd"), 53 | StreamFormat: "Unknown", 54 | Track: track, 55 | OnDemand: false, 56 | } 57 | 58 | epgVal := track.Tags["tvg-id"] 59 | if i.BaseConfig.EPGMatchKey != "" { 60 | epgVal = track.Tags[i.BaseConfig.EPGMatchKey] 61 | } 62 | 63 | if xmlChan, ok := channelMap[epgVal]; ok { 64 | pChannel.EPGMatch = epgVal 65 | pChannel.EPGChannel = &xmlChan 66 | } 67 | 68 | return pChannel, nil 69 | } 70 | 71 | func (i *iris) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { 72 | return &programme 73 | } 74 | 75 | func (i *iris) Configuration() Configuration { 76 | return i.BaseConfig 77 | } 78 | 79 | func (i *iris) RegexKey() string { 80 | return "group-title" 81 | } 82 | -------------------------------------------------------------------------------- /internal/providers/main.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | m3u "github.com/tellytv/telly/internal/m3uplus" 8 | "github.com/tellytv/telly/internal/xmltv" 9 | ) 10 | 11 | var streamNumberRegex = regexp.MustCompile(`/(\d+).(ts|.*.m3u8)`).FindAllStringSubmatch 12 | var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString 13 | var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString 14 | var hdRegex = regexp.MustCompile(`hd|4k`) 15 | 16 | type Configuration struct { 17 | Name string `json:"-"` 18 | Provider string 19 | 20 | Username string `json:"username"` 21 | Password string `json:"password"` 22 | 23 | M3U string `json:"-"` 24 | EPG string `json:"-"` 25 | 26 | Udpxy string `json:"udpxy"` 27 | 28 | VideoOnDemand bool `json:"-"` 29 | 30 | Filter string 31 | FilterKey string 32 | FilterRaw bool 33 | 34 | SortKey string 35 | SortReverse bool 36 | 37 | Favorites []string 38 | FavoriteTag string 39 | 40 | IncludeOnly []string 41 | IncludeOnlyTag string 42 | 43 | CacheFiles bool 44 | 45 | NameKey string 46 | LogoKey string 47 | ChannelNumberKey string 48 | EPGMatchKey string 49 | } 50 | 51 | func (i *Configuration) GetProvider() (Provider, error) { 52 | switch strings.ToLower(i.Provider) { 53 | default: 54 | return newCustomProvider(i) 55 | } 56 | } 57 | 58 | // ProviderChannel describes a channel available in the providers lineup with necessary pieces parsed into fields. 59 | type ProviderChannel struct { 60 | Name string 61 | StreamID int // Should be the integer just before .ts. 62 | Number int 63 | Logo string 64 | StreamURL string 65 | HD bool 66 | Quality string 67 | OnDemand bool 68 | StreamFormat string 69 | Favorite bool 70 | 71 | EPGMatch string 72 | EPGChannel *xmltv.Channel 73 | EPGProgrammes []xmltv.Programme 74 | Track m3u.Track 75 | } 76 | 77 | // Provider describes a IPTV provider configuration. 78 | type Provider interface { 79 | Name() string 80 | PlaylistURL() string 81 | EPGURL() string 82 | 83 | // These are functions to extract information from playlists. 84 | ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) 85 | ProcessProgramme(programme xmltv.Programme) *xmltv.Programme 86 | 87 | RegexKey() string 88 | Configuration() Configuration 89 | } 90 | 91 | func contains(s []string, e string) bool { 92 | for _, ss := range s { 93 | if e == ss { 94 | return true 95 | } 96 | } 97 | return false 98 | } 99 | -------------------------------------------------------------------------------- /internal/providers/tnt.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | // M3U: http://thesepeanutz.xyz:2052/get.php?username=xxx&password=xxx&type=m3u_plus&output=ts 4 | // XMLTV: http://thesepeanutz.xyz:2052/xmltv.php?username=xxx&password=xxx 5 | 6 | // EPG: http://tntcloud.xyz:2052/xmltv.php?username=XXX&password=XXX 7 | // M3U: http://tntcloud.xyz:2052/get.php?username=XXX&password=XXX&type=m3u_plus&output=ts 8 | -------------------------------------------------------------------------------- /internal/xmltv/example.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 KERA 7 | 13 KERA TX42822:- 8 | 13 9 | 13 KERA fcc 10 | KERA 11 | KERA 12 | PBS Affiliate 13 | 14 | 15 | 16 | 11 KTVT 17 | 11 KTVT TX42822:- 18 | 11 19 | 11 KTVT fcc 20 | KTVT 21 | KTVT 22 | CBS Affiliate 23 | 24 | 25 | 26 | NOW on PBS 27 | Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East. 28 | 20080711 29 | Newsmagazine 30 | Interview 31 | Public affairs 32 | Series 33 | EP01006886.0028 34 | 427 35 | 38 | 39 | 40 | 41 | 42 | Mystery! 43 | Foyle's War, Series IV: Bleak Midwinter 44 | Foyle investigates an explosion at a munitions factory, which he comes to believe may have been premeditated. 45 | 20070701 46 | Anthology 47 | Mystery 48 | Series 49 | EP00003026.0665 50 | 2705 51 | 54 | 55 | 56 | 57 | 58 | Mystery! 59 | Foyle's War, Series IV: Casualties of War 60 | The murder of a prominent scientist may have been due to a gambling debt. 61 | 20070708 62 | Anthology 63 | Mystery 64 | Series 65 | EP00003026.0666 66 | 2706 67 | 70 | 71 | 72 | 73 | 74 | BBC World News 75 | International issues. 76 | News 77 | Series 78 | SH00315789.0000 79 | 80 | 81 | 82 | 83 | Sit and Be Fit 84 | 20070924 85 | Exercise 86 | Series 87 | EP00003847.0074 88 | 901 89 | 92 | 93 | 94 | 95 | 96 | The Early Show 97 | Republican candidate John McCain; premiere of the film "The Dark Knight." 98 | 20080715 99 | Talk 100 | News 101 | Series 102 | EP00337003.2361 103 | 106 | 107 | 108 | 109 | Rachael Ray 110 | Actresses Kim Raver, Brooke Shields and Lindsay Price ("Lipstick Jungle"); women in their 40s tell why they got breast implants; a 30-minute meal. 111 | 112 | Rachael Ray 113 | 114 | 20080306 115 | Talk 116 | Series 117 | EP00847333.0303 118 | 2119 119 | 122 | 123 | 124 | 125 | 126 | The Price Is Right 127 | Contestants bid for prizes then compete for fabulous showcases. 128 | 129 | Bart Eskander 130 | Roger Dobkowitz 131 | Drew Carey 132 | 133 | Game show 134 | Series 135 | SH00004372.0000 136 | 139 | 140 | 141 | TV-G 142 | 143 | 144 | 145 | Jeopardy! 146 | 147 | Alex Trebek 148 | 149 | 20080715 150 | Game show 151 | Series 152 | EP00002348.1700 153 | 5507 154 | 155 | 156 | TV-G 157 | 158 | 159 | 160 | The Young and the Restless 161 | Sabrina Offers Victoria a Truce 162 | Jeff thinks Kyon stole the face cream; Nikki asks Nick to give David a chance; Amber begs Adrian to go to Australia. 163 | 164 | Peter Bergman 165 | Eric Braeden 166 | Jeanne Cooper 167 | Melody Thomas Scott 168 | 169 | 20080715 170 | Soap 171 | Series 172 | EP00004422.1359 173 | 8937 174 | 177 | 178 | 179 | TV-14 180 | 181 | 182 | 183 | -------------------------------------------------------------------------------- /internal/xmltv/xmltv.dtd: -------------------------------------------------------------------------------- 1 | 87 | 88 | 120 | 121 | 127 | 128 | 164 | 165 | 166 | 167 | 171 | 172 | 173 | 174 | 181 | 182 | 183 | 218 | 224 | 232 | 233 | 234 | 235 | 236 | 237 | 240 | 241 | 242 | 243 | 249 | 250 | 251 | 252 | 265 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 284 | 285 | 286 | 290 | 291 | 292 | 293 | 298 | 299 | 300 | 301 | 313 | 314 | 315 | 316 | 319 | 320 | 321 | 322 | 328 | 329 | 330 | 331 | 338 | 339 | 342 | 343 | 349 | 350 | 351 | 360 | 361 | 362 | 363 | 418 | 419 | 420 | 421 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 453 | 454 | 455 | 456 | 467 | 468 | 470 | 471 | 492 | 493 | 494 | 495 | 505 | 506 | 507 | 508 | 518 | 519 | 520 | 527 | 528 | 529 | 530 | 536 | 537 | 538 | 539 | 553 | 554 | 555 | 556 | 557 | 565 | 566 | 570 | 571 | 576 | -------------------------------------------------------------------------------- /internal/xmltv/xmltv.go: -------------------------------------------------------------------------------- 1 | // Package xmltv provides structures for parsing XMLTV data. 2 | package xmltv 3 | 4 | import ( 5 | "encoding/xml" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "golang.org/x/net/html/charset" 12 | ) 13 | 14 | // Time that holds the time which is parsed from XML 15 | type Time struct { 16 | time.Time 17 | } 18 | 19 | // MarshalXMLAttr is used to marshal a Go time.Time into the XMLTV Format. 20 | func (t *Time) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { 21 | return xml.Attr{ 22 | Name: name, 23 | Value: t.Format("20060102150405 -0700"), 24 | }, nil 25 | } 26 | 27 | // UnmarshalXMLAttr is used to unmarshal a time in the XMLTV format to a time.Time. 28 | func (t *Time) UnmarshalXMLAttr(attr xml.Attr) error { 29 | // This is a barebones handling of broken XMLTV entries like this one: 30 | // 31 | // What's that negative stop time about? Ignore it 32 | if strings.HasPrefix(attr.Value, "-") { 33 | return nil 34 | } 35 | 36 | t1, err := time.Parse("20060102150405 -0700", attr.Value) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | *t = Time{t1} 42 | return nil 43 | } 44 | 45 | type Date time.Time 46 | 47 | func (p Date) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 48 | t := time.Time(p) 49 | if t.IsZero() { 50 | return e.EncodeElement(nil, start) 51 | } 52 | return e.EncodeElement(t.Format("20060102"), start) 53 | } 54 | 55 | func (p *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) { 56 | var content string 57 | if e := d.DecodeElement(&content, &start); e != nil { 58 | return fmt.Errorf("get the type Date field of %s error", start.Name.Local) 59 | } 60 | 61 | dateFormat := "20060102" 62 | 63 | if len(content) == 4 { 64 | dateFormat = "2006" 65 | } 66 | 67 | if strings.Contains(content, "|") { 68 | content = strings.Split(content, "|")[0] 69 | dateFormat = "2006" 70 | } 71 | 72 | if v, e := time.Parse(dateFormat, content); e != nil { 73 | return fmt.Errorf("the type Date field of %s is not a time, value is: %s", start.Name.Local, content) 74 | } else { 75 | *p = Date(v) 76 | } 77 | return nil 78 | } 79 | 80 | func (p Date) MarshalJSON() ([]byte, error) { 81 | t := time.Time(p) 82 | str := "\"" + t.Format("20060102") + "\"" 83 | 84 | return []byte(str), nil 85 | } 86 | 87 | func (p *Date) UnmarshalJSON(text []byte) (err error) { 88 | strDate := string(text[1 : 8+1]) 89 | 90 | if v, e := time.Parse("20060102", strDate); e != nil { 91 | return fmt.Errorf("Date should be a time, error value is: %s", strDate) 92 | } else { 93 | *p = Date(v) 94 | } 95 | return nil 96 | } 97 | 98 | // TV is the root element. 99 | type TV struct { 100 | XMLName xml.Name `xml:"tv" json:"-"` 101 | Channels []Channel `xml:"channel" json:"channels"` 102 | Programmes []Programme `xml:"programme" json:"programmes"` 103 | Date string `xml:"date,attr,omitempty" json:"date,omitempty"` 104 | SourceInfoURL string `xml:"source-info-url,attr,omitempty" json:"source_info_url,omitempty"` 105 | SourceInfoName string `xml:"source-info-name,attr,omitempty" json:"source_info_name,omitempty"` 106 | SourceDataURL string `xml:"source-data-url,attr,omitempty" json:"source_data_url,omitempty"` 107 | GeneratorInfoName string `xml:"generator-info-name,attr,omitempty" json:"generator_info_name,omitempty"` 108 | GeneratorInfoURL string `xml:"generator-info-url,attr,omitempty" json:"generator_info_url,omitempty"` 109 | } 110 | 111 | // LoadXML loads the XMLTV XML from file. 112 | func (t *TV) LoadXML(f *os.File) error { 113 | decoder := xml.NewDecoder(f) 114 | decoder.CharsetReader = charset.NewReaderLabel 115 | 116 | err := decoder.Decode(&t) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | return nil 122 | } 123 | 124 | // Channel details of a channel 125 | type Channel struct { 126 | DisplayNames []CommonElement `xml:"display-name" json:"display_names" ` 127 | Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty"` 128 | URLs []string `xml:"url,omitempty" json:"urls,omitempty" ` 129 | ID string `xml:"id,attr" json:"id,omitempty" ` 130 | 131 | // These fields are outside of the XMLTV spec. 132 | // LCN is the local channel number. Plex will show it in place of the channel ID if it exists. 133 | LCN int `xml:"lcn" json:"lcn,omitempty"` 134 | } 135 | 136 | // Programme details of a single programme transmission 137 | type Programme struct { 138 | ID string `xml:"id,attr,omitempty" json:"id,omitempty"` // not defined by standard, but often present 139 | Titles []CommonElement `xml:"title" json:"titles"` 140 | SecondaryTitles []CommonElement `xml:"sub-title,omitempty" json:"secondary_titles,omitempty"` 141 | Descriptions []CommonElement `xml:"desc,omitempty" json:"descriptions,omitempty"` 142 | Credits *Credits `xml:"credits,omitempty" json:"credits,omitempty"` 143 | Date Date `xml:"date,omitempty" json:"date,omitempty"` 144 | Categories []CommonElement `xml:"category,omitempty" json:"categories,omitempty"` 145 | Keywords []CommonElement `xml:"keyword,omitempty" json:"keywords,omitempty"` 146 | Languages []CommonElement `xml:"language,omitempty" json:"languages,omitempty"` 147 | OrigLanguages []CommonElement `xml:"orig-language,omitempty" json:"orig_languages,omitempty"` 148 | Length *Length `xml:"length,omitempty" json:"length,omitempty"` 149 | Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty"` 150 | URLs []string `xml:"url,omitempty" json:"urls,omitempty"` 151 | Countries []CommonElement `xml:"country,omitempty" json:"countries,omitempty"` 152 | EpisodeNums []EpisodeNum `xml:"episode-num,omitempty" json:"episode_nums,omitempty"` 153 | Video *Video `xml:"video,omitempty" json:"video,omitempty"` 154 | Audio *Audio `xml:"audio,omitempty" json:"audio,omitempty"` 155 | PreviouslyShown *PreviouslyShown `xml:"previously-shown,omitempty" json:"previously_shown,omitempty"` 156 | Premiere *CommonElement `xml:"premiere,omitempty" json:"premiere,omitempty"` 157 | LastChance *CommonElement `xml:"last-chance,omitempty" json:"last_chance,omitempty"` 158 | New *ElementPresent `xml:"new" json:"new,omitempty"` 159 | Subtitles []Subtitle `xml:"subtitles,omitempty" json:"subtitles,omitempty"` 160 | Ratings []Rating `xml:"rating,omitempty" json:"ratings,omitempty"` 161 | StarRatings []Rating `xml:"star-rating,omitempty" json:"star_ratings,omitempty"` 162 | Reviews []Review `xml:"review,omitempty" json:"reviews,omitempty"` 163 | Start *Time `xml:"start,attr" json:"start"` 164 | Stop *Time `xml:"stop,attr,omitempty" json:"stop,omitempty"` 165 | PDCStart *Time `xml:"pdc-start,attr,omitempty" json:"pdc_start,omitempty"` 166 | VPSStart *Time `xml:"vps-start,attr,omitempty" json:"vps_start,omitempty"` 167 | Showview string `xml:"showview,attr,omitempty" json:"showview,omitempty"` 168 | Videoplus string `xml:"videoplus,attr,omitempty" json:"videoplus,omitempty"` 169 | Channel string `xml:"channel,attr" json:"channel"` 170 | Clumpidx string `xml:"clumpidx,attr,omitempty" json:"clumpidx,omitempty"` 171 | } 172 | 173 | // CommonElement element structure that is common, i.e. Italy 174 | type CommonElement struct { 175 | Lang string `xml:"lang,attr,omitempty" json:"lang,omitempty" ` 176 | Value string `xml:",chardata" json:"value,omitempty"` 177 | } 178 | 179 | // ElementPresent used to determine if element is present or not 180 | type ElementPresent bool 181 | 182 | // MarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 183 | func (c *ElementPresent) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 184 | if c == nil { 185 | return e.EncodeElement(nil, start) 186 | } 187 | return e.EncodeElement("", start) 188 | } 189 | 190 | // UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 191 | func (c *ElementPresent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { 192 | var v string 193 | if decodeErr := d.DecodeElement(&v, &start); decodeErr != nil { 194 | return decodeErr 195 | } 196 | *c = true 197 | return nil 198 | } 199 | 200 | // Icon associated with the element that contains it 201 | type Icon struct { 202 | Source string `xml:"src,attr" json:"source"` 203 | Width int `xml:"width,attr,omitempty" json:"width,omitempty"` 204 | Height int `xml:"height,attr,omitempty" json:"height,omitempty"` 205 | } 206 | 207 | // Credits for the programme 208 | type Credits struct { 209 | Directors []string `xml:"director,omitempty" json:"directors,omitempty"` 210 | Actors []Actor `xml:"actor,omitempty" json:"actors,omitempty"` 211 | Writers []string `xml:"writer,omitempty" json:"writers,omitempty"` 212 | Adapters []string `xml:"adapter,omitempty" json:"adapters,omitempty"` 213 | Producers []string `xml:"producer,omitempty" json:"producers,omitempty"` 214 | Composers []string `xml:"composer,omitempty" json:"composers,omitempty"` 215 | Editors []string `xml:"editor,omitempty" json:"editors,omitempty"` 216 | Presenters []string `xml:"presenter,omitempty" json:"presenters,omitempty"` 217 | Commentators []string `xml:"commentator,omitempty" json:"commentators,omitempty"` 218 | Guests []string `xml:"guest,omitempty" json:"guests,omitempty"` 219 | } 220 | 221 | // Actor in a programme 222 | type Actor struct { 223 | Role string `xml:"role,attr,omitempty" json:"role,omitempty"` 224 | Value string `xml:",chardata" json:"value"` 225 | } 226 | 227 | // Length of the programme 228 | type Length struct { 229 | Units string `xml:"units,attr" json:"units"` 230 | Value string `xml:",chardata" json:"value"` 231 | } 232 | 233 | // EpisodeNum of the programme 234 | type EpisodeNum struct { 235 | System string `xml:"system,attr,omitempty" json:"system,omitempty"` 236 | Value string `xml:",chardata" json:"value"` 237 | } 238 | 239 | // Video details of the programme 240 | type Video struct { 241 | Present string `xml:"present,omitempty" json:"present,omitempty"` 242 | Colour string `xml:"colour,omitempty" json:"colour,omitempty"` 243 | Aspect string `xml:"aspect,omitempty" json:"aspect,omitempty"` 244 | Quality string `xml:"quality,omitempty" json:"quality,omitempty"` 245 | } 246 | 247 | // Audio details of the programme 248 | type Audio struct { 249 | Present string `xml:"present,omitempty" json:"present,omitempty"` 250 | Stereo string `xml:"stereo,omitempty" json:"stereo,omitempty"` 251 | } 252 | 253 | // PreviouslyShown When and where the programme was last shown, if known. 254 | type PreviouslyShown struct { 255 | Start string `xml:"start,attr,omitempty" json:"start,omitempty"` 256 | Channel string `xml:"channel,attr,omitempty" json:"channel,omitempty"` 257 | } 258 | 259 | // Subtitle in a programme 260 | type Subtitle struct { 261 | Language *CommonElement `xml:"language,omitempty" json:"language,omitempty"` 262 | Type string `xml:"type,attr,omitempty" json:"type,omitempty"` 263 | } 264 | 265 | // Rating of a programme 266 | type Rating struct { 267 | Value string `xml:"value" json:"value"` 268 | Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty"` 269 | System string `xml:"system,attr,omitempty" json:"system,omitempty"` 270 | } 271 | 272 | // Review of a programme 273 | type Review struct { 274 | Value string `xml:",chardata" json:"value"` 275 | Type string `xml:"type" json:"type"` 276 | Source string `xml:"source,omitempty" json:"source,omitempty"` 277 | Reviewer string `xml:"reviewer,omitempty" json:"reviewer,omitempty"` 278 | Lang string `xml:"lang,omitempty" json:"lang,omitempty"` 279 | } 280 | -------------------------------------------------------------------------------- /internal/xmltv/xmltv_test.go: -------------------------------------------------------------------------------- 1 | package xmltv 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "io" 7 | "os" 8 | "reflect" 9 | "testing" 10 | "time" 11 | 12 | "github.com/kr/pretty" 13 | ) 14 | 15 | func dummyReader(charset string, input io.Reader) (io.Reader, error) { 16 | return input, nil 17 | } 18 | 19 | func TestDecode(t *testing.T) { 20 | dir, err := os.Getwd() 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | // Example downloaded from http://wiki.xmltv.org/index.php/internal/xmltvFormat 26 | // One may check it with `xmllint --noout --dtdvalid xmltv.dtd example.xml` 27 | f, err := os.Open(fmt.Sprintf("%s/example.xml", dir)) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | defer f.Close() 32 | 33 | var tv TV 34 | dec := xml.NewDecoder(f) 35 | dec.CharsetReader = dummyReader 36 | err = dec.Decode(&tv) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | ch := Channel{ 42 | ID: "I10436.labs.zap2it.com", 43 | DisplayNames: []CommonElement{ 44 | CommonElement{ 45 | Value: "13 KERA", 46 | }, 47 | CommonElement{ 48 | Value: "13 KERA TX42822:-", 49 | }, 50 | CommonElement{ 51 | Value: "13", 52 | }, 53 | CommonElement{ 54 | Value: "13 KERA fcc", 55 | }, 56 | CommonElement{ 57 | Value: "KERA", 58 | }, 59 | CommonElement{ 60 | Value: "KERA", 61 | }, 62 | CommonElement{ 63 | Value: "PBS Affiliate", 64 | }, 65 | }, 66 | Icons: []Icon{ 67 | Icon{ 68 | Source: `file://C:\Perl\site/share/xmltv/icons/KERA.gif`, 69 | }, 70 | }, 71 | } 72 | if !reflect.DeepEqual(ch, tv.Channels[0]) { 73 | t.Errorf("\texpected: %# v\n\t\tactual: %# v\n", pretty.Formatter(ch), pretty.Formatter(tv.Channels[0])) 74 | } 75 | 76 | loc := time.FixedZone("", -6*60*60) 77 | date := time.Date(2008, 07, 11, 0, 0, 0, 0, time.UTC) 78 | pr := Programme{ 79 | ID: "someId", 80 | Date: Date(date), 81 | Channel: "I10436.labs.zap2it.com", 82 | Start: &Time{time.Date(2008, 07, 15, 0, 30, 0, 0, loc)}, 83 | Stop: &Time{time.Date(2008, 07, 15, 1, 0, 0, 0, loc)}, 84 | Titles: []CommonElement{ 85 | CommonElement{ 86 | Lang: "en", 87 | Value: "NOW on PBS", 88 | }, 89 | }, 90 | Descriptions: []CommonElement{ 91 | CommonElement{ 92 | Lang: "en", 93 | Value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.", 94 | }, 95 | }, 96 | Categories: []CommonElement{ 97 | CommonElement{ 98 | Lang: "en", 99 | Value: "Newsmagazine", 100 | }, 101 | CommonElement{ 102 | Lang: "en", 103 | Value: "Interview", 104 | }, 105 | CommonElement{ 106 | Lang: "en", 107 | Value: "Public affairs", 108 | }, 109 | CommonElement{ 110 | Lang: "en", 111 | Value: "Series", 112 | }, 113 | }, 114 | EpisodeNums: []EpisodeNum{ 115 | EpisodeNum{ 116 | System: "dd_progid", 117 | Value: "EP01006886.0028", 118 | }, 119 | EpisodeNum{ 120 | System: "onscreen", 121 | Value: "427", 122 | }, 123 | }, 124 | Audio: &Audio{ 125 | Stereo: "stereo", 126 | }, 127 | PreviouslyShown: &PreviouslyShown{ 128 | Start: "20080711000000", 129 | }, 130 | Subtitles: []Subtitle{ 131 | Subtitle{ 132 | Type: "teletext", 133 | }, 134 | }, 135 | } 136 | if !reflect.DeepEqual(pr, tv.Programmes[0]) { 137 | expected := fmt.Sprintf("\texpected: %# v\n\t\t\texpected start: %s\n\t\t\texpected stop : %s", pretty.Formatter(pr), pr.Start, pr.Stop) 138 | 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) 139 | t.Errorf("%s\n%s\n", expected, actual) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /lineup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "encoding/xml" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "regexp" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/spf13/viper" 17 | schedulesdirect "github.com/tellytv/go.schedulesdirect" 18 | m3u "github.com/tellytv/telly/internal/m3uplus" 19 | "github.com/tellytv/telly/internal/providers" 20 | "github.com/tellytv/telly/internal/xmltv" 21 | ) 22 | 23 | // var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString 24 | // var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString 25 | // var hdRegex = regexp.MustCompile(`hd|4k`) 26 | var xmlNSRegex = regexp.MustCompile(`(\d).(\d).(?:(\d)/(\d))?`) 27 | var ddProgIDRegex = regexp.MustCompile(`(?m)(EP|SH|MV|SP)(\d{7,8}).(\d+).?(?:(\d).(\d))?`) 28 | 29 | // hdHomeRunLineupItem is a HDHomeRun specification compatible representation of a Track available in the lineup. 30 | type hdHomeRunLineupItem struct { 31 | XMLName xml.Name `xml:"Program" json:"-"` 32 | 33 | AudioCodec string `xml:",omitempty" json:",omitempty"` 34 | DRM convertibleBoolean `xml:",omitempty" json:",string,omitempty"` 35 | Favorite convertibleBoolean `xml:",omitempty" json:",string,omitempty"` 36 | GuideName string `xml:",omitempty" json:",omitempty"` 37 | GuideNumber int `xml:",omitempty" json:",string,omitempty"` 38 | HD convertibleBoolean `xml:",omitempty" json:",string,omitempty"` 39 | URL string `xml:",omitempty" json:",omitempty"` 40 | VideoCodec string `xml:",omitempty" json:",omitempty"` 41 | 42 | provider providers.Provider 43 | providerChannel providers.ProviderChannel 44 | } 45 | 46 | func newHDHRItem(provider *providers.Provider, providerChannel *providers.ProviderChannel) hdHomeRunLineupItem { 47 | return hdHomeRunLineupItem{ 48 | DRM: convertibleBoolean(false), 49 | GuideName: providerChannel.Name, 50 | GuideNumber: providerChannel.Number, 51 | Favorite: convertibleBoolean(providerChannel.Favorite), 52 | HD: convertibleBoolean(providerChannel.HD), 53 | URL: fmt.Sprintf("http://%s/auto/v%d", viper.GetString("web.base-address"), providerChannel.Number), 54 | provider: *provider, 55 | providerChannel: *providerChannel, 56 | } 57 | } 58 | 59 | // lineup contains the state of the application. 60 | type lineup struct { 61 | Sources []providers.Provider 62 | 63 | Scanning bool 64 | 65 | // Stores the channel number for found channels without a number. 66 | assignedChannelNumber int 67 | // If true, use channel numbers found in EPG, if any, before assigning. 68 | xmlTVChannelNumbers bool 69 | 70 | channels map[int]hdHomeRunLineupItem 71 | 72 | sd *schedulesdirect.Client 73 | 74 | FfmpegEnabled bool 75 | } 76 | 77 | // newLineup returns a new lineup for the given config struct. 78 | func newLineup() *lineup { 79 | var cfgs []providers.Configuration 80 | 81 | if unmarshalErr := viper.UnmarshalKey("source", &cfgs); unmarshalErr != nil { 82 | log.WithError(unmarshalErr).Panicln("Unable to unmarshal source configuration to slice of providers.Configuration, check your configuration!") 83 | } 84 | 85 | if viper.GetString("iptv.playlist") != "" { 86 | log.Warnln("Legacy --iptv.playlist argument or environment variable provided, using Custom provider with default configuration, this may fail! If so, you should use a configuration file for full flexibility.") 87 | regexStr := ".*" 88 | if viper.IsSet("filter.regex") { 89 | regexStr = viper.GetString("filter.regex") 90 | } 91 | cfgs = append(cfgs, providers.Configuration{ 92 | Name: "Legacy provider created using arguments/environment variables", 93 | M3U: viper.GetString("iptv.playlist"), 94 | Provider: "custom", 95 | Filter: regexStr, 96 | FilterRaw: true, 97 | }) 98 | } 99 | 100 | useFFMpeg := viper.IsSet("iptv.ffmpeg") 101 | if useFFMpeg { 102 | useFFMpeg = viper.GetBool("iptv.ffmpeg") 103 | } 104 | 105 | lineup := &lineup{ 106 | assignedChannelNumber: viper.GetInt("iptv.starting-channel"), 107 | xmlTVChannelNumbers: viper.GetBool("iptv.xmltv-channels"), 108 | channels: make(map[int]hdHomeRunLineupItem), 109 | FfmpegEnabled: useFFMpeg, 110 | } 111 | 112 | if viper.IsSet("schedulesdirect.username") && viper.IsSet("schedulesdirect.password") { 113 | sdClient, sdClientErr := schedulesdirect.NewClient(viper.GetString("schedulesdirect.username"), viper.GetString("schedulesdirect.password")) 114 | if sdClientErr != nil { 115 | log.WithError(sdClientErr).Panicln("error setting up schedules direct client") 116 | } 117 | 118 | lineup.sd = sdClient 119 | } 120 | 121 | for _, cfg := range cfgs { 122 | provider, providerErr := cfg.GetProvider() 123 | if providerErr != nil { 124 | panic(providerErr) 125 | } 126 | 127 | lineup.Sources = append(lineup.Sources, provider) 128 | } 129 | 130 | return lineup 131 | } 132 | 133 | // Scan processes all sources. 134 | func (l *lineup) Scan() error { 135 | 136 | l.Scanning = true 137 | 138 | totalAddedChannels := 0 139 | 140 | for _, provider := range l.Sources { 141 | addedChannels, providerErr := l.processProvider(provider) 142 | if providerErr != nil { 143 | log.WithError(providerErr).Errorln("error when processing provider") 144 | } 145 | totalAddedChannels = totalAddedChannels + addedChannels 146 | } 147 | 148 | if totalAddedChannels > 420 { 149 | log.Panicf("telly has loaded more than 420 channels (%d) into the lineup. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You must use regular expressions to filter out channels. You can also start another Telly instance.", totalAddedChannels) 150 | } 151 | 152 | l.Scanning = false 153 | 154 | return nil 155 | } 156 | 157 | func (l *lineup) processProvider(provider providers.Provider) (int, error) { 158 | addedChannels := 0 159 | m3u, channelMap, programmeMap, prepareErr := l.prepareProvider(provider) 160 | if prepareErr != nil { 161 | log.WithError(prepareErr).Errorln("error when preparing provider") 162 | return 0, prepareErr 163 | } 164 | 165 | if provider.Configuration().SortKey != "" { 166 | sortKey := provider.Configuration().SortKey 167 | sort.Slice(m3u.Tracks, func(i, j int) bool { 168 | if _, ok := m3u.Tracks[i].Tags[sortKey]; ok { 169 | log.Panicf("the provided sort key (%s) doesn't exist in the M3U!", sortKey) 170 | return false 171 | } 172 | ii := m3u.Tracks[i].Tags[sortKey] 173 | jj := m3u.Tracks[j].Tags[sortKey] 174 | if provider.Configuration().SortReverse { 175 | return ii < jj 176 | } 177 | return ii > jj 178 | }) 179 | } 180 | 181 | successChannels := []string{} 182 | failedChannels := []string{} 183 | 184 | for _, track := range m3u.Tracks { 185 | // First, we run the filter. 186 | if !l.FilterTrack(provider, track) { 187 | failedChannels = append(failedChannels, track.Name) 188 | continue 189 | } else { 190 | successChannels = append(successChannels, track.Name) 191 | } 192 | 193 | // Then we do the provider specific translation to a hdHomeRunLineupItem. 194 | channel, channelErr := provider.ParseTrack(track, channelMap) 195 | if channelErr != nil { 196 | return addedChannels, channelErr 197 | } 198 | 199 | channel, processErr := l.processProviderChannel(channel, programmeMap) 200 | if processErr != nil { 201 | log.WithError(processErr).Errorln("error processing track") 202 | continue 203 | } else if channel == nil { 204 | log.Infof("Channel %s was returned empty from the provider (%s)", track.Name, provider.Name()) 205 | continue 206 | } 207 | addedChannels = addedChannels + 1 208 | 209 | l.channels[channel.Number] = newHDHRItem(&provider, channel) 210 | } 211 | 212 | log.Debugf("These channels (%d) passed the filter and successfully parsed: %s", len(successChannels), strings.Join(successChannels, ", ")) 213 | log.Debugf("These channels (%d) did NOT pass the filter: %s", len(failedChannels), strings.Join(failedChannels, ", ")) 214 | 215 | log.Infof("Loaded %d channels into the lineup from %s", addedChannels, provider.Name()) 216 | 217 | if addedChannels == 0 { 218 | log.Infof("Check your filter; %d channels were blocked by it", len(failedChannels)) 219 | } 220 | 221 | return addedChannels, nil 222 | } 223 | 224 | func (l *lineup) prepareProvider(provider providers.Provider) (*m3u.Playlist, map[string]xmltv.Channel, map[string][]xmltv.Programme, error) { 225 | cacheFiles := provider.Configuration().CacheFiles 226 | 227 | reader, m3uErr := getM3U(provider.PlaylistURL(), cacheFiles) 228 | if m3uErr != nil { 229 | log.WithError(m3uErr).Errorln("unable to get m3u file") 230 | return nil, nil, nil, m3uErr 231 | } 232 | 233 | rawPlaylist, err := m3u.Decode(reader) 234 | if err != nil { 235 | log.WithError(err).Errorln("unable to parse m3u file") 236 | return nil, nil, nil, err 237 | } 238 | 239 | for _, playlistTrack := range rawPlaylist.Tracks { 240 | if (playlistTrack.URI.Scheme == "http" || playlistTrack.URI.Scheme == "udp") && !l.FfmpegEnabled { 241 | log.Errorf("The playlist you tried to add has at least one entry using a protocol other than http or udp and you have ffmpeg disabled in your config. This will most likely not work. Offending URI is %s", playlistTrack.URI) 242 | } 243 | } 244 | 245 | if closeM3UErr := reader.Close(); closeM3UErr != nil { 246 | log.WithError(closeM3UErr).Panicln("error when closing m3u reader") 247 | } 248 | 249 | channelMap, programmeMap, epgErr := l.prepareEPG(provider, cacheFiles) 250 | if epgErr != nil { 251 | log.WithError(epgErr).Errorln("error when parsing EPG") 252 | return nil, nil, nil, epgErr 253 | } 254 | 255 | return rawPlaylist, channelMap, programmeMap, nil 256 | } 257 | 258 | func (l *lineup) processProviderChannel(channel *providers.ProviderChannel, programmeMap map[string][]xmltv.Programme) (*providers.ProviderChannel, error) { 259 | if channel.EPGChannel != nil { 260 | channel.EPGProgrammes = programmeMap[channel.EPGMatch] 261 | } 262 | 263 | if !l.xmlTVChannelNumbers || channel.Number == 0 { 264 | channel.Number = l.assignedChannelNumber 265 | l.assignedChannelNumber = l.assignedChannelNumber + 1 266 | } 267 | 268 | if channel.EPGChannel != nil && channel.EPGChannel.LCN == 0 { 269 | channel.EPGChannel.LCN = channel.Number 270 | } 271 | 272 | if channel.Logo != "" && channel.EPGChannel != nil && !containsIcon(channel.EPGChannel.Icons, channel.Logo) { 273 | if viper.GetBool("misc.ignore-epg-icons") { 274 | channel.EPGChannel.Icons = nil 275 | } 276 | channel.EPGChannel.Icons = append(channel.EPGChannel.Icons, xmltv.Icon{Source: channel.Logo}) 277 | } 278 | 279 | return channel, nil 280 | } 281 | 282 | func (l *lineup) FilterTrack(provider providers.Provider, track m3u.Track) bool { 283 | config := provider.Configuration() 284 | if config.Filter == "" && len(config.IncludeOnly) == 0 { 285 | return true 286 | } 287 | 288 | if v, ok := track.Tags[config.IncludeOnlyTag]; len(config.IncludeOnly) > 0 && ok { 289 | return contains(config.IncludeOnly, v) 290 | } 291 | 292 | filterRegex, regexErr := regexp.Compile(config.Filter) 293 | if regexErr != nil { 294 | log.WithError(regexErr).Panicln("your regex is invalid") 295 | return false 296 | } 297 | 298 | if config.FilterRaw { 299 | return filterRegex.MatchString(track.Raw) 300 | } 301 | 302 | log.Debugf("track.Tags %+v", track.Tags) 303 | 304 | filterKey := provider.RegexKey() 305 | if config.FilterKey != "" { 306 | filterKey = config.FilterKey 307 | } 308 | 309 | if key, ok := track.Tags[filterKey]; key != "" && !ok { 310 | log.Warnf("the provided filter key (%s) does not exist or is blank, skipping track: %s", config.FilterKey, track.Raw) 311 | return false 312 | } 313 | 314 | log.Debugf("Checking if filter (%s) matches string %s", config.Filter, track.Tags[filterKey]) 315 | 316 | return filterRegex.MatchString(track.Tags[filterKey]) 317 | 318 | } 319 | 320 | func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[string]xmltv.Channel, map[string][]xmltv.Programme, error) { 321 | var epg *xmltv.TV 322 | epgChannelMap := make(map[string]xmltv.Channel) 323 | epgProgrammeMap := make(map[string][]xmltv.Programme) 324 | if provider.EPGURL() != "" { 325 | var epgErr error 326 | epg, epgErr = getXMLTV(provider.EPGURL(), cacheFiles) 327 | if epgErr != nil { 328 | return epgChannelMap, epgProgrammeMap, epgErr 329 | } 330 | 331 | augmentWithSD := viper.IsSet("schedulesdirect.username") && viper.IsSet("schedulesdirect.password") 332 | 333 | sdEligible := make(map[string]xmltv.Programme) // TMSID:programme 334 | haveAllInfo := make(map[string][]xmltv.Programme) // channel number:[]programme 335 | 336 | for _, channel := range epg.Channels { 337 | epgChannelMap[channel.ID] = channel 338 | 339 | for _, programme := range epg.Programmes { 340 | if programme.Channel == channel.ID { 341 | ddProgID := "" 342 | if augmentWithSD { 343 | for _, epNum := range programme.EpisodeNums { 344 | if epNum.System == "dd_progid" { 345 | ddProgID = epNum.Value 346 | } 347 | } 348 | } 349 | if augmentWithSD == true && ddProgID != "" { 350 | idType, uniqID, epID, _, _, extractErr := extractDDProgID(ddProgID) 351 | if extractErr != nil { 352 | log.WithError(extractErr).Errorln("error extracting dd_progid") 353 | continue 354 | } 355 | cleanID := fmt.Sprintf("%s%s%s", idType, padNumberWithZero(uniqID, 8), padNumberWithZero(epID, 4)) 356 | if len(cleanID) < 14 { 357 | log.Warnf("found an invalid TMS ID/dd_progid, expected length of exactly 14, got %d: %s\n", len(cleanID), cleanID) 358 | continue 359 | } 360 | 361 | sdEligible[cleanID] = programme 362 | } else { 363 | haveAllInfo[channel.ID] = append(haveAllInfo[channel.ID], programme) 364 | } 365 | } 366 | } 367 | } 368 | 369 | if augmentWithSD { 370 | tmsIDs := make([]string, 0) 371 | 372 | for tmsID := range sdEligible { 373 | idType, uniqID, epID, _, _, extractErr := extractDDProgID(tmsID) 374 | if extractErr != nil { 375 | log.WithError(extractErr).Errorln("error extracting dd_progid") 376 | continue 377 | } 378 | cleanID := fmt.Sprintf("%s%s%s", idType, padNumberWithZero(uniqID, 8), padNumberWithZero(epID, 4)) 379 | if len(cleanID) < 14 { 380 | log.Warnf("found an invalid TMS ID/dd_progid, expected length of exactly 14, got %d: %s\n", len(cleanID), cleanID) 381 | continue 382 | } 383 | tmsIDs = append(tmsIDs, cleanID) 384 | } 385 | 386 | log.Infof("Requesting guide data for %d programs from Schedules Direct", len(tmsIDs)) 387 | 388 | allResponses := make([]schedulesdirect.ProgramInfo, 0) 389 | 390 | artworkMap := make(map[string][]schedulesdirect.ProgramArtwork) 391 | 392 | chunks := chunkStringSlice(tmsIDs, 5000) 393 | 394 | log.Infof("Making %d requests to Schedules Direct for program information, this might take a while", len(chunks)) 395 | 396 | for _, chunk := range chunks { 397 | moreInfo, moreInfoErr := l.sd.GetProgramInfo(chunk) 398 | if moreInfoErr != nil { 399 | log.WithError(moreInfoErr).Errorln("Error when getting more program details from Schedules Direct") 400 | return epgChannelMap, epgProgrammeMap, moreInfoErr 401 | } 402 | 403 | log.Debugf("received %d responses for chunk", len(moreInfo)) 404 | 405 | allResponses = append(allResponses, moreInfo...) 406 | } 407 | 408 | artworkTMSIDs := make([]string, 0) 409 | 410 | for _, entry := range allResponses { 411 | if entry.HasArtwork() { 412 | artworkTMSIDs = append(artworkTMSIDs, entry.ProgramID) 413 | } 414 | } 415 | 416 | chunks = chunkStringSlice(artworkTMSIDs, 500) 417 | 418 | log.Infof("Making %d requests to Schedules Direct for artwork, this might take a while", len(chunks)) 419 | 420 | for _, chunk := range chunks { 421 | artwork, artworkErr := l.sd.GetArtworkForProgramIDs(chunk) 422 | if artworkErr != nil { 423 | log.WithError(artworkErr).Errorln("Error when getting program artwork from Schedules Direct") 424 | return epgChannelMap, epgProgrammeMap, artworkErr 425 | } 426 | 427 | for _, artworks := range artwork { 428 | if artworks.ProgramID == "" || artworks.Artwork == nil { 429 | continue 430 | } 431 | artworkMap[artworks.ProgramID] = append(artworkMap[artworks.ProgramID], *artworks.Artwork...) 432 | } 433 | } 434 | 435 | log.Debugf("Got %d responses from SD", len(allResponses)) 436 | 437 | for _, sdResponse := range allResponses { 438 | programme := sdEligible[sdResponse.ProgramID] 439 | mergedProgramme := MergeSchedulesDirectAndXMLTVProgramme(&programme, sdResponse, artworkMap[sdResponse.ProgramID]) 440 | haveAllInfo[mergedProgramme.Channel] = append(haveAllInfo[mergedProgramme.Channel], *mergedProgramme) 441 | } 442 | } 443 | 444 | for _, programmes := range haveAllInfo { 445 | for _, programme := range programmes { 446 | processedProgram := *provider.ProcessProgramme(programme) 447 | hasXMLTV := false 448 | itemType := "" 449 | for _, epNum := range processedProgram.EpisodeNums { 450 | if epNum.System == "dd_progid" { 451 | idType, _, _, _, _, extractErr := extractDDProgID(epNum.Value) 452 | if extractErr != nil { 453 | log.WithError(extractErr).Errorln("error extracting dd_progid") 454 | continue 455 | } 456 | itemType = idType 457 | } 458 | if epNum.System == "xmltv_ns" { 459 | hasXMLTV = true 460 | } 461 | } 462 | if (itemType == "SH" || itemType == "EP") && !hasXMLTV { 463 | t := time.Time(processedProgram.Date) 464 | if !t.IsZero() { 465 | processedProgram.EpisodeNums = append(processedProgram.EpisodeNums, xmltv.EpisodeNum{System: "original-air-date", Value: t.Format("2006-01-02 15:04:05")}) 466 | } 467 | } 468 | epgProgrammeMap[programme.Channel] = append(epgProgrammeMap[programme.Channel], processedProgram) 469 | } 470 | } 471 | 472 | } 473 | 474 | return epgChannelMap, epgProgrammeMap, nil 475 | } 476 | 477 | func getM3U(path string, cacheFiles bool) (io.ReadCloser, error) { 478 | safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) 479 | log.Infof("Loading M3U from %s", safePath) 480 | 481 | file, _, err := getFile(path, cacheFiles) 482 | if err != nil { 483 | return nil, err 484 | } 485 | 486 | return file, nil 487 | } 488 | 489 | func getXMLTV(path string, cacheFiles bool) (*xmltv.TV, error) { 490 | safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) 491 | log.Infof("Loading XMLTV from %s", safePath) 492 | file, _, err := getFile(path, cacheFiles) 493 | if err != nil { 494 | return nil, err 495 | } 496 | 497 | decoder := xml.NewDecoder(file) 498 | tvSetup := new(xmltv.TV) 499 | if err := decoder.Decode(tvSetup); err != nil { 500 | log.WithError(err).Errorln("Could not decode xmltv programme") 501 | return nil, err 502 | } 503 | 504 | if closeXMLErr := file.Close(); closeXMLErr != nil { 505 | log.WithError(closeXMLErr).Panicln("error when closing xml reader") 506 | } 507 | 508 | return tvSetup, nil 509 | } 510 | 511 | func getFile(path string, cacheFiles bool) (io.ReadCloser, string, error) { 512 | transport := "disk" 513 | 514 | if strings.HasPrefix(strings.ToLower(path), "http") { 515 | 516 | transport = "http" 517 | 518 | req, reqErr := http.NewRequest("GET", path, nil) 519 | if reqErr != nil { 520 | return nil, transport, reqErr 521 | } 522 | 523 | // For whatever reason, some providers only allow access from a "real" User-Agent. 524 | 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") 525 | 526 | resp, err := http.Get(path) 527 | if err != nil { 528 | return nil, transport, err 529 | } 530 | 531 | if strings.HasSuffix(strings.ToLower(path), ".gz") || resp.Header.Get("Content-Type") == "application/x-gzip" { 532 | log.Infof("File (%s) is gzipp'ed, ungzipping now, this might take a while", path) 533 | gz, gzErr := gzip.NewReader(resp.Body) 534 | if gzErr != nil { 535 | return nil, transport, gzErr 536 | } 537 | 538 | if cacheFiles { 539 | return writeFile(path, transport, gz) 540 | } 541 | 542 | return gz, transport, nil 543 | } 544 | 545 | if cacheFiles { 546 | return writeFile(path, transport, resp.Body) 547 | } 548 | 549 | return resp.Body, transport, nil 550 | } 551 | 552 | file, fileErr := os.Open(path) 553 | if fileErr != nil { 554 | return nil, transport, fileErr 555 | } 556 | 557 | return file, transport, nil 558 | } 559 | 560 | func writeFile(path, transport string, reader io.ReadCloser) (io.ReadCloser, string, error) { 561 | // buf := new(bytes.Buffer) 562 | // buf.ReadFrom(reader) 563 | // buf.Bytes() 564 | return reader, transport, nil 565 | } 566 | 567 | func containsIcon(s []xmltv.Icon, e string) bool { 568 | for _, ss := range s { 569 | if e == ss.Source { 570 | return true 571 | } 572 | } 573 | return false 574 | } 575 | 576 | func chunkStringSlice(sl []string, chunkSize int) [][]string { 577 | var divided [][]string 578 | 579 | for i := 0; i < len(sl); i += chunkSize { 580 | end := i + chunkSize 581 | 582 | if end > len(sl) { 583 | end = len(sl) 584 | } 585 | 586 | divided = append(divided, sl[i:end]) 587 | } 588 | return divided 589 | } 590 | 591 | func MergeSchedulesDirectAndXMLTVProgramme(programme *xmltv.Programme, sdProgram schedulesdirect.ProgramInfo, artworks []schedulesdirect.ProgramArtwork) *xmltv.Programme { 592 | 593 | allTitles := make([]string, 0) 594 | 595 | for _, title := range programme.Titles { 596 | allTitles = append(allTitles, title.Value) 597 | } 598 | 599 | for _, title := range sdProgram.Titles { 600 | allTitles = append(allTitles, title.Title120) 601 | } 602 | 603 | for _, title := range UniqueStrings(allTitles) { 604 | programme.Titles = append(programme.Titles, xmltv.CommonElement{Value: title}) 605 | } 606 | 607 | allKeywords := make([]string, 0) 608 | 609 | for _, keyword := range programme.Keywords { 610 | allKeywords = append(allKeywords, keyword.Value) 611 | } 612 | 613 | for _, keywords := range sdProgram.Keywords { 614 | for _, keyword := range keywords { 615 | allKeywords = append(allKeywords, keyword) 616 | } 617 | } 618 | 619 | for _, keyword := range UniqueStrings(allKeywords) { 620 | programme.Keywords = append(programme.Keywords, xmltv.CommonElement{Value: keyword}) 621 | } 622 | 623 | // FIXME: We should really be making sure that we passthrough languages. 624 | allDescriptions := make([]string, 0) 625 | 626 | for _, description := range programme.Descriptions { 627 | allDescriptions = append(allDescriptions, description.Value) 628 | } 629 | 630 | for _, descriptions := range sdProgram.Descriptions { 631 | for _, description := range descriptions { 632 | if description.Description != "" { 633 | allDescriptions = append(allDescriptions, description.Description) 634 | } 635 | if description.Description != "" { 636 | allDescriptions = append(allDescriptions, description.Description) 637 | } 638 | } 639 | } 640 | 641 | for _, description := range UniqueStrings(allDescriptions) { 642 | programme.Descriptions = append(programme.Descriptions, xmltv.CommonElement{Value: description}) 643 | } 644 | 645 | allRatings := make(map[string]string, 0) 646 | 647 | for _, rating := range programme.Ratings { 648 | allRatings[rating.System] = rating.Value 649 | } 650 | 651 | for _, rating := range sdProgram.ContentRating { 652 | allRatings[rating.Body] = rating.Code 653 | } 654 | 655 | for system, rating := range allRatings { 656 | programme.Ratings = append(programme.Ratings, xmltv.Rating{Value: rating, System: system}) 657 | } 658 | 659 | for _, artwork := range artworks { 660 | programme.Icons = append(programme.Icons, xmltv.Icon{ 661 | Source: getImageURL(artwork.URI), 662 | Width: artwork.Width, 663 | Height: artwork.Height, 664 | }) 665 | } 666 | 667 | hasXMLTVNS := false 668 | ddProgID := "" 669 | 670 | for _, epNum := range programme.EpisodeNums { 671 | if epNum.System == "xmltv_ns" { 672 | hasXMLTVNS = true 673 | } else if epNum.System == "dd_progid" { 674 | ddProgID = epNum.Value 675 | } 676 | } 677 | 678 | if !hasXMLTVNS { 679 | seasonNumber := 0 680 | episodeNumber := 0 681 | totalSeasons := 0 682 | totalEpisodes := 0 683 | numbersFilled := false 684 | 685 | for _, meta := range sdProgram.Metadata { 686 | for _, metadata := range meta { 687 | if metadata.Season > 0 { 688 | seasonNumber = metadata.Season - 1 // SD metadata isnt 0 index 689 | numbersFilled = true 690 | } 691 | if metadata.Episode > 0 { 692 | episodeNumber = metadata.Episode - 1 693 | numbersFilled = true 694 | } 695 | if metadata.TotalEpisodes > 0 { 696 | totalEpisodes = metadata.TotalEpisodes 697 | numbersFilled = true 698 | } 699 | if metadata.TotalSeasons > 0 { 700 | totalSeasons = metadata.TotalSeasons 701 | numbersFilled = true 702 | } 703 | } 704 | } 705 | 706 | if numbersFilled { 707 | seasonNumberStr := fmt.Sprintf("%d", seasonNumber) 708 | if totalSeasons > 0 { 709 | seasonNumberStr = fmt.Sprintf("%d/%d", seasonNumber, totalSeasons) 710 | } 711 | episodeNumberStr := fmt.Sprintf("%d", episodeNumber) 712 | if totalEpisodes > 0 { 713 | episodeNumberStr = fmt.Sprintf("%d/%d", episodeNumber, totalEpisodes) 714 | } 715 | 716 | partNumber := 0 717 | totalParts := 0 718 | 719 | if ddProgID != "" { 720 | var extractErr error 721 | _, _, _, partNumber, totalParts, extractErr = extractDDProgID(ddProgID) 722 | if extractErr != nil { 723 | panic(extractErr) 724 | } 725 | } 726 | 727 | partStr := "0" 728 | if partNumber > 0 { 729 | partStr = fmt.Sprintf("%d", partNumber) 730 | if totalParts > 0 { 731 | partStr = fmt.Sprintf("%d/%d", partNumber, totalParts) 732 | } 733 | } 734 | 735 | xmlTVNS := fmt.Sprintf("%s.%s.%s", seasonNumberStr, episodeNumberStr, partStr) 736 | programme.EpisodeNums = append(programme.EpisodeNums, xmltv.EpisodeNum{System: "xmltv_ns", Value: xmlTVNS}) 737 | } 738 | } 739 | 740 | return programme 741 | } 742 | 743 | func extractXMLTVNS(str string) (int, int, int, int, error) { 744 | matches := xmlNSRegex.FindAllStringSubmatch(str, -1) 745 | 746 | if len(matches) == 0 { 747 | return 0, 0, 0, 0, fmt.Errorf("invalid xmltv_ns: %s", str) 748 | } 749 | 750 | season, seasonErr := strconv.Atoi(matches[0][1]) 751 | if seasonErr != nil { 752 | return 0, 0, 0, 0, seasonErr 753 | } 754 | 755 | episode, episodeErr := strconv.Atoi(matches[0][2]) 756 | if episodeErr != nil { 757 | return 0, 0, 0, 0, episodeErr 758 | } 759 | 760 | currentPartNum := 0 761 | totalPartsNum := 0 762 | 763 | if len(matches[0]) > 2 && matches[0][3] != "" { 764 | currentPart, currentPartErr := strconv.Atoi(matches[0][3]) 765 | if currentPartErr != nil { 766 | return 0, 0, 0, 0, currentPartErr 767 | } 768 | currentPartNum = currentPart 769 | } 770 | 771 | if len(matches[0]) > 3 && matches[0][4] != "" { 772 | totalParts, totalPartsErr := strconv.Atoi(matches[0][4]) 773 | if totalPartsErr != nil { 774 | return 0, 0, 0, 0, totalPartsErr 775 | } 776 | totalPartsNum = totalParts 777 | } 778 | 779 | // if season > 0 { 780 | // season = season - 1 781 | // } 782 | 783 | // if episode > 0 { 784 | // episode = episode - 1 785 | // } 786 | 787 | // if currentPartNum > 0 { 788 | // currentPartNum = currentPartNum - 1 789 | // } 790 | 791 | // if totalPartsNum > 0 { 792 | // totalPartsNum = totalPartsNum - 1 793 | // } 794 | 795 | return season, episode, currentPartNum, totalPartsNum, nil 796 | } 797 | 798 | // extractDDProgID returns type, ID, episode ID, part number, total parts, error. 799 | func extractDDProgID(progID string) (string, int, int, int, int, error) { 800 | matches := ddProgIDRegex.FindAllStringSubmatch(progID, -1) 801 | 802 | if len(matches) == 0 { 803 | return "", 0, 0, 0, 0, fmt.Errorf("invalid dd_progid: %s", progID) 804 | } 805 | 806 | itemType := matches[0][1] 807 | 808 | itemID, itemIDErr := strconv.Atoi(matches[0][2]) 809 | if itemIDErr != nil { 810 | return itemType, 0, 0, 0, 0, itemIDErr 811 | } 812 | 813 | specificID, specificIDErr := strconv.Atoi(matches[0][3]) 814 | if specificIDErr != nil { 815 | return itemType, itemID, 0, 0, 0, specificIDErr 816 | } 817 | 818 | currentPartNum := 0 819 | totalPartsNum := 0 820 | 821 | if len(matches[0]) > 2 && matches[0][4] != "" { 822 | currentPart, currentPartErr := strconv.Atoi(matches[0][4]) 823 | if currentPartErr != nil { 824 | return itemType, itemID, specificID, 0, 0, currentPartErr 825 | } 826 | currentPartNum = currentPart 827 | } 828 | 829 | if len(matches[0]) > 3 && matches[0][5] != "" { 830 | totalParts, totalPartsErr := strconv.Atoi(matches[0][5]) 831 | if totalPartsErr != nil { 832 | return itemType, itemID, specificID, currentPartNum, 0, totalPartsErr 833 | } 834 | totalPartsNum = totalParts 835 | } 836 | 837 | return itemType, itemID, specificID, currentPartNum, totalPartsNum, nil 838 | } 839 | 840 | func UniqueStrings(input []string) []string { 841 | u := make([]string, 0, len(input)) 842 | m := make(map[string]bool) 843 | 844 | for _, val := range input { 845 | if _, ok := m[val]; !ok { 846 | m[val] = true 847 | u = append(u, val) 848 | } 849 | } 850 | 851 | return u 852 | } 853 | 854 | func getImageURL(imageURI string) string { 855 | if strings.HasPrefix(imageURI, "https://s3.amazonaws.com") { 856 | return imageURI 857 | } 858 | return fmt.Sprint(schedulesdirect.DefaultBaseURL, schedulesdirect.APIVersion, "/image/", imageURI) 859 | } 860 | 861 | func padNumberWithZero(value int, expectedLength int) string { 862 | padded := fmt.Sprintf("%02d", value) 863 | valLength := countDigits(value) 864 | if valLength != expectedLength { 865 | return fmt.Sprintf("%s%d", strings.Repeat("0", expectedLength-valLength), value) 866 | } 867 | return padded 868 | } 869 | 870 | func countDigits(i int) int { 871 | count := 0 872 | if i == 0 { 873 | count = 1 874 | } 875 | for i != 0 { 876 | i /= 10 877 | count = count + 1 878 | } 879 | return count 880 | } 881 | 882 | func contains(s []string, e string) bool { 883 | for _, ss := range s { 884 | if e == ss { 885 | return true 886 | } 887 | } 888 | return false 889 | } 890 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | fflag "flag" 6 | "fmt" 7 | "net" 8 | "os" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/prometheus/client_golang/prometheus" 13 | "github.com/prometheus/common/version" 14 | "github.com/sirupsen/logrus" 15 | flag "github.com/spf13/pflag" 16 | "github.com/spf13/viper" 17 | ) 18 | 19 | var ( 20 | namespace = "telly" 21 | namespaceWithVersion = fmt.Sprintf("%s %s", namespace, version.Version) 22 | log = &logrus.Logger{ 23 | Out: os.Stderr, 24 | Formatter: &logrus.TextFormatter{ 25 | FullTimestamp: true, 26 | }, 27 | Hooks: make(logrus.LevelHooks), 28 | Level: logrus.DebugLevel, 29 | } 30 | 31 | exposedChannels = prometheus.NewGauge( 32 | prometheus.GaugeOpts{ 33 | Name: "exposed_channels_total", 34 | Help: "Number of exposed channels.", 35 | }, 36 | ) 37 | 38 | safeStringsRegex = regexp.MustCompile(`(?m)(username|password|token)=[\w=]+(&?)`) 39 | 40 | stringSafer = func(input string) string { 41 | ret := input 42 | if strings.HasPrefix(input, "username=") { 43 | ret = "username=REDACTED" 44 | } else if strings.HasPrefix(input, "password=") { 45 | ret = "password=REDACTED" 46 | } else if strings.HasPrefix(input, "token=") { 47 | ret = "token=bm90Zm9yeW91" // "notforyou" 48 | } 49 | if strings.HasSuffix(input, "&") { 50 | return fmt.Sprintf("%s&", ret) 51 | } 52 | return ret 53 | } 54 | ) 55 | 56 | func main() { 57 | 58 | // Discovery flags 59 | flag.String("discovery.device-id", "12345678", "8 alpha-numeric characters used to uniquely identify the device. $(TELLY_DISCOVERY_DEVICE_ID)") 60 | 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)") 61 | flag.String("discovery.device-auth", "telly123", "Only change this if you know what you're doing $(TELLY_DISCOVERY_DEVICE_AUTH)") 62 | flag.String("discovery.device-manufacturer", "Silicondust", "Manufacturer exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MANUFACTURER)") 63 | flag.String("discovery.device-model-number", "HDTC-2US", "Model number exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MODEL_NUMBER)") 64 | flag.String("discovery.device-firmware-name", "hdhomeruntc_atsc", "Firmware name exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_NAME)") 65 | flag.String("discovery.device-firmware-version", "20150826", "Firmware version exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_VERSION)") 66 | flag.Bool("discovery.ssdp", true, "Turn on SSDP announcement of telly to the local network $(TELLY_DISCOVERY_SSDP)") 67 | 68 | // Regex/filtering flags 69 | 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)") 70 | flag.String("filter.regex", ".*", "Use regex to filter for channels that you want. A basic example would be .*UK.*. $(TELLY_FILTER_REGEX)") 71 | 72 | // Web flags 73 | flag.StringP("web.listen-address", "l", "localhost:6077", "Address to listen on for web interface and telemetry $(TELLY_WEB_LISTEN_ADDRESS)") 74 | flag.StringP("web.base-address", "b", "localhost:6077", "The address to expose via discovery. Useful with reverse proxy $(TELLY_WEB_BASE_ADDRESS)") 75 | 76 | // Log flags 77 | 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)") 78 | flag.Bool("log.requests", false, "Log HTTP requests $(TELLY_LOG_REQUESTS)") 79 | 80 | // IPTV flags 81 | flag.String("iptv.playlist", "", "Path to an M3U file on disk or at a URL. $(TELLY_IPTV_PLAYLIST)") 82 | flag.Int("iptv.streams", 1, "Number of concurrent streams allowed $(TELLY_IPTV_STREAMS)") 83 | flag.Int("iptv.starting-channel", 10000, "The channel number to start exposing from. $(TELLY_IPTV_STARTING_CHANNEL)") 84 | flag.Bool("iptv.xmltv-channels", true, "Use channel numbers discovered via XMLTV file, if provided. $(TELLY_IPTV_XMLTV_CHANNELS)") 85 | 86 | // Misc flags 87 | 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)") 88 | flag.Bool("version", false, "Show application version") 89 | 90 | flag.CommandLine.AddGoFlagSet(fflag.CommandLine) 91 | 92 | deprecatedFlags := []string{ 93 | "discovery.device-id", 94 | "discovery.device-friendly-name", 95 | "discovery.device-auth", 96 | "discovery.device-manufacturer", 97 | "discovery.device-model-number", 98 | "discovery.device-firmware-name", 99 | "discovery.device-firmware-version", 100 | "discovery.ssdp", 101 | "iptv.playlist", 102 | "iptv.streams", 103 | "iptv.starting-channel", 104 | "iptv.xmltv-channels", 105 | "filter.regex-inclusive", 106 | "filter.regex", 107 | } 108 | 109 | for _, depFlag := range deprecatedFlags { 110 | if depErr := flag.CommandLine.MarkDeprecated(depFlag, "use the configuration file instead."); depErr != nil { 111 | log.WithError(depErr).Panicf("error marking flag %s as deprecated", depFlag) 112 | } 113 | } 114 | 115 | flag.Parse() 116 | if bindErr := viper.BindPFlags(flag.CommandLine); bindErr != nil { 117 | log.WithError(bindErr).Panicln("error binding flags to viper") 118 | } 119 | 120 | if flag.Lookup("version").Changed { 121 | fmt.Println(version.Print(namespace)) 122 | os.Exit(0) 123 | } 124 | 125 | if flag.Lookup("config.file").Changed { 126 | viper.SetConfigFile(flag.Lookup("config.file").Value.String()) 127 | } else { 128 | viper.SetConfigName("telly.config") 129 | viper.AddConfigPath("/etc/telly/") 130 | viper.AddConfigPath("$HOME/.telly") 131 | viper.AddConfigPath(".") 132 | viper.SetEnvPrefix(namespace) 133 | viper.AutomaticEnv() 134 | } 135 | 136 | err := viper.ReadInConfig() 137 | if err != nil { 138 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 139 | log.WithError(err).Panicln("fatal error while reading config file:") 140 | } 141 | } 142 | 143 | prometheus.MustRegister(version.NewCollector("telly"), exposedChannels) 144 | 145 | level, parseLevelErr := logrus.ParseLevel(viper.GetString("log.level")) 146 | if parseLevelErr != nil { 147 | log.WithError(parseLevelErr).Panicln("error setting log level!") 148 | } 149 | log.SetLevel(level) 150 | 151 | log.Infoln("telly is preparing to go live", version.Info()) 152 | log.Debugln("Build context", version.BuildContext()) 153 | 154 | validateConfig() 155 | 156 | viper.Set("discovery.device-uuid", fmt.Sprintf("%s-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetString("discovery.device-id"))) 157 | 158 | if log.Level == logrus.DebugLevel { 159 | js, jsErr := json.MarshalIndent(viper.AllSettings(), "", " ") 160 | if jsErr != nil { 161 | log.WithError(jsErr).Panicln("error marshal indenting viper config to JSON") 162 | } 163 | log.Debugf("Loaded configuration %s", js) 164 | } 165 | 166 | lineup := newLineup() 167 | 168 | if scanErr := lineup.Scan(); scanErr != nil { 169 | log.WithError(scanErr).Panicln("Error scanning lineup!") 170 | } 171 | 172 | serve(lineup) 173 | } 174 | 175 | func validateConfig() { 176 | if viper.IsSet("filter.regexstr") { 177 | if _, regexErr := regexp.Compile(viper.GetString("filter.regex")); regexErr != nil { 178 | log.WithError(regexErr).Panicln("Error when compiling regex, is it valid?") 179 | } 180 | } 181 | 182 | if !(viper.IsSet("source")) { 183 | log.Warnln("There is no source element in the configuration, the config file is likely missing.") 184 | } 185 | 186 | var addrErr error 187 | if _, addrErr = net.ResolveTCPAddr("tcp", viper.GetString("web.listenaddress")); addrErr != nil { 188 | log.WithError(addrErr).Panic("Error when parsing Listen address, please check the address and try again.") 189 | return 190 | } 191 | 192 | if _, addrErr = net.ResolveTCPAddr("tcp", viper.GetString("web.base-address")); addrErr != nil { 193 | log.WithError(addrErr).Panic("Error when parsing Base addresses, please check the address and try again.") 194 | return 195 | } 196 | 197 | if getTCPAddr("web.base-address").IP.IsUnspecified() { 198 | 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.") 199 | } 200 | 201 | if getTCPAddr("web.listenaddress").IP.IsUnspecified() && getTCPAddr("web.base-address").IP.IsLoopback() { 202 | 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?") 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /routes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/xml" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os/exec" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/gin-contrib/cors" 17 | "github.com/gin-gonic/gin" 18 | "github.com/gobuffalo/packr" 19 | ssdp "github.com/koron/go-ssdp" 20 | "github.com/sirupsen/logrus" 21 | "github.com/spf13/viper" 22 | ginprometheus "github.com/tellytv/telly/internal/go-gin-prometheus" 23 | "github.com/tellytv/telly/internal/xmltv" 24 | ) 25 | 26 | func serve(lineup *lineup) { 27 | discoveryData := getDiscoveryData() 28 | 29 | log.Debugln("creating device xml") 30 | upnp := discoveryData.UPNP() 31 | 32 | log.Debugln("creating webserver routes") 33 | 34 | if viper.GetString("log.level") != logrus.DebugLevel.String() { 35 | gin.SetMode(gin.ReleaseMode) 36 | } 37 | 38 | router := gin.New() 39 | router.Use(cors.Default()) 40 | router.Use(gin.Recovery()) 41 | 42 | if viper.GetBool("log.logrequests") { 43 | router.Use(ginrus()) 44 | } 45 | 46 | p := ginprometheus.NewPrometheus("http") 47 | p.Use(router) 48 | 49 | router.GET("/", deviceXML(upnp)) 50 | router.GET("/discover.json", discovery(discoveryData)) 51 | router.GET("/lineup_status.json", func(c *gin.Context) { 52 | payload := LineupStatus{ 53 | ScanInProgress: convertibleBoolean(false), 54 | ScanPossible: convertibleBoolean(true), 55 | Source: "Cable", 56 | SourceList: []string{"Cable"}, 57 | } 58 | if lineup.Scanning { 59 | payload = LineupStatus{ 60 | ScanInProgress: convertibleBoolean(true), 61 | // Gotta fake out Plex. 62 | Progress: 50, 63 | Found: 50, 64 | } 65 | } 66 | 67 | c.JSON(http.StatusOK, payload) 68 | }) 69 | router.POST("/lineup.post", func(c *gin.Context) { 70 | scanAction := c.Query("scan") 71 | if scanAction == "start" { 72 | if refreshErr := lineup.Scan(); refreshErr != nil { 73 | c.AbortWithError(http.StatusInternalServerError, refreshErr) 74 | } 75 | c.AbortWithStatus(http.StatusOK) 76 | return 77 | } else if scanAction == "abort" { 78 | c.AbortWithStatus(http.StatusOK) 79 | return 80 | } 81 | c.String(http.StatusBadRequest, "%s is not a valid scan command", scanAction) 82 | }) 83 | router.GET("/device.xml", deviceXML(upnp)) 84 | router.GET("/lineup.json", serveLineup(lineup)) 85 | router.GET("/lineup.xml", serveLineup(lineup)) 86 | router.GET("/auto/:channelID", stream(lineup)) 87 | router.GET("/epg.xml", xmlTV(lineup)) 88 | router.GET("/debug.json", func(c *gin.Context) { 89 | c.JSON(http.StatusOK, lineup) 90 | }) 91 | 92 | if viper.GetBool("discovery.ssdp") { 93 | if _, ssdpErr := setupSSDP(viper.GetString("web.base-address"), viper.GetString("discovery.device-friendly-name"), viper.GetString("discovery.device-uuid")); ssdpErr != nil { 94 | log.WithError(ssdpErr).Errorln("telly cannot advertise over ssdp") 95 | } 96 | } 97 | 98 | box := packr.NewBox("./frontend/dist/telly-fe") 99 | 100 | router.StaticFS("/manage", box) 101 | 102 | log.Infof("telly is live and on the air!") 103 | log.Infof("Broadcasting from http://%s/", viper.GetString("web.base-address")) 104 | log.Infof("EPG URL: http://%s/epg.xml", viper.GetString("web.base-address")) 105 | log.Infof("Lineup JSON: http://%s/lineup.json", viper.GetString("web.base-address")) 106 | 107 | if err := router.Run(viper.GetString("web.listen-address")); err != nil { 108 | log.WithError(err).Panicln("Error starting up web server") 109 | } 110 | } 111 | 112 | func deviceXML(deviceXML UPNP) gin.HandlerFunc { 113 | return func(c *gin.Context) { 114 | c.XML(http.StatusOK, deviceXML) 115 | } 116 | } 117 | 118 | func discovery(data DiscoveryData) gin.HandlerFunc { 119 | return func(c *gin.Context) { 120 | c.JSON(http.StatusOK, data) 121 | } 122 | } 123 | 124 | type hdhrLineupContainer struct { 125 | XMLName xml.Name `xml:"Lineup" json:"-"` 126 | Programs []hdHomeRunLineupItem 127 | } 128 | 129 | func serveLineup(lineup *lineup) gin.HandlerFunc { 130 | return func(c *gin.Context) { 131 | channels := make([]hdHomeRunLineupItem, 0) 132 | for _, channel := range lineup.channels { 133 | channels = append(channels, channel) 134 | } 135 | sort.Slice(channels, func(i, j int) bool { 136 | return channels[i].GuideNumber < channels[j].GuideNumber 137 | }) 138 | if strings.HasSuffix(c.Request.URL.String(), ".xml") { 139 | buf, marshallErr := xml.MarshalIndent(hdhrLineupContainer{Programs: channels}, "", "\t") 140 | if marshallErr != nil { 141 | c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error marshalling lineup to XML")) 142 | } 143 | c.Data(http.StatusOK, "application/xml", []byte(``+"\n"+string(buf))) 144 | return 145 | } 146 | c.JSON(http.StatusOK, channels) 147 | } 148 | } 149 | 150 | func xmlTV(lineup *lineup) gin.HandlerFunc { 151 | epg := &xmltv.TV{ 152 | GeneratorInfoName: namespaceWithVersion, 153 | GeneratorInfoURL: "https://github.com/tellytv/telly", 154 | } 155 | 156 | for _, channel := range lineup.channels { 157 | if channel.providerChannel.EPGChannel != nil { 158 | epg.Channels = append(epg.Channels, *channel.providerChannel.EPGChannel) 159 | epg.Programmes = append(epg.Programmes, channel.providerChannel.EPGProgrammes...) 160 | } 161 | } 162 | 163 | sort.Slice(epg.Channels, func(i, j int) bool { return epg.Channels[i].LCN < epg.Channels[j].LCN }) 164 | 165 | return func(c *gin.Context) { 166 | buf, marshallErr := xml.MarshalIndent(epg, "", "\t") 167 | if marshallErr != nil { 168 | c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error marshalling EPG to XML")) 169 | } 170 | c.Data(http.StatusOK, "application/xml", []byte(xml.Header+``+"\n"+string(buf))) 171 | } 172 | } 173 | 174 | func stream(lineup *lineup) gin.HandlerFunc { 175 | return func(c *gin.Context) { 176 | channelIDStr := c.Param("channelID")[1:] 177 | channelID, channelIDErr := strconv.Atoi(channelIDStr) 178 | if channelIDErr != nil { 179 | c.AbortWithError(http.StatusBadRequest, fmt.Errorf("that (%s) doesn't appear to be a valid channel number", channelIDStr)) 180 | return 181 | } 182 | 183 | if channel, ok := lineup.channels[channelID]; ok { 184 | channelURI := channel.providerChannel.Track.URI 185 | 186 | log.Infof("Serving channel number %d", channelID) 187 | 188 | if !lineup.FfmpegEnabled { 189 | log.Debugf("Redirecting caller to %s", channelURI) 190 | c.Redirect(http.StatusMovedPermanently, channelURI.String()) 191 | return 192 | } 193 | 194 | log.Infoln("Remuxing stream with ffmpeg") 195 | run := exec.Command("ffmpeg", "-i", "pipe:0", "-c:v", "copy", "-f", "mpegts", "pipe:1") 196 | log.Debugf("Executing ffmpeg as \"%s\"", strings.Join(run.Args, " ")) 197 | ffmpegout, err := run.StdoutPipe() 198 | if err != nil { 199 | log.WithError(err).Errorln("StdoutPipe Error") 200 | return 201 | } 202 | 203 | stderr, stderrErr := run.StderrPipe() 204 | if stderrErr != nil { 205 | log.WithError(stderrErr).Errorln("Error creating ffmpeg stderr pipe") 206 | } 207 | 208 | if startErr := run.Start(); startErr != nil { 209 | log.WithError(startErr).Errorln("Error starting ffmpeg") 210 | return 211 | } 212 | defer run.Wait() 213 | 214 | go func() { 215 | scanner := bufio.NewScanner(stderr) 216 | scanner.Split(split) 217 | for scanner.Scan() { 218 | log.Println(scanner.Text()) 219 | } 220 | }() 221 | 222 | continueStream := true 223 | c.Header("Content-Type", `video/mpeg; codecs="avc1.4D401E"`) 224 | 225 | c.Stream(func(w io.Writer) bool { 226 | defer func() { 227 | log.Infoln("Stopped streaming", channelID) 228 | if killErr := run.Process.Kill(); killErr != nil { 229 | panic(killErr) 230 | } 231 | continueStream = false 232 | return 233 | }() 234 | if _, copyErr := io.Copy(w, ffmpegout); copyErr != nil { 235 | log.WithError(copyErr).Errorln("Error when copying data") 236 | continueStream = false 237 | return false 238 | } 239 | return continueStream 240 | }) 241 | 242 | return 243 | } 244 | 245 | c.AbortWithError(http.StatusNotFound, fmt.Errorf("unknown channel number %d", channelID)) 246 | } 247 | } 248 | 249 | func ginrus() gin.HandlerFunc { 250 | return func(c *gin.Context) { 251 | start := time.Now() 252 | // some evil middlewares modify this values 253 | path := c.Request.URL.Path 254 | c.Next() 255 | 256 | end := time.Now() 257 | latency := end.Sub(start) 258 | end = end.UTC() 259 | 260 | logFields := logrus.Fields{ 261 | "status": c.Writer.Status(), 262 | "method": c.Request.Method, 263 | "path": path, 264 | "ipAddress": c.ClientIP(), 265 | "latency": latency, 266 | "userAgent": c.Request.UserAgent(), 267 | "time": end.Format(time.RFC3339), 268 | } 269 | 270 | entry := log.WithFields(logFields) 271 | 272 | if len(c.Errors) > 0 { 273 | // Append error field if this is an erroneous request. 274 | entry.Error(c.Errors.String()) 275 | } else { 276 | entry.Info() 277 | } 278 | } 279 | } 280 | 281 | func setupSSDP(baseAddress, deviceName, deviceUUID string) (*ssdp.Advertiser, error) { 282 | log.Debugf("Advertising telly as %s (%s)", deviceName, deviceUUID) 283 | 284 | adv, err := ssdp.Advertise( 285 | "upnp:rootdevice", 286 | fmt.Sprintf("uuid:%s::upnp:rootdevice", deviceUUID), 287 | fmt.Sprintf("http://%s/device.xml", baseAddress), 288 | deviceName, 289 | 1800) 290 | 291 | if err != nil { 292 | return nil, err 293 | } 294 | 295 | go func(advertiser *ssdp.Advertiser) { 296 | aliveTick := time.Tick(15 * time.Second) 297 | 298 | for { 299 | <-aliveTick 300 | if err := advertiser.Alive(); err != nil { 301 | log.WithError(err).Panicln("error when sending ssdp heartbeat") 302 | } 303 | } 304 | }(adv) 305 | 306 | return adv, nil 307 | } 308 | 309 | func split(data []byte, atEOF bool) (advance int, token []byte, spliterror error) { 310 | if atEOF && len(data) == 0 { 311 | return 0, nil, nil 312 | } 313 | if i := bytes.IndexByte(data, '\n'); i >= 0 { 314 | // We have a full newline-terminated line. 315 | return i + 1, data[0:i], nil 316 | } 317 | if i := bytes.IndexByte(data, '\r'); i >= 0 { 318 | // We have a cr terminated line 319 | return i + 1, data[0:i], nil 320 | } 321 | if atEOF { 322 | return len(data), data, nil 323 | } 324 | 325 | return 0, nil, nil 326 | } 327 | -------------------------------------------------------------------------------- /structs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | ) 8 | 9 | // DiscoveryData contains data about telly to expose in the HDHomeRun format for Plex detection. 10 | type DiscoveryData struct { 11 | FriendlyName string 12 | Manufacturer string 13 | ModelNumber string 14 | FirmwareName string 15 | TunerCount int 16 | FirmwareVersion string 17 | DeviceID string 18 | DeviceAuth string 19 | BaseURL string 20 | LineupURL string 21 | } 22 | 23 | // UPNP returns the UPNP representation of the DiscoveryData. 24 | func (d *DiscoveryData) UPNP() UPNP { 25 | return UPNP{ 26 | SpecVersion: upnpVersion{ 27 | Major: 1, Minor: 0, 28 | }, 29 | URLBase: d.BaseURL, 30 | Device: upnpDevice{ 31 | DeviceType: "urn:schemas-upnp-org:device:MediaServer:1", 32 | FriendlyName: d.FriendlyName, 33 | Manufacturer: d.Manufacturer, 34 | ModelName: d.ModelNumber, 35 | ModelNumber: d.ModelNumber, 36 | UDN: fmt.Sprintf("uuid:%s", d.DeviceID), 37 | }, 38 | } 39 | } 40 | 41 | // LineupStatus exposes the status of the channel lineup. 42 | type LineupStatus struct { 43 | ScanInProgress convertibleBoolean 44 | ScanPossible convertibleBoolean `json:",omitempty"` 45 | Source string `json:",omitempty"` 46 | SourceList []string `json:",omitempty"` 47 | Progress int `json:",omitempty"` // Percent complete 48 | Found int `json:",omitempty"` // Number of found channels 49 | } 50 | 51 | type upnpVersion struct { 52 | Major int32 `xml:"major"` 53 | Minor int32 `xml:"minor"` 54 | } 55 | 56 | type upnpDevice struct { 57 | DeviceType string `xml:"deviceType"` 58 | FriendlyName string `xml:"friendlyName"` 59 | Manufacturer string `xml:"manufacturer"` 60 | ModelName string `xml:"modelName"` 61 | ModelNumber string `xml:"modelNumber"` 62 | SerialNumber string `xml:"serialNumber"` 63 | UDN string `xml:"UDN"` 64 | } 65 | 66 | // UPNP describes the UPNP/SSDP XML. 67 | type UPNP struct { 68 | XMLName xml.Name `xml:"urn:schemas-upnp-org:device-1-0 root"` 69 | SpecVersion upnpVersion `xml:"specVersion"` 70 | URLBase string `xml:"URLBase"` 71 | Device upnpDevice `xml:"device"` 72 | } 73 | 74 | type convertibleBoolean bool 75 | 76 | func (bit *convertibleBoolean) MarshalJSON() ([]byte, error) { 77 | var bitSetVar int8 78 | if *bit { 79 | bitSetVar = 1 80 | } 81 | 82 | return json.Marshal(bitSetVar) 83 | } 84 | 85 | func (bit *convertibleBoolean) UnmarshalJSON(data []byte) error { 86 | asString := string(data) 87 | if asString == "1" || asString == "true" { 88 | *bit = true 89 | } else if asString == "0" || asString == "false" { 90 | *bit = false 91 | } else { 92 | return fmt.Errorf("Boolean unmarshal error: invalid input %s", asString) 93 | } 94 | return nil 95 | } 96 | 97 | // MarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 98 | func (bit *convertibleBoolean) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 99 | var bitSetVar int8 100 | if *bit { 101 | bitSetVar = 1 102 | } 103 | 104 | return e.EncodeElement(bitSetVar, start) 105 | } 106 | 107 | // UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 108 | func (bit *convertibleBoolean) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { 109 | var asString string 110 | if decodeErr := d.DecodeElement(&asString, &start); decodeErr != nil { 111 | return decodeErr 112 | } 113 | if asString == "1" || asString == "true" { 114 | *bit = true 115 | } else if asString == "0" || asString == "false" { 116 | *bit = false 117 | } else { 118 | return fmt.Errorf("Boolean unmarshal error: invalid input %s", asString) 119 | } 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | func getTCPAddr(key string) *net.TCPAddr { 11 | addr, addrErr := net.ResolveTCPAddr("tcp", viper.GetString(key)) 12 | if addrErr != nil { 13 | panic(fmt.Errorf("error parsing address %s: %s", viper.GetString(key), addrErr)) 14 | } 15 | return addr 16 | } 17 | 18 | func getDiscoveryData() DiscoveryData { 19 | return DiscoveryData{ 20 | FriendlyName: viper.GetString("discovery.device-friendly-name"), 21 | Manufacturer: viper.GetString("discovery.device-manufacturer"), 22 | ModelNumber: viper.GetString("discovery.device-model-number"), 23 | FirmwareName: viper.GetString("discovery.device-firmware-name"), 24 | TunerCount: viper.GetInt("iptv.streams"), 25 | FirmwareVersion: viper.GetString("discovery.device-firmware-version"), 26 | DeviceID: viper.GetString("discovery.device-id"), 27 | DeviceAuth: viper.GetString("discovery.device-auth"), 28 | BaseURL: fmt.Sprintf("http://%s", viper.GetString("web.base-address")), 29 | LineupURL: fmt.Sprintf("http://%s/lineup.json", viper.GetString("web.base-address")), 30 | } 31 | } 32 | --------------------------------------------------------------------------------