├── .gitattributes
├── internal
├── appletv
│ ├── templates
│ │ ├── channel.xml
│ │ ├── error.xml
│ │ ├── logs.xml
│ │ ├── base.xml
│ │ ├── search.xml
│ │ ├── player.xml
│ │ ├── reload-channels.xml
│ │ ├── main.xml
│ │ ├── category.xml
│ │ ├── favorites.xml
│ │ ├── bag.plist
│ │ ├── recent.xml
│ │ ├── search-results.xml
│ │ ├── channel-options.xml
│ │ ├── locales
│ │ │ └── en-US.json
│ │ ├── channels.xml
│ │ └── settings.xml
│ ├── xml.go
│ └── appletv.go
├── server
│ ├── assets
│ │ ├── images
│ │ │ ├── settings.png
│ │ │ ├── missing_logo.png
│ │ │ ├── no_favorites.png
│ │ │ └── no_recents.png
│ │ ├── custom.js
│ │ └── application.js
│ └── server.go
├── m3u
│ ├── logo.go
│ ├── m3u.go
│ └── models.go
├── logging
│ └── logging.go
└── config
│ └── config.go
├── sample
├── certs
│ ├── redbulltv.cer
│ ├── redbulltv.key
│ └── redbulltv.pem
├── config.yaml
└── sample.m3u
├── go.mod
├── .vscode
└── launch.json
├── .editorconfig
├── .github
└── workflows
│ ├── build.yaml
│ └── publish.yaml
├── main.go
├── go.sum
├── LICENSE
├── Makefile
├── .gitignore
└── README.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
--------------------------------------------------------------------------------
/internal/appletv/templates/channel.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/sample/certs/redbulltv.cer:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ghokun/appletv3-iptv/HEAD/sample/certs/redbulltv.cer
--------------------------------------------------------------------------------
/internal/server/assets/images/settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ghokun/appletv3-iptv/HEAD/internal/server/assets/images/settings.png
--------------------------------------------------------------------------------
/internal/server/assets/images/missing_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ghokun/appletv3-iptv/HEAD/internal/server/assets/images/missing_logo.png
--------------------------------------------------------------------------------
/internal/server/assets/images/no_favorites.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ghokun/appletv3-iptv/HEAD/internal/server/assets/images/no_favorites.png
--------------------------------------------------------------------------------
/internal/server/assets/images/no_recents.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ghokun/appletv3-iptv/HEAD/internal/server/assets/images/no_recents.png
--------------------------------------------------------------------------------
/internal/appletv/templates/error.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
6 | {{- end }}
--------------------------------------------------------------------------------
/internal/appletv/templates/logs.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 | {{ index .Translations "settings.menu.trouble.logs.title" }}
4 |
5 |
6 | {{- end }}
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ghokun/appletv3-iptv
2 |
3 | go 1.16
4 |
5 | require (
6 | github.com/google/uuid v1.2.0
7 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
8 | golang.org/x/text v0.3.5
9 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
10 | )
11 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Debug appletv3-iptv",
6 | "type": "go",
7 | "request": "launch",
8 | "mode": "debug",
9 | "program": "${workspaceFolder}/main.go"
10 | }
11 | ]
12 | }
--------------------------------------------------------------------------------
/sample/config.yaml:
--------------------------------------------------------------------------------
1 | m3uPath: ../sample/sample.m3u
2 | httpPort: "80"
3 | httpsPort: "443"
4 | cerPath: ../sample/certs/redbulltv.cer
5 | pemPath: ../sample/certs/redbulltv.pem
6 | keyPath: ../sample/certs/redbulltv.key
7 | logToFile: true
8 | loggingPath: log
9 | recents: []
10 | favorites: []
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://EditorConfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | indent_style = space
8 | indent_size = 2
9 | trim_trailing_whitespace = true
10 |
11 | [*.go]
12 | indent_style = tab
13 | indent_size = 4
14 |
15 | [Makefile]
16 | indent_style = tab
17 | indent_size = 8
--------------------------------------------------------------------------------
/internal/appletv/templates/base.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{ template "body" . }}
9 |
10 |
--------------------------------------------------------------------------------
/internal/appletv/templates/search.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 |
4 |
5 | {{ index .Translations "search.title" }}
6 |
7 |
8 | {{ .BasePath }}/search-results.xml?term=
9 |
10 | {{- end }}
--------------------------------------------------------------------------------
/sample/sample.m3u:
--------------------------------------------------------------------------------
1 | #EXTM3U
2 | #EXTINF:-1 tvg-id="1" tvg-logo="" tvg-url="" group-title="News",Channel 1
3 | https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8
4 | #EXTINF:-2 tvg-id="2" tvg-logo="" tvg-url="" group-title="Kids",Channel 2
5 | https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8
6 | #EXTINF:-3 tvg-id="3" tvg-logo="" tvg-url="" group-title="Documentary",Channel 3
7 | https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8
--------------------------------------------------------------------------------
/internal/appletv/templates/player.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 |
6 | {{ .Data.MediaURL }}
7 | {{ .Data.Title }}
8 | {{ .Data.Description }}
9 |
12 |
13 |
14 | {{- end }}
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | push:
5 | branches:
6 | - "*"
7 | pull_request:
8 | branches:
9 | - "*"
10 |
11 | jobs:
12 | build:
13 | strategy:
14 | matrix:
15 | go-version: [1.16.x]
16 | os: [ubuntu-latest]
17 | runs-on: ${{ matrix.os }}
18 | steps:
19 | - uses: actions/checkout@master
20 | with:
21 | fetch-depth: 1
22 | - name: Install Go
23 | uses: actions/setup-go@v2
24 | with:
25 | go-version: ${{ matrix.go-version }}
26 | - name: Make dev
27 | run: make dev
28 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yaml:
--------------------------------------------------------------------------------
1 | name: publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@master
13 | with:
14 | fetch-depth: 1
15 | - name: Install Go
16 | uses: actions/setup-go@v2
17 | with:
18 | go-version: "^1.16.0"
19 | - name: Make release
20 | run: make release
21 | - name: Upload release binaries
22 | uses: alexellis/upload-assets@0.3.0
23 | env:
24 | GITHUB_TOKEN: ${{ github.token }}
25 | with:
26 | asset_paths: '["./bin/*"]'
27 |
--------------------------------------------------------------------------------
/internal/appletv/templates/reload-channels.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
5 | {{ index .Translations "settings.menu.m3u.reload.title" }}
6 | {{ index .Translations "settings.menu.m3u.reload.footnote" }}
7 |
8 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
22 | {{- end }}
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 |
9 | "github.com/ghokun/appletv3-iptv/internal/config"
10 | "github.com/ghokun/appletv3-iptv/internal/logging"
11 | "github.com/ghokun/appletv3-iptv/internal/m3u"
12 | "github.com/ghokun/appletv3-iptv/internal/server"
13 | )
14 |
15 | func main() {
16 |
17 | configFilePtr := flag.String("config", "config.yaml", "Config file path")
18 | versionPtr := flag.Bool("v", false, "prints current application version")
19 | flag.Parse()
20 |
21 | if *versionPtr {
22 | fmt.Println(config.Version)
23 | os.Exit(0)
24 | }
25 |
26 | err := config.LoadConfig(*configFilePtr)
27 | if err != nil {
28 | log.Fatal(err)
29 | }
30 |
31 | if config.Current.LogToFile {
32 | logging.EnableLoggingToFile()
33 | }
34 |
35 | logging.Info("Starting appletv3-iptv")
36 |
37 | if config.Current.M3UPath != "" {
38 | err := m3u.GeneratePlaylist()
39 | if err != nil {
40 | logging.Warn(err)
41 | }
42 | }
43 |
44 | server.Serve()
45 | }
46 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
2 | github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
3 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
4 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
5 | golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
6 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
7 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
10 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
11 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
12 |
--------------------------------------------------------------------------------
/internal/appletv/templates/main.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
6 |
7 | {{ if gt .Data 0 -}}
8 |
9 | {{ index .Translations "main.channels" }}
10 | {{ .BasePath }}/channels.xml
11 |
12 |
13 | {{ index .Translations "main.search" }}
14 | {{ .BasePath }}/search.xml
15 |
16 | {{- end }}
17 |
18 | {{ index .Translations "main.settings" }}
19 | {{ .BasePath }}/settings.xml
20 |
21 |
22 |
23 | {{- end }}
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 ghokun
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 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | VERSION := $(shell git describe --tags --dirty)
2 | LDFLAGS := "-X github.com/ghokun/appletv3-iptv/internal/config.Version=$(VERSION)"
3 | SOURCE_DIRS := internal main.go
4 |
5 | .PHONY: check-fmt
6 | check-fmt:
7 | @test -z $(shell gofmt -l -s $(SOURCE_DIRS) ./ | tee /dev/stderr) || (echo "[WARN] Fix formatting issues with 'go fmt'" && exit 1)
8 |
9 | .PHONY: build
10 | build:
11 | rm -rf bin
12 | mkdir -p bin
13 | go build -o bin/appletv3-iptv
14 |
15 | .PHONY: copy-sample-config
16 | copy-sample-config:
17 | cp sample/config.yaml bin/config.yaml
18 |
19 | .PHONY: dev
20 | dev: check-fmt build copy-sample-config
21 |
22 | .PHONY: release
23 | release:
24 | mkdir -p bin
25 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags $(LDFLAGS) -a -o bin/appletv3-iptv
26 | CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags $(LDFLAGS) -a -o bin/appletv3-iptv-armhf
27 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags $(LDFLAGS) -a -o bin/appletv3-iptv-arm64
28 | CGO_ENABLED=0 GOOS=darwin go build -ldflags $(LDFLAGS) -a -o bin/appletv3-iptv-darwin
29 | CGO_ENABLED=0 GOOS=windows go build -ldflags $(LDFLAGS) -a -o bin/appletv3-iptv.exe
30 | cd bin && shasum -a 256 * > checksum
--------------------------------------------------------------------------------
/internal/appletv/templates/category.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 |
4 |
5 |
8 |
9 | {{ $count := 0 }} {{ range $key, $value := .Data.Channels }}
10 |
16 | {{ if $value.IsFavorite }}⭐ {{ end }}{{ $value.Title }}
17 |
20 | {{ $.BasePath }}/assets/images/missing_logo.png
21 |
22 | {{- end }}
23 |
24 |
25 |
26 |
27 |
28 | {{- end }}
--------------------------------------------------------------------------------
/internal/appletv/templates/favorites.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 |
4 |
5 |
8 |
9 | {{ range $value := .Data.GetFavoriteChannels }}
10 |
16 | {{ if $value.IsFavorite }}⭐ {{ end }}{{ $value.Title }}
17 | {{ $value.Category }}
18 |
21 | {{ $.BasePath }}/assets/images/missing_logo.png
22 |
23 | {{- end }}
24 |
25 |
26 |
27 |
28 |
29 | {{- end }}
--------------------------------------------------------------------------------
/internal/appletv/templates/bag.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | javascript-url
6 | https://appletv.redbull.tv/assets/application-d2c261b5c9aebdd31db887659ebde38deb7d8ffc9b212ea1c8caf4d4b7a35869.js
7 | root-url
8 | https://appletv.redbull.tv/
9 | auth-type
10 | js
11 | enabled
12 | YES
13 | menu-title
14 | Red Bull TV
15 | merchant
16 | com.redbulltv.appletv.prod
17 | top-shelf-url
18 | https://appletv.redbull.tv/
19 | menu-icon-url
20 |
21 | 720
22 | https://appletv.redbull.tv/assets/rbtv-tv-ios-720-f0b5a73651e6c4b039ab9cb8e5e7cb9922310773dfe309abad042d8a76dee1e6.png
23 | 1080
24 | https://appletv.redbull.tv/assets/rbtv-tv-ios-1080-7793faf899da090489e5d4372db7916f43792388d23cbb2423eb6e03a350285e.png
25 |
26 | menu-icon-url-version
27 | 2.3
28 |
29 |
--------------------------------------------------------------------------------
/internal/appletv/templates/recent.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 |
4 |
5 |
8 |
9 | {{ range $value := .Data.GetRecentChannels }}
10 |
16 | {{ if $value.IsFavorite }}⭐ {{ end }}{{ $value.Title }}
17 | {{ $value.Category }}
18 |
21 | {{ $value.RecentOrdinal }}
22 | {{ $.BasePath }}/assets/images/missing_logo.png
23 |
24 | {{- end }}
25 |
26 |
27 |
28 |
29 |
30 | {{- end }}
--------------------------------------------------------------------------------
/internal/appletv/templates/search-results.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 |
31 |
32 | {{- end }}
--------------------------------------------------------------------------------
/internal/m3u/logo.go:
--------------------------------------------------------------------------------
1 | package m3u
2 |
3 | import (
4 | "errors"
5 | "image"
6 | "image/jpeg"
7 | "net/http"
8 | "os"
9 |
10 | "github.com/nfnt/resize"
11 | )
12 |
13 | const (
14 | cacheFolder = ".cache/logo"
15 | missing = "/assets/images/missing_logo.png"
16 | )
17 |
18 | func missingResponse(err error) (string, error) {
19 | return missing, err
20 | }
21 |
22 | func fileExists(filename string) bool {
23 | fileInfo, err := os.Stat(filename)
24 | if os.IsNotExist(err) {
25 | return false
26 | }
27 | return !fileInfo.IsDir()
28 | }
29 |
30 | func computeChannelLogo(id string, rawLogo string) (logo string, err error) {
31 |
32 | err = os.MkdirAll(cacheFolder, os.ModePerm)
33 | if err != nil {
34 | return missingResponse(err)
35 | }
36 |
37 | if rawLogo == "" {
38 | return missingResponse(nil)
39 | }
40 |
41 | logoFilename := cacheFolder + "/" + id + ".png"
42 | logo = "/logo/" + id + ".png"
43 |
44 | if fileExists(logoFilename) {
45 | return logo, nil
46 | }
47 |
48 | response, err := http.Get(rawLogo)
49 | if err != nil {
50 | return missingResponse(err)
51 | }
52 |
53 | defer response.Body.Close()
54 |
55 | if response.StatusCode >= 200 && response.StatusCode < 300 {
56 | image, _, err := image.Decode(response.Body)
57 | if err != nil {
58 | return missingResponse(err)
59 | }
60 | resizedImage := resize.Resize(320, 180, image, resize.Lanczos3)
61 |
62 | file, err := os.Create(logoFilename)
63 | if err != nil {
64 | return missingResponse(err)
65 | }
66 | err = jpeg.Encode(file, resizedImage, nil)
67 | if err != nil {
68 | return missingResponse(err)
69 | }
70 | return logo, nil
71 | }
72 | return missingResponse(errors.New("Error while fetching channel logo. Status code: " + response.Status))
73 | }
74 |
--------------------------------------------------------------------------------
/sample/certs/redbulltv.key:
--------------------------------------------------------------------------------
1 | -----BEGIN PRIVATE KEY-----
2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDKLSU26K9XvTgm
3 | qsX4HhEAXNI/ss7nneKh3R4lujBNq5XuqgNbO/fQJmO+YKDC7J+hFSHUJNBFJgrz
4 | LpIcmm7GQ2LhQfUfxdHSPprYTBqrDzPgP+meFtHrpLkFCPmwFRjmwihvo0kdm0xo
5 | yZrEU+fv7tS5sDBt6MBAkl2iGW71Po/IhDWYueuzZmAHjhUqDYtA+xNToJzjEOva
6 | GMvUOEVLdgwbqr90JqZZLi+SI2GCtQbquwS+qb8Zrmj3yc5fusuGzRPa107rBA4D
7 | Vihnbzu4IDR3WBDvvXmGzvYfLPqsN5egZklheZvqL0J3Fxf/KNDU3MSZN2Qv8lLz
8 | xtqya9C5AgMBAAECggEAJVSiq3nZboT0ykb8GO1MTFnXRIW6qI/BmgufFm5DnwPQ
9 | wmnIBt+SyW9dOXjUFknky7SAM5C8mBgHK5HszrVBQQCOUHOCVGSNcpm2s7uRrQY4
10 | mO6UL2mdRzp6I1Dd8cJjf7BYEQ0AYiQbvrmDBz9K80WRJ9w9hP3WCdY8zcKOd1/K
11 | gtyPX9IqfeEGUILgg3QExvu0RvHhZsmMjO9grRxk2Zy0III5OUq+WojGULE4JBHp
12 | 8pvLPOdyoLI5ZLsOFcpFx9pSB+hdiwKhhgTB342Wc2yeJrBvlVDOK7AFYrGgQzlv
13 | ALCGO9otKKCsKe8hQr+vFSPT3F3DtRToljvR3M0pRQKBgQD5jTTfvcJOe8G4Uv95
14 | iy7HXxf7mXNG7qwpvaSBVI+8bTSkOV3Kg3W5uTqq5+ucbz/vKzjhK6cfJoJpA+uv
15 | UFFIlgEmi7fuybb+QMOo+jxL0he9qyjs/uQCa47qzwnBlEWeTVdjne+xLHGCWFje
16 | AuPwty7cj9HCBUClnybh2nARZwKBgQDPZoy+gJ9urL90ad/4ds/d8hTzPehcuzuq
17 | 5MECHbqk9lbyh/s37ZT8VlltJso7ONvX9DDCAzQNreQ30xwcNR/bnvHW9tq3k/rn
18 | gkvtEVqqqDdEvNr1MWLQ8UYoEa/dPZDS96KViPjltdqW6v21mShiA1fIguDnWQhA
19 | YLz+u50Y3wKBgQCbYYa4gUjI4Vm/UT5tCXJ5BQbDy8nxMo7T9pbFSEevBTgvwOBb
20 | Rfs5RtH2tC0J3GMsofbqjOmkBbBRfvVy1UmnLm9M9tXxwntEWEL7pcOBWjEaEcaL
21 | ujFyKFJ2da8XbyDh7jopdp9V69xJUoUSxy3yJbzx7EKo0ehst2nYWtBIpQKBgHt7
22 | F/LYC5ROP5Lk8lcxDeObnQORaUXEp+rAVXWYE6bhj7TIZzbOOfTeyFFnVeJaoPF7
23 | TohEdfpq/MSL6WGV84jDokMVJ/VCopCxj9juiyeuDXHcaxSuuaGi9N0oYqd7Xz1r
24 | +J3FNkM1uZY/BJzZOiTYzqvv2E2FQZdqwTt8ojTLAoGAR/pFxUPfIhl3kX1/fHIK
25 | ROykymcsgm2Y2G8g0HVapkgTEnbx6SDWKwsQFwv6LOzBVd6qWWmlXylemvRiQEPy
26 | NUEFBBbMWFt2InQ0vVXEtC4Y9eQ7xEab7PKbY/F5ndqLdqmWz+I/bGXlSkFV1D91
27 | k2Fh/5Y8YO5eoZjx1xK3Ews=
28 | -----END PRIVATE KEY-----
29 |
--------------------------------------------------------------------------------
/internal/logging/logging.go:
--------------------------------------------------------------------------------
1 | package logging
2 |
3 | import (
4 | "io"
5 | "log"
6 | "os"
7 | "path"
8 | "time"
9 |
10 | "github.com/ghokun/appletv3-iptv/internal/config"
11 | )
12 |
13 | var (
14 | latestLogFilePath string
15 | logFile *os.File
16 | Logger *log.Logger
17 | )
18 |
19 | func init() {
20 | Logger = log.New(os.Stdout, "", log.LstdFlags|log.Lshortfile)
21 | }
22 |
23 | func EnableLoggingToFile() {
24 | t := time.Now().Format("2006-01-02")
25 | latestLogFilePath = path.Join(config.Current.LoggingPath, t+".log")
26 | err := os.Mkdir(config.Current.LoggingPath, os.ModePerm)
27 | logFile, err := os.OpenFile(latestLogFilePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0664)
28 | if err != nil {
29 | log.Fatal(err)
30 | }
31 | mw := io.MultiWriter(os.Stdout, logFile)
32 | Logger.SetOutput(mw)
33 | }
34 |
35 | func CheckLogRotationAndRotate() {
36 | if !config.Current.LogToFile {
37 | return
38 | }
39 | t := time.Now().Format("2006-01-02")
40 | newLogFilePath := path.Join(config.Current.LoggingPath, t+".log")
41 |
42 | if latestLogFilePath != newLogFilePath {
43 | Logger.Println("Rotating log file")
44 | logFile.Close()
45 | err := os.Mkdir(config.Current.LoggingPath, os.ModePerm)
46 | logFile, err := os.OpenFile(newLogFilePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0664)
47 | if err != nil {
48 | panic(err)
49 | }
50 | mw := io.MultiWriter(os.Stdout, logFile)
51 | Logger.SetOutput(mw)
52 | Logger.Println("Rotated log file")
53 | }
54 | }
55 |
56 | func logInternal(prefix string, data interface{}) {
57 | Logger.SetPrefix(prefix)
58 | Logger.Println(data)
59 | }
60 |
61 | func Info(data interface{}) {
62 | logInternal("INFO: ", data)
63 | }
64 |
65 | func Warn(data interface{}) {
66 | logInternal("WARN: ", data)
67 | }
68 |
69 | func Fatal(data interface{}) {
70 | logInternal("FATAL: ", data)
71 | logFile.Close()
72 | os.Exit(1)
73 | }
74 |
--------------------------------------------------------------------------------
/internal/appletv/templates/channel-options.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
5 | {{ .Data.Title }}
6 | {{ index .Translations "channel.options.footnote" }}
7 |
8 |
12 |
13 |
14 |
20 | {{- if .Data.IsFavorite -}}
21 |
25 |
26 |
27 | {{- else -}}
28 |
32 |
33 |
34 | {{- end -}}
35 |
36 |
37 | {{- end }}
--------------------------------------------------------------------------------
/internal/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "embed"
5 | "net/http"
6 |
7 | "github.com/ghokun/appletv3-iptv/internal/appletv"
8 | "github.com/ghokun/appletv3-iptv/internal/config"
9 | "github.com/ghokun/appletv3-iptv/internal/logging"
10 | )
11 |
12 | //go:embed assets/*
13 | var assets embed.FS
14 |
15 | func serveHTTP(mux *http.ServeMux, errs chan<- error) {
16 | port := ":" + config.Current.HTTPPort
17 | errs <- http.ListenAndServe(port, mux)
18 | }
19 |
20 | func serveHTTPS(mux *http.ServeMux, errs chan<- error) {
21 | port := ":" + config.Current.HTTPSPort
22 | errs <- http.ListenAndServeTLS(port, config.Current.PemPath, config.Current.KeyPath, mux)
23 | }
24 |
25 | func Serve() {
26 | // Serve both http and https
27 | mux := http.NewServeMux()
28 |
29 | // Serve static files
30 | mux.Handle("/assets/", http.FileServer(http.FS(assets)))
31 | mux.Handle("/logo/", http.StripPrefix("/logo/", http.FileServer(http.Dir(".cache/logo"))))
32 | mux.HandleFunc("/redbulltv.cer", func(w http.ResponseWriter, r *http.Request) {
33 | http.ServeFile(w, r, config.Current.CerPath)
34 | })
35 |
36 | // Serve apple tv pages and functions
37 | mux.HandleFunc("/", appletv.MainHandler)
38 |
39 | // Channels
40 | mux.HandleFunc("/channels.xml", appletv.ChannelsHandler)
41 | mux.HandleFunc("/channel-options.xml", appletv.ChannelOptionsHandler)
42 | mux.HandleFunc("/recent.xml", appletv.RecentHandler)
43 | mux.HandleFunc("/favorites.xml", appletv.FavoritesHandler)
44 | mux.HandleFunc("/toggle-favorite.xml", appletv.ToggleFavoriteHandler)
45 | mux.HandleFunc("/category.xml", appletv.CategoryHandler)
46 | mux.HandleFunc("/player.xml", appletv.PlayerHandler)
47 |
48 | // Search
49 | mux.HandleFunc("/search.xml", appletv.SearchHandler)
50 | mux.HandleFunc("/search-results.xml", appletv.SearchResultsHandler)
51 |
52 | // Settings
53 | mux.HandleFunc("/settings.xml", appletv.SettingsHandler)
54 | mux.HandleFunc("/set-m3u.xml", appletv.SetM3UHandler)
55 | mux.HandleFunc("/reload-channels.xml", appletv.ReloadChannelsHandler)
56 | mux.HandleFunc("/clear-recent.xml", appletv.ClearRecentHandler)
57 | mux.HandleFunc("/clear-favorites.xml", appletv.ClearFavoritesHandler)
58 | mux.HandleFunc("/logs.xml", appletv.LogsHandler)
59 |
60 | httpErrs := make(chan error, 1)
61 | go serveHTTP(mux, httpErrs)
62 | go serveHTTPS(mux, httpErrs)
63 | logging.Fatal(<-httpErrs)
64 | }
65 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "io/ioutil"
5 |
6 | "gopkg.in/yaml.v3"
7 | )
8 |
9 | // Config is the struct for configuration.
10 | type Config struct {
11 | M3UPath string `yaml:"m3uPath"`
12 | HTTPPort string `yaml:"httpPort"`
13 | HTTPSPort string `yaml:"httpsPort"`
14 | CerPath string `yaml:"cerPath"`
15 | PemPath string `yaml:"pemPath"`
16 | KeyPath string `yaml:"keyPath"`
17 | LogToFile bool `yaml:"logToFile"`
18 | LoggingPath string `yaml:"loggingPath"`
19 | Recents []string `yaml:"recents,flow"`
20 | Favorites []string `yaml:"favorites,flow"`
21 | }
22 |
23 | var (
24 | // Current - Global configuration variable.
25 | Current *Config
26 | currentConfigFile *string
27 | // Version - Set by ldflags
28 | Version string
29 | )
30 |
31 | // LoadConfig - Loads configuration file.
32 | func LoadConfig(configFile string) (err error) {
33 | contents, err := ioutil.ReadFile(configFile)
34 | if err != nil {
35 | return err
36 | }
37 | err = yaml.Unmarshal(contents, &Current)
38 | if err != nil {
39 | return err
40 | }
41 | currentConfigFile = &configFile
42 | return nil
43 | }
44 |
45 | func saveConfig(config *Config) (err error) {
46 | contents, err := yaml.Marshal(&config)
47 | if err != nil {
48 | return err
49 | }
50 | err = ioutil.WriteFile(*currentConfigFile, contents, 0644)
51 | if err != nil {
52 | return err
53 | }
54 | return nil
55 | }
56 |
57 | // SaveM3UPath - Edits M3U path and saves to configuration file.
58 | func (config *Config) SaveM3UPath(newM3UPath string) (err error) {
59 | config.M3UPath = newM3UPath
60 | return saveConfig(config)
61 | }
62 |
63 | // SaveRecents - Save recent channels to file, in order to preserve between restarts.
64 | func (config *Config) SaveRecents(newRecents []string) (err error) {
65 | config.Recents = newRecents
66 | return saveConfig(config)
67 | }
68 |
69 | // ClearRecents -
70 | func (config *Config) ClearRecents() (err error) {
71 | config.Recents = make([]string, 0)
72 | return saveConfig(config)
73 | }
74 |
75 | // SaveFavorites - Save favorite channels to file, in order to preserve between restarts.
76 | func (config *Config) SaveFavorites(newFavorites []string) (err error) {
77 | config.Favorites = newFavorites
78 | return saveConfig(config)
79 | }
80 |
81 | // ClearFavorites -
82 | func (config *Config) ClearFavorites() (err error) {
83 | config.Favorites = make([]string, 0)
84 | return saveConfig(config)
85 | }
86 |
--------------------------------------------------------------------------------
/internal/appletv/templates/locales/en-US.json:
--------------------------------------------------------------------------------
1 | {
2 | "channel.options.add-to-fav": "Add channel to favorites",
3 | "channel.options.detail": "Channel Details",
4 | "channel.options.footnote": "You can also watch channel by pressing Play button in previous page.",
5 | "channel.options.rm-from-fav": "Remove channel from favorites",
6 | "channel.options.watch": "Watch Channel",
7 | "channels.categories.title": "Categories",
8 | "channels.favorites.empty.description": "You can add any channel to your favorites in channel options menu.",
9 | "channels.favorites.empty.title": "No Favorite Channels",
10 | "channels.favorites.title": "Favorites",
11 | "channels.quick.title": "Quick Access",
12 | "channels.recent.empty.description": "You haven't visited any channels recently.",
13 | "channels.recent.empty.title": "No Recent Channels",
14 | "channels.recent.title": "Recently Watched",
15 | "channels.title": "Channels",
16 | "main.channels": "Channels",
17 | "main.search": "Search",
18 | "main.settings": "Settings",
19 | "search.title": "Search For Channels",
20 | "settings.legal": "The software is FREE and provided as is. Use at your own risk. I am poor, do not sue me if your Apple TV becomes a brick. If you have questions, open an issue at source code repository. Open a pull request if you want to contribute.",
21 | "settings.menu.m3u.clear-favorites": "Clear Favorites",
22 | "settings.menu.m3u.clear-recent": "Clear Recently Watched",
23 | "settings.menu.m3u.edit": "Edit M3U Address",
24 | "settings.menu.m3u.footnote": "This application does not provide any M3U links. You must provide your own file.",
25 | "settings.menu.m3u.instructions": "M3U Address can be a valid URL (starts with http:// or https://) or a file path. Both absolute and relative (relative to application binary) paths work.",
26 | "settings.menu.m3u.label": "M3U Address",
27 | "settings.menu.m3u.notset": "M3U Address is not set",
28 | "settings.menu.m3u.reload": "Reload Channel List",
29 | "settings.menu.m3u.reload.footnote": "You may lose some recent or favorite channels after reloading",
30 | "settings.menu.m3u.reload.no": "No, I changed my mind",
31 | "settings.menu.m3u.reload.title": "Reload Channel List from M3U path",
32 | "settings.menu.m3u.reload.yes": "Yes, reload please",
33 | "settings.menu.m3u.title": "Channel Settings",
34 | "settings.menu.trouble.logs": "Show Logs",
35 | "settings.menu.trouble.logs.title": "Logs",
36 | "settings.menu.trouble.title": "Troubleshooting",
37 | "settings.source": "Source",
38 | "settings.title": "Settings",
39 | "settings.version": "Version"
40 | }
--------------------------------------------------------------------------------
/sample/certs/redbulltv.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIC0DCCAbgCCQC9829VEdfmQzANBgkqhkiG9w0BAQsFADAqMQswCQYDVQQGEwJV
3 | UzEbMBkGA1UEAwwSYXBwbGV0di5yZWRidWxsLnR2MB4XDTIxMDIxMzE0MTYyN1oX
4 | DTQxMDIwODE0MTYyN1owKjELMAkGA1UEBhMCVVMxGzAZBgNVBAMMEmFwcGxldHYu
5 | cmVkYnVsbC50djCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMotJTbo
6 | r1e9OCaqxfgeEQBc0j+yzued4qHdHiW6ME2rle6qA1s799AmY75goMLsn6EVIdQk
7 | 0EUmCvMukhyabsZDYuFB9R/F0dI+mthMGqsPM+A/6Z4W0eukuQUI+bAVGObCKG+j
8 | SR2bTGjJmsRT5+/u1LmwMG3owECSXaIZbvU+j8iENZi567NmYAeOFSoNi0D7E1Og
9 | nOMQ69oYy9Q4RUt2DBuqv3QmplkuL5IjYYK1Buq7BL6pvxmuaPfJzl+6y4bNE9rX
10 | TusEDgNWKGdvO7ggNHdYEO+9eYbO9h8s+qw3l6BmSWF5m+ovQncXF/8o0NTcxJk3
11 | ZC/yUvPG2rJr0LkCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAmt7YajmfLTbjLlf6
12 | P4bGzPInxhzjKvKiv8VdieY+e6BvKbWPBmnr2fzPp2EHaQ/5g3HJDFbsclPD+T8r
13 | t47o48AghgSosr2QElZOwEvIN4d3zTQDVomdCIikn8Ba8ftlNmHwm/CnfLRR7LvC
14 | OJ9ecXRxRBwTZAGfPLQ02nrHORoibudakstzdWRReUVoFd7uFxbQA+oomq51Ouf5
15 | YQR8/J2s7Kvs7o0hvwCrcLhYvZwgw+TGIsO87IM1FDUZf12r6rfkxSivd0J3u3yY
16 | CADEgBBv2JSc06PNSpOBlbqEGO8s1R7whgD8ukgU7urPPCLz6JrcJGgNk66OOsbl
17 | q+feDw==
18 | -----END CERTIFICATE-----
19 | -----BEGIN PRIVATE KEY-----
20 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDKLSU26K9XvTgm
21 | qsX4HhEAXNI/ss7nneKh3R4lujBNq5XuqgNbO/fQJmO+YKDC7J+hFSHUJNBFJgrz
22 | LpIcmm7GQ2LhQfUfxdHSPprYTBqrDzPgP+meFtHrpLkFCPmwFRjmwihvo0kdm0xo
23 | yZrEU+fv7tS5sDBt6MBAkl2iGW71Po/IhDWYueuzZmAHjhUqDYtA+xNToJzjEOva
24 | GMvUOEVLdgwbqr90JqZZLi+SI2GCtQbquwS+qb8Zrmj3yc5fusuGzRPa107rBA4D
25 | Vihnbzu4IDR3WBDvvXmGzvYfLPqsN5egZklheZvqL0J3Fxf/KNDU3MSZN2Qv8lLz
26 | xtqya9C5AgMBAAECggEAJVSiq3nZboT0ykb8GO1MTFnXRIW6qI/BmgufFm5DnwPQ
27 | wmnIBt+SyW9dOXjUFknky7SAM5C8mBgHK5HszrVBQQCOUHOCVGSNcpm2s7uRrQY4
28 | mO6UL2mdRzp6I1Dd8cJjf7BYEQ0AYiQbvrmDBz9K80WRJ9w9hP3WCdY8zcKOd1/K
29 | gtyPX9IqfeEGUILgg3QExvu0RvHhZsmMjO9grRxk2Zy0III5OUq+WojGULE4JBHp
30 | 8pvLPOdyoLI5ZLsOFcpFx9pSB+hdiwKhhgTB342Wc2yeJrBvlVDOK7AFYrGgQzlv
31 | ALCGO9otKKCsKe8hQr+vFSPT3F3DtRToljvR3M0pRQKBgQD5jTTfvcJOe8G4Uv95
32 | iy7HXxf7mXNG7qwpvaSBVI+8bTSkOV3Kg3W5uTqq5+ucbz/vKzjhK6cfJoJpA+uv
33 | UFFIlgEmi7fuybb+QMOo+jxL0he9qyjs/uQCa47qzwnBlEWeTVdjne+xLHGCWFje
34 | AuPwty7cj9HCBUClnybh2nARZwKBgQDPZoy+gJ9urL90ad/4ds/d8hTzPehcuzuq
35 | 5MECHbqk9lbyh/s37ZT8VlltJso7ONvX9DDCAzQNreQ30xwcNR/bnvHW9tq3k/rn
36 | gkvtEVqqqDdEvNr1MWLQ8UYoEa/dPZDS96KViPjltdqW6v21mShiA1fIguDnWQhA
37 | YLz+u50Y3wKBgQCbYYa4gUjI4Vm/UT5tCXJ5BQbDy8nxMo7T9pbFSEevBTgvwOBb
38 | Rfs5RtH2tC0J3GMsofbqjOmkBbBRfvVy1UmnLm9M9tXxwntEWEL7pcOBWjEaEcaL
39 | ujFyKFJ2da8XbyDh7jopdp9V69xJUoUSxy3yJbzx7EKo0ehst2nYWtBIpQKBgHt7
40 | F/LYC5ROP5Lk8lcxDeObnQORaUXEp+rAVXWYE6bhj7TIZzbOOfTeyFFnVeJaoPF7
41 | TohEdfpq/MSL6WGV84jDokMVJ/VCopCxj9juiyeuDXHcaxSuuaGi9N0oYqd7Xz1r
42 | +J3FNkM1uZY/BJzZOiTYzqvv2E2FQZdqwTt8ojTLAoGAR/pFxUPfIhl3kX1/fHIK
43 | ROykymcsgm2Y2G8g0HVapkgTEnbx6SDWKwsQFwv6LOzBVd6qWWmlXylemvRiQEPy
44 | NUEFBBbMWFt2InQ0vVXEtC4Y9eQ7xEab7PKbY/F5ndqLdqmWz+I/bGXlSkFV1D91
45 | k2Fh/5Y8YO5eoZjx1xK3Ews=
46 | -----END PRIVATE KEY-----
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .cache/*
2 | bin/
3 | log/
4 | # Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,go,vscode,visualstudiocode,git
5 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,go,vscode,visualstudiocode,git
6 |
7 | ### Git ###
8 | # Created by git for backups. To disable backups in Git:
9 | # $ git config --global mergetool.keepBackup false
10 | *.orig
11 |
12 | # Created by git when using merge tools for conflicts
13 | *.BACKUP.*
14 | *.BASE.*
15 | *.LOCAL.*
16 | *.REMOTE.*
17 | *_BACKUP_*.txt
18 | *_BASE_*.txt
19 | *_LOCAL_*.txt
20 | *_REMOTE_*.txt
21 |
22 | ### Go ###
23 | # Binaries for programs and plugins
24 | *.exe
25 | *.exe~
26 | *.dll
27 | *.so
28 | *.dylib
29 |
30 | # Test binary, built with `go test -c`
31 | *.test
32 |
33 | # Output of the go coverage tool, specifically when used with LiteIDE
34 | *.out
35 |
36 | # Dependency directories (remove the comment below to include it)
37 | # vendor/
38 |
39 | ### Go Patch ###
40 | /vendor/
41 | /Godeps/
42 |
43 | ### Linux ###
44 | *~
45 |
46 | # temporary files which can be created if a process still has a handle open of a deleted file
47 | .fuse_hidden*
48 |
49 | # KDE directory preferences
50 | .directory
51 |
52 | # Linux trash folder which might appear on any partition or disk
53 | .Trash-*
54 |
55 | # .nfs files are created when an open file is removed but is still being accessed
56 | .nfs*
57 |
58 | ### macOS ###
59 | # General
60 | .DS_Store
61 | .AppleDouble
62 | .LSOverride
63 |
64 | # Icon must end with two \r
65 | Icon
66 |
67 |
68 | # Thumbnails
69 | ._*
70 |
71 | # Files that might appear in the root of a volume
72 | .DocumentRevisions-V100
73 | .fseventsd
74 | .Spotlight-V100
75 | .TemporaryItems
76 | .Trashes
77 | .VolumeIcon.icns
78 | .com.apple.timemachine.donotpresent
79 |
80 | # Directories potentially created on remote AFP share
81 | .AppleDB
82 | .AppleDesktop
83 | Network Trash Folder
84 | Temporary Items
85 | .apdisk
86 |
87 | ### VisualStudioCode ###
88 | .vscode/*
89 | !.vscode/tasks.json
90 | !.vscode/launch.json
91 | *.code-workspace
92 |
93 | ### VisualStudioCode Patch ###
94 | # Ignore all local history of files
95 | .history
96 | .ionide
97 |
98 | ### vscode ###
99 | !.vscode/settings.json
100 | !.vscode/extensions.json
101 |
102 | ### Windows ###
103 | # Windows thumbnail cache files
104 | Thumbs.db
105 | Thumbs.db:encryptable
106 | ehthumbs.db
107 | ehthumbs_vista.db
108 |
109 | # Dump file
110 | *.stackdump
111 |
112 | # Folder config file
113 | [Dd]esktop.ini
114 |
115 | # Recycle Bin used on file shares
116 | $RECYCLE.BIN/
117 |
118 | # Windows Installer files
119 | *.cab
120 | *.msi
121 | *.msix
122 | *.msm
123 | *.msp
124 |
125 | # Windows shortcuts
126 | *.lnk
127 |
128 | # End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,go,vscode,visualstudiocode,git
129 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Apple TV 3 IPTV
2 | This is an IPTV application for Apple TV 3 devices. It replaces RedbullTV app.
3 |
4 | ## Installation
5 | 1. Create DNS record for `appletv.redbull.tv` in your network.
6 | > `appletv.redbull.tv` should point to ip address that `appletv3-iptv` runs.
7 | 2. Generate certificates for `appletv.redbull.tv`
8 | ```bash
9 | openssl req -new -nodes -newkey rsa:2048 -out redbulltv.pem -keyout redbulltv.key -x509 -days 7300 -subj "/C=US/CN=appletv.redbull.tv"
10 | openssl x509 -in redbulltv.pem -outform der -out redbulltv.cer && cat redbulltv.key >> redbulltv.pem
11 | ```
12 | 3. Download binary for your platform from [releases](https://github.com/ghokun/appletv3-iptv/releases).
13 | 4. Create a settings file and run
14 | ```yaml
15 | # See sample/config.yaml
16 | ---
17 | # You can leave m3u link empty and set it from settings in app
18 | m3uPath: ./sample/sample.m3u # or https://domain.com/sample.m3u
19 | httpPort: "80"
20 | httpsPort: "443"
21 | cerPath: ./sample/certs/redbulltv.cer
22 | pemPath: ./sample/certs/redbulltv.pem
23 | keyPath: ./sample/certs/redbulltv.key
24 | logToFile: true
25 | loggingPath: log
26 | recents: []
27 | favorites: []
28 | ```
29 | Run from command line:
30 | ```bash
31 | chmod +x appletv3-iptv
32 | ./appletv3-iptv -config config.yaml # May need administrative permissions ports are under 1024
33 | ```
34 |
35 | Run as a systemd service:
36 | ```
37 | [Unit]
38 | Description=appletv3-iptv
39 |
40 | [Service]
41 | User=root
42 | ExecStart=/opt/appletv3-iptv/appletv3-iptv -config /opt/appletv3-iptv/config.yaml
43 | Restart=always
44 |
45 | [Install]
46 | WantedBy=multi-user.target
47 | ```
48 | ```bash
49 | sudo systemctl daemon-reload
50 | sudo systemctl enable appletv3-iptv.service
51 | sudo systemctl start appletv3-iptv.service
52 | ```
53 |
54 | 5. Install profile on Apple TV
55 | ```
56 | 1. Open Apple TV
57 | 2. Go to Settings > General
58 | 3. Set Send Data to Apple to `No`.
59 | 4. Press `Play` button on Send Data to Apple
60 | 5. Add Profile > Ok
61 | 6. Enter URL: http://appletv.redbull.tv/redbulltv.cer
62 | ```
63 | 6. Open RedbullTV application
64 |
65 |
66 | ## Compability
67 | | Device | OS |
68 | | ----------- | :-------------: |
69 | | ATV3 A1469 | 7.6.2, 7.7, 7.9 |
70 |
71 | ## Credits
72 | Code parts or ideas are taken from following repositories:
73 | - https://github.com/iBaa/PlexConnect
74 | - https://github.com/wahlmanj/sample-aTV
75 | - https://github.com/jamesnetherton/m3u
76 |
77 | ## Screenshots
78 |
79 |
80 |
81 | ## Tasks
82 | - [ ] Cleanup javascript files
83 | - [ ] Inject application icon
84 | - [ ] EPG support
85 | - [ ] Include DNS server
86 | - [ ] Prevent Apple TV software update
87 | - [ ] Add screenshots
88 |
--------------------------------------------------------------------------------
/internal/appletv/templates/channels.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 |
4 |
5 | {{ index .Translations "channels.title" }}
6 |
7 |
8 |
79 |
80 | {{- end }}
--------------------------------------------------------------------------------
/internal/appletv/xml.go:
--------------------------------------------------------------------------------
1 | package appletv
2 |
3 | import (
4 | "embed"
5 | "encoding/json"
6 | "net/http"
7 | "text/template"
8 |
9 | "github.com/ghokun/appletv3-iptv/internal/config"
10 | "github.com/ghokun/appletv3-iptv/internal/logging"
11 | "github.com/ghokun/appletv3-iptv/internal/m3u"
12 | "golang.org/x/text/language"
13 | )
14 |
15 | const (
16 | basePath = "https://appletv.redbull.tv"
17 | baseXML = "templates/base.xml"
18 | errorXML = "templates/error.xml"
19 | )
20 |
21 | //go:embed templates
22 | var templates embed.FS
23 |
24 | var matcher = language.NewMatcher([]language.Tag{
25 | language.AmericanEnglish,
26 | language.Turkish,
27 | })
28 |
29 | // TemplateData struct is evaluated in all pages.
30 | type TemplateData struct {
31 | BasePath string
32 | BodyID string
33 | Data interface{}
34 | Translations map[string]string
35 | }
36 |
37 | // ErrorData struct is evaluated error pages.
38 | type ErrorData struct {
39 | Title string
40 | Description string
41 | }
42 |
43 | // SettingsData struct is evaluated in Setting pages.
44 | type SettingsData struct {
45 | Version string
46 | M3UPath string
47 | ReloadChannelsActive bool
48 | ChannelCount int
49 | RecentCount int
50 | FavoritesCount int
51 | LogsActive bool
52 | }
53 |
54 | // GenerateXML : Parses base XML with given template
55 | func GenerateXML(w http.ResponseWriter, r *http.Request, templateName string, data interface{}) {
56 | template, err := template.ParseFS(templates, baseXML, templateName)
57 | if err != nil {
58 | logging.Warn(err)
59 | GenerateErrorXML(w, r, ErrorData{
60 | Title: "Template Parse Error",
61 | Description: err.Error(),
62 | })
63 | return
64 | }
65 | accept := r.Header.Get("Accept-Language")
66 | tag, _ := language.MatchStrings(matcher, accept)
67 | file, err := templates.ReadFile("templates/locales/" + tag.String() + ".json")
68 | if err != nil {
69 | logging.Warn(err)
70 | GenerateErrorXML(w, r, ErrorData{
71 | Title: "Translation Load Error",
72 | Description: err.Error(),
73 | })
74 | return
75 | }
76 | var translations map[string]string
77 | if err := json.Unmarshal(file, &translations); err != nil {
78 | logging.Warn(err)
79 | GenerateErrorXML(w, r, ErrorData{
80 | Title: "Translation Decode Error",
81 | Description: err.Error(),
82 | })
83 | return
84 | }
85 | templateData := TemplateData{
86 | BasePath: basePath,
87 | BodyID: templateName,
88 | Data: data,
89 | Translations: translations,
90 | }
91 | w.Header().Set("Content-Type", "application/xml")
92 | err = template.Execute(w, templateData)
93 | if err != nil {
94 | logging.Warn(err)
95 | GenerateErrorXML(w, r, ErrorData{
96 | Title: "Template Execution Error",
97 | Description: err.Error(),
98 | })
99 | return
100 | }
101 | }
102 |
103 | // GenerateErrorXML : If this fails application should stop.
104 | func GenerateErrorXML(w http.ResponseWriter, r *http.Request, errorData ErrorData) {
105 | template, err := template.ParseFS(templates, errorXML)
106 | if err != nil {
107 | logging.Fatal(err)
108 | }
109 | templateData := TemplateData{
110 | BasePath: basePath,
111 | BodyID: errorXML,
112 | Data: errorData,
113 | Translations: nil,
114 | }
115 | w.Header().Set("Content-Type", "application/xml")
116 | err = template.Execute(w, templateData)
117 | if err != nil {
118 | logging.Fatal(err)
119 | }
120 | }
121 |
122 | // GetSettingsData provides data to Settings page.
123 | func GetSettingsData() SettingsData {
124 | return SettingsData{
125 | Version: config.Version,
126 | M3UPath: config.Current.M3UPath,
127 | ReloadChannelsActive: config.Current.M3UPath != "",
128 | ChannelCount: m3u.GetPlaylist().GetChannelsCount(),
129 | RecentCount: m3u.GetPlaylist().GetRecentChannelsCount(),
130 | FavoritesCount: m3u.GetPlaylist().GetFavoriteChannelsCount(),
131 | LogsActive: config.Current.LogToFile,
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/internal/appletv/templates/settings.xml:
--------------------------------------------------------------------------------
1 | {{ define "body" -}}
2 |
3 |
4 |
5 | {{ index .Translations "settings.title" }}
6 |
7 |
8 |
9 |
10 |
11 | {{ index .Translations "settings.legal" }}
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | {{ .BasePath }}/assets/images/settings.png
21 |
22 |
23 |
24 |
107 |
108 | {{- end }}
--------------------------------------------------------------------------------
/internal/server/assets/custom.js:
--------------------------------------------------------------------------------
1 | var navbarItemNumber = null;
2 |
3 | function updatePage(url) {
4 | if (navbarItemNumber == '0') // First navbar item is a special case
5 | {
6 | atv.loadAndSwapURL(url);
7 | } else {
8 | var req = new XMLHttpRequest();
9 | req.onreadystatechange = function () {
10 | if (req.readyState == 4) {
11 | var doc = req.responseXML;
12 | var navBar = doc.getElementById('templates/main.xml');
13 | var navKey = navBar.getElementByTagName('navigation');
14 | navKey.setAttribute('currentIndex', navbarItemNumber);
15 | atv.loadAndSwapXML(doc);
16 | }
17 | };
18 | }
19 | req.open('GET', url, false);
20 | req.send();
21 | };
22 |
23 | // Main navigation bar handler
24 | function handleNavbarNavigate(event) {
25 | // The navigation item ID is passed in through the event parameter.
26 | var navId = event.navigationItemId;
27 | var root = document.rootElement;
28 | var navitems = root.getElementsByTagName('navigationItem')
29 | for (var i = 0; i < navitems.length; i++) {
30 | if (navitems[i].getAttribute('id') == navId) {
31 | navbarItemNumber = i.toString();
32 | break;
33 | }
34 | }
35 | // Use the event.navigationItemId to retrieve the appropriate URL information this can
36 | // retrieved from the document navigation item.
37 | docUrl = document.getElementById(navId).getElementByTagName('url').textContent,
38 | // Request the XML document via URL and send any headers you need to here.
39 | ajax = new ATVUtils.Ajax({
40 | "url": docUrl,
41 | "success": function (xhr) {
42 | // After successfully retrieving the document you can manipulate the document
43 | // before sending it to the navigation bar.
44 | var doc = xhr.responseXML,
45 | title = doc.rootElement.getElementByTagName('title');
46 | // title.textContent = title.textContent +": Appended by Javascript";
47 | // Once the document is ready to load pass it to the event.success function
48 | event.success(doc);
49 | },
50 | "failure": function (status, xhr) {
51 | // If the document fails to load pass an error message to the event.failure button
52 | event.failure("Navigation failed to load.");
53 | }
54 | });
55 | event.onCancel = function () {
56 | // declare an onCancel handler to handle cleanup if the user presses the menu button before the page loads.
57 | }
58 | }
59 |
60 | function callUrlAndUnload(url, method) {
61 | ajax = new ATVUtils.Ajax({
62 | "url": url,
63 | "method": method,
64 | "success": function (xhr) {
65 | atv.unloadPage();
66 | },
67 | "failure": function (status, xhr) {
68 | atv.unloadPage();
69 | }
70 | });
71 | }
72 |
73 | function addSpinner(elem) {
74 | var elem_add = document.makeElementNamed("spinner");
75 | elem.getElementByTagName("accessories").appendChild(elem_add);
76 | }
77 |
78 | function removeSpinner(elem) {
79 | var elem_remove = elem.getElementByTagName("accessories").getElementByTagName("spinner");
80 | if (elem_remove) elem_remove.removeFromParent();
81 | }
82 |
83 | function callUrlAndUpdateElement(id, url, method) {
84 | element = document.getElementById(id);
85 | rightLabel = element.getElementByTagName("rightLabel");
86 | if (rightLabel.textContent == '0') {
87 | return;
88 | }
89 | addSpinner(element);
90 | ajax = new ATVUtils.Ajax({
91 | "url": url,
92 | "method": method,
93 | "success": function (xhr) {
94 | removeSpinner(element);
95 | var doc = xhr.responseXML;
96 | newElement = doc.getElementById(id);
97 | rightLabel.textContent = newElement.getElementByTagName("rightLabel").textContent;
98 | if (rightLabel.textContent == '0') {
99 | element.setAttribute("dimmed", "true");
100 | }
101 | },
102 | "failure": function (status, xhr) {
103 | removeSpinner(element);
104 | rightLabel.textContent = '⚠️';
105 | }
106 | });
107 | }
108 |
109 | function editM3UAddress(title, instructions, label, footnote, defaultValue) {
110 | var textEntry = new atv.TextEntry();
111 | textEntry.type = 'emailAddress';
112 | textEntry.title = title;
113 | textEntry.instructions = instructions;
114 | textEntry.label = label;
115 | textEntry.footnote = footnote;
116 | textEntry.defaultValue = defaultValue;
117 | textEntry.defaultToAppleID = false;
118 | // textEntry.image = 'http://sample-web-server/sample-xml/images/ZYXLogo.png'
119 | var label2 = document.getElementById("edit-m3u").getElementByTagName('label2');
120 | textEntry.onSubmit = function (value) {
121 | ajax = new ATVUtils.Ajax({
122 | "url": "https://appletv.redbull.tv/set-m3u.xml?m3u=" + value,
123 | "method": "POST",
124 | "success": function (xhr) {
125 | label2.textContent = value;
126 | },
127 | "failure": function (status, xhr) {
128 | label2.textContent = status;
129 | }
130 | });
131 | }
132 | textEntry.onCancel = function () {
133 | label2.textContent = defaultValue;
134 | }
135 | textEntry.show();
136 | }
--------------------------------------------------------------------------------
/internal/m3u/m3u.go:
--------------------------------------------------------------------------------
1 | package m3u
2 |
3 | import (
4 | "bufio"
5 | "encoding/hex"
6 | "errors"
7 | "io"
8 | "net/http"
9 | "os"
10 | "regexp"
11 | "strconv"
12 | "strings"
13 |
14 | "github.com/ghokun/appletv3-iptv/internal/config"
15 | "github.com/ghokun/appletv3-iptv/internal/logging"
16 | "github.com/google/uuid"
17 | )
18 |
19 | var (
20 | singleton *Playlist
21 | )
22 |
23 | // GeneratePlaylist takes an m3u playlist and creates Playlists.
24 | // Loads recent and favorite channels from config file if exist.
25 | func GeneratePlaylist() (err error) {
26 | playlist, err := ParseM3U(config.Current.M3UPath)
27 | if err != nil {
28 | return err
29 | }
30 | for _, recent := range config.Current.Recents {
31 | parts := strings.Split(recent, ":")
32 | categoryID := parts[0]
33 | channelID := parts[1]
34 | ordinal := parts[2]
35 | channel, err := playlist.GetChannel(categoryID, channelID)
36 | if err != nil {
37 | logging.Warn(err)
38 | } else {
39 | channel.IsRecent = true
40 | channel.RecentOrdinal, _ = strconv.Atoi(ordinal)
41 | playlist.Categories[categoryID].Channels[channelID] = channel
42 | }
43 | }
44 | for _, favorite := range config.Current.Favorites {
45 | parts := strings.Split(favorite, ":")
46 | categoryID := parts[0]
47 | channelID := parts[1]
48 | channel, err := playlist.GetChannel(categoryID, channelID)
49 | if err != nil {
50 | logging.Warn(err)
51 | } else {
52 | channel.IsFavorite = true
53 | playlist.Categories[categoryID].Channels[channelID] = channel
54 | }
55 | }
56 | singleton = &playlist
57 | return
58 | }
59 |
60 | // GetPlaylist returns singleton
61 | func GetPlaylist() *Playlist {
62 | return singleton
63 | }
64 |
65 | // ParseM3U parses an m3u list.
66 | // Modified code of https://github.com/jamesnetherton/m3u/blob/master/m3u.go
67 | func ParseM3U(fileNameOrURL string) (playlist Playlist, err error) {
68 |
69 | var f io.ReadCloser
70 | var data *http.Response
71 | if strings.HasPrefix(fileNameOrURL, "http://") || strings.HasPrefix(fileNameOrURL, "https://") {
72 | data, err = http.Get(fileNameOrURL)
73 | f = data.Body
74 | } else {
75 | f, err = os.Open(fileNameOrURL)
76 | }
77 |
78 | if err != nil {
79 | err = errors.New("Unable to open playlist file")
80 | return
81 | }
82 | defer f.Close()
83 |
84 | onFirstLine := true
85 | scanner := bufio.NewScanner(f)
86 |
87 | for scanner.Scan() {
88 | line := scanner.Text()
89 | if onFirstLine && !strings.HasPrefix(line, "#EXTM3U") {
90 | err = errors.New("Invalid m3u file format. Expected #EXTM3U file header")
91 | return
92 | }
93 |
94 | onFirstLine = false
95 |
96 | // Find #EXTINF prefixes
97 | if strings.HasPrefix(line, "#EXTINF") {
98 | line := strings.Replace(line, "#EXTINF:", "", -1)
99 | channelInfo := strings.Split(line, ",")
100 | if len(channelInfo) < 2 {
101 | err = errors.New("Invalid m3u file format. Expected EXTINF metadata to contain tvg attributes and channel name")
102 | return
103 | }
104 | attributes := channelInfo[0]
105 | title := channelInfo[1]
106 | category, id, logo, description := parseAttributes(attributes, title)
107 | categoryID := hex.EncodeToString([]byte(category))
108 | // Next line is m3u8 url
109 | scanner.Scan()
110 | mediaURL := scanner.Text()
111 |
112 | channel := Channel{
113 | ID: id,
114 | Title: title,
115 | MediaURL: mediaURL,
116 | Logo: logo,
117 | Description: description,
118 | Category: category,
119 | CategoryID: categoryID,
120 | }
121 |
122 | if playlist.Categories == nil {
123 | playlist.Categories = make(map[string]Category)
124 | }
125 | if _, ok := playlist.Categories[categoryID]; !ok {
126 | playlist.Categories[categoryID] = Category{
127 | ID: categoryID,
128 | Name: category,
129 | Channels: make(map[string]Channel),
130 | }
131 | }
132 | if _, ok := playlist.Categories[categoryID].Channels[channel.ID]; !ok {
133 | playlist.Categories[categoryID].Channels[channel.ID] = channel
134 | }
135 | }
136 | }
137 | return playlist, err
138 | }
139 |
140 | func parseAttributes(attributes string, title string) (category string, id string, logo string, description string) {
141 | tagsRegExp, _ := regexp.Compile("([a-zA-Z0-9-]+?)=\"([^\"]+)\"")
142 | tags := tagsRegExp.FindAllString(attributes, -1)
143 | category = "Uncategorized"
144 | id = title + uuid.New().String()
145 | logo = ""
146 | description = "TODO EPG"
147 |
148 | for i := range tags {
149 | tagParts := strings.Split(tags[i], "=")
150 | tagKey := tagParts[0]
151 | tagValue := strings.Replace(tagParts[1], "\"", "", -1)
152 | if tagKey == "group-title" {
153 | category = tagValue
154 | }
155 | // if tagKey == "tvg-id" {
156 | // id = tagValue
157 | // }
158 | if tagKey == "tvg-logo" {
159 | logo = tagValue
160 | }
161 | if tagKey == "tvg-url" {
162 | description = tagValue
163 | }
164 | }
165 | id = hex.EncodeToString([]byte(id))
166 | //logo, err := computeChannelLogo(id, logo)
167 | // if err != nil {
168 | // logging.Warn("Error while fetching channel logo for channel " + title + ". " + err.Error())
169 | // }
170 | return category, id, logo, description
171 | }
172 |
--------------------------------------------------------------------------------
/internal/m3u/models.go:
--------------------------------------------------------------------------------
1 | package m3u
2 |
3 | import (
4 | "errors"
5 | "strconv"
6 | "strings"
7 |
8 | "github.com/ghokun/appletv3-iptv/internal/config"
9 | )
10 |
11 | // Playlist struct defines a M3U playlist. M3U playlist starts with #EXTM3U line.
12 | type Playlist struct {
13 | Categories map[string]Category
14 | }
15 |
16 | // Category in a M3U playlist, group-title attribute.
17 | type Category struct {
18 | ID string
19 | Name string
20 | Channels map[string]Channel
21 | }
22 |
23 | // Channel is a TV channel in an M3U playlist. Starts with #EXTINF:- prefix.
24 | // #EXTINF:-1 tvg-id="" tvg-name="" tvg-country="" tvg-language="" tvg-logo="" tvg-url="" group-title="",Channel Name
25 | // https://channel.url/stream.m3u8
26 | type Channel struct {
27 | ID string // tvg-id or .Title. Spaces are replaced with underscore.
28 | Title string // Channel title, string that comes after comma
29 | MediaURL string // Second line after #EXTINF:-...
30 | Logo string // tvg-logo or placeholder. missing_logo aspect ratio
31 | Description string // Unused for now, will be used for EPG implementation
32 | Category string // group-title or Uncategorized if missing
33 | CategoryID string // For link generation purposes
34 | IsRecent bool // Is channel recently watched?
35 | RecentOrdinal int // Recent watch order
36 | IsFavorite bool // Is channel favorite?
37 | }
38 |
39 | // GetCategory - Gets Category and its children in current playlist.
40 | func (playlist *Playlist) GetCategory(category string) (value Category, err error) {
41 | if value, ok := playlist.Categories[category]; ok {
42 | return value, nil
43 | }
44 | return value, errors.New("Category could not be found")
45 | }
46 |
47 | // GetChannel - Gets channel with given category and channel values.
48 | func (playlist *Playlist) GetChannel(category string, channel string) (value Channel, err error) {
49 | cat, err := playlist.GetCategory(category)
50 | if err != nil {
51 | return value, err
52 | }
53 | if value, ok := cat.Channels[channel]; ok {
54 | return value, nil
55 | }
56 | return value, errors.New("Channel could not be found")
57 | }
58 |
59 | // GetChannelsCount - Gets count of all channels.
60 | func (playlist *Playlist) GetChannelsCount() (count int) {
61 | count = 0
62 | if playlist == nil {
63 | return count
64 | }
65 | for _, category := range playlist.Categories {
66 | count += len(category.Channels)
67 | }
68 | return count
69 | }
70 |
71 | // GetRecentChannelsCount - Gets count of recently watched channels.
72 | func (playlist *Playlist) GetRecentChannelsCount() (count int) {
73 | count = 0
74 | if playlist == nil {
75 | return count
76 | }
77 | for _, category := range playlist.Categories {
78 | for _, channel := range category.Channels {
79 | if channel.IsRecent {
80 | count++
81 | }
82 | }
83 | }
84 | return count
85 | }
86 |
87 | // GetFavoriteChannelsCount - Gets count of favorite channels.
88 | func (playlist *Playlist) GetFavoriteChannelsCount() (count int) {
89 | count = 0
90 | if playlist == nil {
91 | return count
92 | }
93 | for _, category := range playlist.Categories {
94 | for _, channel := range category.Channels {
95 | if channel.IsFavorite {
96 | count++
97 | }
98 | }
99 | }
100 | return count
101 | }
102 |
103 | // GetRecentChannels - Gets recent channels and puts them in order.
104 | func (playlist *Playlist) GetRecentChannels() (recentChannels []Channel) {
105 | recentCount := playlist.GetRecentChannelsCount()
106 | if recentCount == 0 {
107 | return nil
108 | }
109 | recentChannels = make([]Channel, recentCount)
110 | for _, category := range playlist.Categories {
111 | for _, channel := range category.Channels {
112 | if channel.IsRecent {
113 | recentChannels[channel.RecentOrdinal-1] = channel
114 | }
115 | }
116 | }
117 | return recentChannels
118 | }
119 |
120 | // SetRecentChannel - Sets selected channel as recent and updates order of other channels.
121 | func (playlist *Playlist) SetRecentChannel(selectedChannel Channel) error {
122 | for _, channel := range playlist.GetRecentChannels() {
123 | if selectedChannel.IsRecent {
124 | if channel.ID != selectedChannel.ID && selectedChannel.RecentOrdinal > channel.RecentOrdinal {
125 | channel.RecentOrdinal++
126 | }
127 | } else {
128 | channel.RecentOrdinal++
129 | }
130 | playlist.Categories[channel.CategoryID].Channels[channel.ID] = channel
131 | }
132 | selectedChannel.RecentOrdinal = 1
133 | selectedChannel.IsRecent = true
134 | playlist.Categories[selectedChannel.CategoryID].Channels[selectedChannel.ID] = selectedChannel
135 |
136 | return config.Current.SaveRecents(channelsToString(playlist.GetRecentChannels(), true))
137 | }
138 |
139 | // ClearRecentChannels - Clears recent channel list.
140 | func (playlist *Playlist) ClearRecentChannels() error {
141 | for _, channel := range playlist.GetRecentChannels() {
142 | channel.IsRecent = false
143 | playlist.Categories[channel.CategoryID].Channels[channel.ID] = channel
144 | }
145 | return config.Current.ClearRecents()
146 | }
147 |
148 | // GetFavoriteChannels - Gets favorite channels.
149 | func (playlist *Playlist) GetFavoriteChannels() (favoriteChannels []Channel) {
150 | for _, category := range playlist.Categories {
151 | for _, channel := range category.Channels {
152 | if channel.IsFavorite {
153 | favoriteChannels = append(favoriteChannels, channel)
154 | }
155 | }
156 | }
157 | return favoriteChannels
158 | }
159 |
160 | // ToggleFavoriteChannel - Clears favorite channel list.
161 | func (playlist *Playlist) ToggleFavoriteChannel(category string, channel string) (err error) {
162 | selectedChannel, err := playlist.GetChannel(category, channel)
163 | if err != nil {
164 | return err
165 | }
166 | selectedChannel.IsFavorite = !selectedChannel.IsFavorite
167 | playlist.Categories[category].Channels[channel] = selectedChannel
168 | err = config.Current.SaveFavorites(channelsToString(playlist.GetFavoriteChannels(), false))
169 | return err
170 | }
171 |
172 | // ClearFavoriteChannels - Clears favorite channel list.
173 | func (playlist *Playlist) ClearFavoriteChannels() error {
174 | for _, channel := range playlist.GetFavoriteChannels() {
175 | channel.IsFavorite = false
176 | playlist.Categories[channel.CategoryID].Channels[channel.ID] = channel
177 | }
178 | return config.Current.ClearFavorites()
179 | }
180 |
181 | // SearchChannels - Searches channel titles with the given term, case insensitive.
182 | func (playlist *Playlist) SearchChannels(term string) (searchResults Playlist) {
183 | searchResults = Playlist{}
184 | for categoryKey, categoryValue := range playlist.Categories {
185 | for channelKey, channelValue := range categoryValue.Channels {
186 | if strings.Contains(strings.ToLower(channelValue.Title), strings.ToLower(term)) {
187 | if searchResults.Categories == nil {
188 | searchResults.Categories = make(map[string]Category)
189 | }
190 | if _, ok := searchResults.Categories[categoryKey]; !ok {
191 | searchResults.Categories[categoryKey] = Category{
192 | Name: categoryValue.Name,
193 | Channels: make(map[string]Channel),
194 | }
195 | }
196 | if _, ok := searchResults.Categories[categoryKey].Channels[channelKey]; !ok {
197 | searchResults.Categories[categoryKey].Channels[channelKey] = channelValue
198 | }
199 | }
200 | }
201 | }
202 | return searchResults
203 | }
204 |
205 | func channelsToString(channels []Channel, includeOrdinal bool) (channelsStr []string) {
206 | for _, channel := range channels {
207 | channelStr := channel.CategoryID + ":" + channel.ID
208 | if includeOrdinal {
209 | channelStr = channelStr + ":" + strconv.Itoa(channel.RecentOrdinal)
210 | }
211 | channelsStr = append(channelsStr, channelStr)
212 | }
213 | return channelsStr
214 | }
215 |
--------------------------------------------------------------------------------
/internal/appletv/appletv.go:
--------------------------------------------------------------------------------
1 | package appletv
2 |
3 | import (
4 | "errors"
5 | "io/ioutil"
6 | "net/http"
7 | "path"
8 | "strconv"
9 | "time"
10 |
11 | "github.com/ghokun/appletv3-iptv/internal/config"
12 | "github.com/ghokun/appletv3-iptv/internal/logging"
13 | "github.com/ghokun/appletv3-iptv/internal/m3u"
14 | )
15 |
16 | func errorHandler(w http.ResponseWriter, r *http.Request, err error) {
17 | logging.Warn("Error at " + r.RequestURI + ". With details: " + err.Error())
18 | GenerateErrorXML(w, r, ErrorData{
19 | Title: "Error",
20 | Description: err.Error(),
21 | })
22 | }
23 |
24 | func unsupportedOperationHandler(w http.ResponseWriter, r *http.Request) {
25 | errorHandler(w, r, errors.New("Unsupported operation"))
26 | }
27 |
28 | // MainHandler https://appletv.redbull.tv
29 | func MainHandler(w http.ResponseWriter, r *http.Request) {
30 | logging.CheckLogRotationAndRotate()
31 | switch r.Method {
32 | case "GET":
33 | GenerateXML(w, r, "templates/main.xml", m3u.GetPlaylist().GetChannelsCount())
34 | default:
35 | unsupportedOperationHandler(w, r)
36 | }
37 | }
38 |
39 | // ChannelsHandler https://appletv.redbull.tv/channels.xml
40 | func ChannelsHandler(w http.ResponseWriter, r *http.Request) {
41 | switch r.Method {
42 | case "GET":
43 | GenerateXML(w, r, "templates/channels.xml", m3u.GetPlaylist())
44 | default:
45 | unsupportedOperationHandler(w, r)
46 | }
47 | }
48 |
49 | // ChannelOptionsHandler https://appletv.redbull.tv/channel-options.xml?category=..&channel=..
50 | func ChannelOptionsHandler(w http.ResponseWriter, r *http.Request) {
51 | switch r.Method {
52 | case "GET":
53 | category := r.URL.Query().Get("category")
54 | channel := r.URL.Query().Get("channel")
55 | value, err := m3u.GetPlaylist().GetChannel(category, channel)
56 | if err != nil {
57 | errorHandler(w, r, err)
58 | } else {
59 | GenerateXML(w, r, "templates/channel-options.xml", value)
60 | }
61 | default:
62 | unsupportedOperationHandler(w, r)
63 | }
64 | }
65 |
66 | // RecentHandler https://appletv.redbull.tv/recent.xml
67 | func RecentHandler(w http.ResponseWriter, r *http.Request) {
68 | switch r.Method {
69 | case "GET":
70 | GenerateXML(w, r, "templates/recent.xml", m3u.GetPlaylist())
71 | default:
72 | unsupportedOperationHandler(w, r)
73 | }
74 | }
75 |
76 | // FavoritesHandler https://appletv.redbull.tv/favorites.xml
77 | func FavoritesHandler(w http.ResponseWriter, r *http.Request) {
78 | switch r.Method {
79 | case "GET":
80 | GenerateXML(w, r, "templates/favorites.xml", m3u.GetPlaylist())
81 | default:
82 | unsupportedOperationHandler(w, r)
83 | }
84 | }
85 |
86 | // ToggleFavoriteHandler https://appletv.redbull.tv/toggle-favorite.xml?category=..&channel=..
87 | func ToggleFavoriteHandler(w http.ResponseWriter, r *http.Request) {
88 | switch r.Method {
89 | case "POST":
90 | category := r.URL.Query().Get("category")
91 | channel := r.URL.Query().Get("channel")
92 | err := m3u.GetPlaylist().ToggleFavoriteChannel(category, channel)
93 | if err != nil {
94 | errorHandler(w, r, err)
95 | }
96 | default:
97 | unsupportedOperationHandler(w, r)
98 | }
99 | }
100 |
101 | // CategoryHandler https://appletv.redbull.tv/category.xml?category=..
102 | func CategoryHandler(w http.ResponseWriter, r *http.Request) {
103 | switch r.Method {
104 | case "GET":
105 | category := r.URL.Query().Get("category")
106 | value, err := m3u.GetPlaylist().GetCategory(category)
107 | if err != nil {
108 | errorHandler(w, r, err)
109 | } else {
110 | GenerateXML(w, r, "templates/category.xml", value)
111 | }
112 | default:
113 | unsupportedOperationHandler(w, r)
114 | }
115 | }
116 |
117 | // PlayerHandler https://appletv.redbull.tv/player.xml?category=..&channel=..
118 | func PlayerHandler(w http.ResponseWriter, r *http.Request) {
119 | switch r.Method {
120 | case "GET":
121 | category := r.URL.Query().Get("category")
122 | channel := r.URL.Query().Get("channel")
123 | selectedChannel, err := m3u.GetPlaylist().GetChannel(category, channel)
124 | if err != nil {
125 | errorHandler(w, r, err)
126 | } else {
127 | err = m3u.GetPlaylist().SetRecentChannel(selectedChannel)
128 | if err != nil {
129 | logging.Warn("Error while setting recent channel: " + selectedChannel.Title)
130 | }
131 | GenerateXML(w, r, "templates/player.xml", selectedChannel)
132 | }
133 | default:
134 | unsupportedOperationHandler(w, r)
135 | }
136 | }
137 |
138 | // SearchHandler https://appletv.redbull.tv/search.xml
139 | func SearchHandler(w http.ResponseWriter, r *http.Request) {
140 | switch r.Method {
141 | case "GET":
142 | GenerateXML(w, r, "templates/search.xml", nil)
143 | default:
144 | unsupportedOperationHandler(w, r)
145 | }
146 | }
147 |
148 | // SearchResultsHandler https://appletv.redbull.tv/search-results.xml?term=..
149 | func SearchResultsHandler(w http.ResponseWriter, r *http.Request) {
150 | switch r.Method {
151 | case "GET":
152 | term := r.URL.Query().Get("term")
153 | GenerateXML(w, r, "templates/search-results.xml", m3u.GetPlaylist().SearchChannels(term))
154 | default:
155 | unsupportedOperationHandler(w, r)
156 | }
157 | }
158 |
159 | // SettingsHandler https://appletv.redbull.tv/settings.xml
160 | func SettingsHandler(w http.ResponseWriter, r *http.Request) {
161 | switch r.Method {
162 | case "GET":
163 | GenerateXML(w, r, "templates/settings.xml", GetSettingsData())
164 | default:
165 | unsupportedOperationHandler(w, r)
166 | }
167 | }
168 |
169 | // SetM3UHandler https://appletv.redbull.tv/set-m3u.xml
170 | func SetM3UHandler(w http.ResponseWriter, r *http.Request) {
171 | switch r.Method {
172 | case "POST":
173 | newM3UPath := r.URL.Query().Get("m3u")
174 | err := config.Current.SaveM3UPath(newM3UPath)
175 | if err != nil {
176 | logging.Warn("Error while setting M3U address: " + err.Error())
177 | } else {
178 | logging.Info("Setting M3U address to: " + newM3UPath)
179 | }
180 | http.Redirect(w, r, "/settings.xml", http.StatusSeeOther)
181 | default:
182 | unsupportedOperationHandler(w, r)
183 | }
184 | }
185 |
186 | // ReloadChannelsHandler https://appletv.redbull.tv/reload-channels.xml
187 | func ReloadChannelsHandler(w http.ResponseWriter, r *http.Request) {
188 | switch r.Method {
189 | case "GET":
190 | GenerateXML(w, r, "templates/reload-channels.xml", nil)
191 | case "POST":
192 | logging.Info("Reloading channels...")
193 | logging.Info("Previous channel count is: " + strconv.Itoa(m3u.GetPlaylist().GetChannelsCount()))
194 | logging.Info("Previous recent channel count is: " + strconv.Itoa(m3u.GetPlaylist().GetRecentChannelsCount()))
195 | logging.Info("Previous favorite count is: " + strconv.Itoa(m3u.GetPlaylist().GetFavoriteChannelsCount()))
196 | recentChannels := m3u.GetPlaylist().GetRecentChannels()
197 | favoriteChannels := m3u.GetPlaylist().GetFavoriteChannels()
198 | err := m3u.GeneratePlaylist()
199 | for _, recent := range recentChannels {
200 | channel, err := m3u.GetPlaylist().GetChannel(recent.CategoryID, recent.ID)
201 | if err == nil {
202 | channel.IsRecent = true
203 | err = m3u.GetPlaylist().SetRecentChannel(channel)
204 | if err != nil {
205 | logging.Warn("Error while setting recent channel: " + channel.Title)
206 | }
207 | }
208 | }
209 | for _, favorite := range favoriteChannels {
210 | _, err := m3u.GetPlaylist().GetChannel(favorite.CategoryID, favorite.ID)
211 | if err == nil {
212 | err = m3u.GetPlaylist().ToggleFavoriteChannel(favorite.CategoryID, favorite.ID)
213 | if err != nil {
214 | logging.Warn("Error while setting favorite channel: " + favorite.ID)
215 | }
216 | }
217 | }
218 | if err != nil {
219 | errorHandler(w, r, err)
220 | }
221 | logging.Info("Reloaded channels.")
222 | logging.Info("Channel count after reload is: " + strconv.Itoa(m3u.GetPlaylist().GetChannelsCount()))
223 | logging.Info("Recent Channel count after reload is: " + strconv.Itoa(m3u.GetPlaylist().GetRecentChannelsCount()))
224 | logging.Info("Favorite Channel count after reload is: " + strconv.Itoa(m3u.GetPlaylist().GetFavoriteChannelsCount()))
225 | default:
226 | unsupportedOperationHandler(w, r)
227 | }
228 | }
229 |
230 | // ClearRecentHandler https://appletv.redbull.tv/clear-recent.xml
231 | func ClearRecentHandler(w http.ResponseWriter, r *http.Request) {
232 | switch r.Method {
233 | case "POST":
234 | err := m3u.GetPlaylist().ClearRecentChannels()
235 | if err != nil {
236 | logging.Warn("Error while clearing recently watched channels.")
237 | } else {
238 | logging.Info("Cleared recently watched channels.")
239 | }
240 | http.Redirect(w, r, "/settings.xml", http.StatusSeeOther)
241 | default:
242 | unsupportedOperationHandler(w, r)
243 | }
244 | }
245 |
246 | // ClearFavoritesHandler https://appletv.redbull.tv/clear-favorites.xml
247 | func ClearFavoritesHandler(w http.ResponseWriter, r *http.Request) {
248 | switch r.Method {
249 | case "POST":
250 | err := m3u.GetPlaylist().ClearFavoriteChannels()
251 | if err != nil {
252 | logging.Warn("Error while clearing favorite channels.")
253 | } else {
254 | logging.Info("Cleared favorite channels.")
255 | }
256 | http.Redirect(w, r, "/settings.xml", http.StatusSeeOther)
257 | default:
258 | unsupportedOperationHandler(w, r)
259 | }
260 | }
261 |
262 | // LogsHandler https://appletv.redbull.tv/logs.xml
263 | func LogsHandler(w http.ResponseWriter, r *http.Request) {
264 | switch r.Method {
265 | case "GET":
266 | t := time.Now().Format("2006-01-02")
267 | logFilePath := path.Join(config.Current.LoggingPath, t+".log")
268 | logs, err := ioutil.ReadFile(logFilePath)
269 | if err != nil {
270 | errorHandler(w, r, err)
271 | } else {
272 | GenerateXML(w, r, "templates/logs.xml", string(logs))
273 | }
274 | default:
275 | unsupportedOperationHandler(w, r)
276 | }
277 | }
278 |
--------------------------------------------------------------------------------
/internal/server/assets/application.js:
--------------------------------------------------------------------------------
1 | // ***************************************************
2 | // ATVUtils - a JavaScript helper library for Apple TV
3 | var atvutils = ATVUtils = {
4 | makeRequest: function (url, method, headers, body, callback) {
5 | if (!url) {
6 | throw "loadURL requires a url argument";
7 | }
8 |
9 | var method = method || "GET",
10 | headers = headers || {},
11 | body = body || "";
12 |
13 | var xhr = new XMLHttpRequest();
14 | xhr.onreadystatechange = function () {
15 | try {
16 | if (xhr.readyState == 4) {
17 | if (xhr.status == 200) {
18 | callback(xhr.responseXML);
19 | } else {
20 | console.log("makeRequest received HTTP status " + xhr.status + " for " + url);
21 | callback(null);
22 | }
23 | }
24 | } catch (e) {
25 | console.error('makeRequest caught exception while processing request for ' + url + '. Aborting. Exception: ' + e);
26 | xhr.abort();
27 | callback(null);
28 | }
29 | }
30 | xhr.open(method, url, true);
31 |
32 | for (var key in headers) {
33 | xhr.setRequestHeader(key, headers[key]);
34 | }
35 |
36 | xhr.send();
37 | return xhr;
38 | },
39 |
40 | makeErrorDocument: function (message, description) {
41 | if (!message) {
42 | message = "";
43 | }
44 | if (!description) {
45 | description = "";
46 | }
47 |
48 | var errorXML = ' \
49 | \
50 | \
51 | \
55 | \
56 | ';
57 |
58 | return atv.parseXML(errorXML);
59 | },
60 |
61 | siteUnavailableError: function () {
62 | // TODO: localize
63 | return this.makeErrorDocument("sample-xml is currently unavailable. Try again later.", "Go to sample-xml.com/appletv for more information.");
64 | },
65 |
66 | loadError: function (message, description) {
67 | atv.loadXML(this.makeErrorDocument(message, description));
68 | },
69 |
70 | loadAndSwapError: function (message, description) {
71 | atv.loadAndSwapXML(this.makeErrorDocument(message, description));
72 | },
73 |
74 | loadURLInternal: function (url, method, headers, body, loader) {
75 | var me = this,
76 | xhr,
77 | proxy = new atv.ProxyDocument;
78 |
79 | proxy.show();
80 |
81 | proxy.onCancel = function () {
82 | if (xhr) {
83 | xhr.abort();
84 | }
85 | };
86 |
87 | xhr = me.makeRequest(url, method, headers, body, function (xml) {
88 | try {
89 | loader(proxy, xml);
90 | } catch (e) {
91 | console.error("Caught exception in for " + url + ". " + e);
92 | loader(me.siteUnavailableError());
93 | }
94 | });
95 | },
96 |
97 | loadURL: function (options) { //url, method, headers, body, processXML) {
98 | var me = this;
99 | if (typeof (options) === "string") {
100 | var url = options;
101 | } else {
102 | var url = options.url,
103 | method = options.method || null,
104 | headers = options.headers || null,
105 | body = options.body || null,
106 | processXML = options.processXML || null;
107 | }
108 |
109 | this.loadURLInternal(url, method, headers, body, function (proxy, xml) {
110 | if (typeof (processXML) == "function") processXML.call(this, xml);
111 | try {
112 | proxy.loadXML(xml, function (success) {
113 | if (!success) {
114 | console.log("loadURL failed to load " + url);
115 | proxy.loadXML(me.siteUnavailableError());
116 | }
117 | });
118 | } catch (e) {
119 | console.log("loadURL caught exception while loading " + url + ". " + e);
120 | proxy.loadXML(me.siteUnavailableError());
121 | }
122 | });
123 | },
124 |
125 | // loadAndSwapURL can only be called from page-level JavaScript of the page that wants to be swapped out.
126 | loadAndSwapURL: function (options) { //url, method, headers, body, processXML) {
127 | var me = this;
128 | if (typeof (options) === "string") {
129 | var url = options;
130 | } else {
131 | var url = options.url,
132 | method = options.method || null,
133 | headers = options.headers || null,
134 | body = options.body || null,
135 | processXML = options.processXML || null;
136 | }
137 |
138 | this.loadURLInternal(url, method, headers, body, function (proxy, xml) {
139 | if (typeof (processXML) == "function") processXML.call(this, xml);
140 | try {
141 | proxy.loadXML(xml, function (success) {
142 | if (success) {
143 | atv.unloadPage();
144 | } else {
145 | console.log("loadAndSwapURL failed to load " + url);
146 | proxy.loadXML(me.siteUnavailableError(), function (success) {
147 | if (success) {
148 | atv.unloadPage();
149 | }
150 | });
151 | }
152 | });
153 | } catch (e) {
154 | console.error("loadAndSwapURL caught exception while loading " + url + ". " + e);
155 | proxy.loadXML(me.siteUnavailableError(), function (success) {
156 | if (success) {
157 | atv.unloadPage();
158 | }
159 | });
160 | }
161 | });
162 | },
163 |
164 | /**
165 | * Used to manage setting and retrieving data from local storage
166 | */
167 | data: function (key, value) {
168 | if (key && value) {
169 | try {
170 | atv.localStorage.setItem(key, value);
171 | return value;
172 | } catch (error) {
173 | console.error('Failed to store data element: ' + error);
174 | }
175 |
176 | } else if (key) {
177 | try {
178 | return atv.localStorage.getItem(key);
179 | } catch (error) {
180 | console.error('Failed to retrieve data element: ' + error);
181 | }
182 | }
183 | return null;
184 | },
185 |
186 | deleteData: function (key) {
187 | try {
188 | atv.localStorage.removeItem(key);
189 | } catch (error) {
190 | console.error('Failed to remove data element: ' + error);
191 | }
192 | },
193 |
194 |
195 | /**
196 | * @params options.name - string node name
197 | * @params options.text - string textContent
198 | * @params options.attrs - array of attribute to set {"name": string, "value": string, bool}
199 | * @params options.children = array of childNodes same values as options
200 | * @params doc - document to attach the node to
201 | * returns node
202 | */
203 | createNode: function (options, doc) {
204 | var doc = doc || document;
205 | options = options || {};
206 |
207 | if (options.name && options.name != '') {
208 | var newElement = doc.makeElementNamed(options.name);
209 |
210 | if (options.text) newElement.textContent = options.text;
211 |
212 | if (options.attrs) {
213 | options.attrs.forEach(function (e, i, a) {
214 | newElement.setAttribute(e.name, e.value);
215 | }, this);
216 | }
217 |
218 | if (options.children) {
219 | options.children.forEach(function (e, i, a) {
220 | newElement.appendChild(this.createNode(e, doc));
221 | }, this)
222 | }
223 |
224 | return newElement;
225 | }
226 | },
227 |
228 | validEmailAddress: function (email) {
229 | var emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i,
230 | isValid = email.search(emailRegex);
231 | return (isValid > -1);
232 | },
233 |
234 | softwareVersionIsAtLeast: function (version) {
235 | var deviceVersion = atv.device.softwareVersion.split('.'),
236 | requestedVersion = version.split('.');
237 |
238 | // We need to pad the device version length with "0" to account for 5.0 vs 5.0.1
239 | if (deviceVersion.length < requestedVersion.length) {
240 | var difference = requestedVersion.length - deviceVersion.length,
241 | dvl = deviceVersion.length;
242 |
243 | for (var i = 0; i < difference; i++) {
244 | deviceVersion[dvl + i] = "0";
245 | };
246 | };
247 |
248 | // compare the same index from each array.
249 | for (var c = 0; c < deviceVersion.length; c++) {
250 | var dv = deviceVersion[c],
251 | rv = requestedVersion[c] || "0";
252 |
253 | if (parseInt(dv) > parseInt(rv)) {
254 | return true;
255 | } else if (parseInt(dv) < parseInt(rv)) {
256 | return false;
257 | };
258 | };
259 |
260 | // If we make it this far the two arrays are identical, so we're true
261 | return true;
262 | },
263 |
264 | shuffleArray: function (arr) {
265 | var tmp, current, top = arr.length;
266 |
267 | if (top) {
268 | while (--top) {
269 | current = Math.floor(Math.random() * (top + 1));
270 | tmp = arr[current];
271 | arr[current] = arr[top];
272 | arr[top] = tmp;
273 | };
274 | };
275 |
276 | return arr;
277 | },
278 |
279 | loadTextEntry: function (textEntryOptions) {
280 | var textView = new atv.TextEntry;
281 |
282 | textView.type = textEntryOptions.type || "emailAddress";
283 | textView.title = textEntryOptions.title || "";
284 | textView.image = textEntryOptions.image || null;
285 | textView.instructions = textEntryOptions.instructions || "";
286 | textView.label = textEntryOptions.label || "";
287 | textView.footnote = textEntryOptions.footnote || "";
288 | textView.defaultValue = textEntryOptions.defaultValue || null;
289 | textView.defaultToAppleID = textEntryOptions.defaultToAppleID || false;
290 | textView.onSubmit = textEntryOptions.onSubmit,
291 | textView.onCancel = textEntryOptions.onCancel,
292 |
293 | textView.show();
294 | },
295 |
296 | log: function (message, level) {
297 | var debugLevel = atv.sessionStorage.getItem("DEBUG_LEVEL"),
298 | level = level || 0;
299 |
300 | if (level <= debugLevel) {
301 | console.log(message);
302 | }
303 | },
304 |
305 | accessibilitySafeString: function (string) {
306 | var string = unescape(string);
307 |
308 | string = string
309 | .replace(/&/g, 'and')
310 | .replace(/&/g, 'and')
311 | .replace(/</g, 'less than')
312 | .replace(/\/g, 'greater than');
315 |
316 | return string;
317 | }
318 | };
319 |
320 | // Extend atv.ProxyDocument to load errors from a message and description.
321 | if (atv.ProxyDocument) {
322 | atv.ProxyDocument.prototype.loadError = function (message, description) {
323 | var doc = atvutils.makeErrorDocument(message, description);
324 | this.loadXML(doc);
325 | }
326 | }
327 |
328 |
329 | // atv.Document extensions
330 | if (atv.Document) {
331 | atv.Document.prototype.getElementById = function (id) {
332 | var elements = this.evaluateXPath("//*[@id='" + id + "']", this);
333 | if (elements && elements.length > 0) {
334 | return elements[0];
335 | }
336 | return undefined;
337 | }
338 | }
339 |
340 |
341 | // atv.Element extensions
342 | if (atv.Element) {
343 | atv.Element.prototype.getElementsByTagName = function (tagName) {
344 | return this.ownerDocument.evaluateXPath("descendant::" + tagName, this);
345 | }
346 |
347 | atv.Element.prototype.getElementByTagName = function (tagName) {
348 | var elements = this.getElementsByTagName(tagName);
349 | if (elements && elements.length > 0) {
350 | return elements[0];
351 | }
352 | return undefined;
353 | }
354 | }
355 |
356 | // Simple Array Sorting methods
357 | Array.prototype.sortAsc = function () {
358 | this.sort(function (a, b) {
359 | return a - b;
360 | });
361 | };
362 |
363 | Array.prototype.sortDesc = function () {
364 | this.sort(function (a, b) {
365 | return b - a;
366 | });
367 | };
368 |
369 |
370 | // Date methods and properties
371 | Date.lproj = {
372 | "DAYS": {
373 | "en": {
374 | "full": ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
375 | "abbrv": ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
376 | },
377 | "en_GB": {
378 | "full": ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
379 | "abbrv": ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
380 | }
381 | },
382 | "MONTHS": {
383 | "en": {
384 | "full": ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
385 | "abbrv": ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
386 | },
387 | "en_GB": {
388 | "full": ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
389 | "abbrv": ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
390 | }
391 | }
392 | }
393 |
394 | Date.prototype.getLocaleMonthName = function (type) {
395 | var language = atv.device.language,
396 | type = (type === true) ? "abbrv" : "full",
397 | MONTHS = Date.lproj.MONTHS[language] || Date.lproj.MONTHS["en"];
398 |
399 | return MONTHS[type][this.getMonth()];
400 | };
401 |
402 | Date.prototype.getLocaleDayName = function (type) {
403 | var language = atv.device.language,
404 | type = (type === true) ? "abbrv" : "full",
405 | DAYS = Date.lproj.DAYS[language] || Date.lproj.DAYS["en"];
406 |
407 | return DAYS[type][this.getDay()];
408 | };
409 |
410 | Date.prototype.nextDay = function (days) {
411 | var oneDay = 86400000,
412 | days = days || 1;
413 | this.setTime(new Date(this.valueOf() + (oneDay * days)));
414 | };
415 |
416 | Date.prototype.prevDay = function (days) {
417 | var oneDay = 86400000,
418 | days = days || 1;
419 | this.setTime(new Date(this.valueOf() - (oneDay * days)));
420 | };
421 |
422 |
423 | // String Trim methods
424 | String.prototype.trim = function (ch) {
425 | var ch = ch || '\\s',
426 | s = new RegExp('^[' + ch + ']+|[' + ch + ']+$', 'g');
427 | return this.replace(s, '');
428 | };
429 |
430 | String.prototype.trimLeft = function (ch) {
431 | var ch = ch || '\\s',
432 | s = new RegExp('^[' + ch + ']+', 'g');
433 | return this.replace(s, '');
434 | };
435 |
436 | String.prototype.trimRight = function (ch) {
437 | var ch = ch || '\\s',
438 | s = new RegExp('[' + ch + ']+$', 'g');
439 | return this.replace(s, '');
440 | };
441 |
442 | String.prototype.xmlEncode = function () {
443 | var string = unescape(this);
444 |
445 | string = string
446 | .replace(/&/g, '&')
447 | .replace(/\/g, '>');
449 |
450 | return string;
451 | };
452 |
453 | // End ATVUtils
454 | // ***************************************************
455 | /**
456 | * This is an XHR handler. It handles most of tediousness of the XHR request
457 | * and keeps track of onRefresh XHR calls so that we don't end up with multiple
458 | * page refresh calls.
459 | *
460 | * You can see how I call it on the handleRefresh function below.
461 | *
462 | *
463 | * @params object (hash) $options
464 | * @params string $options.url - url to be loaded
465 | * @params string $options.method - "GET", "POST", "PUT", "DELTE"
466 | * @params bool $options.type - false = "Sync" or true = "Async" (You should always use true)
467 | * @params func $options.success - Gets called on readyState 4 & status 200
468 | * @params func $options.failure - Gets called on readyState 4 & status != 200
469 | * @params func $options.callback - Gets called after the success and failure on readyState 4
470 | * @params string $options.data - data to be sent to the server
471 | * @params bool $options.refresh - Is this a call from the onRefresh event.
472 | */
473 | ATVUtils.Ajax = function ($options) {
474 | var me = this;
475 | $options = $options || {}
476 |
477 | /* Setup properties */
478 | this.url = $options.url || false;
479 | this.method = $options.method || "GET";
480 | this.type = ($options.type === false) ? false : true;
481 | this.success = $options.success || null;
482 | this.failure = $options.failure || null;
483 | this.data = $options.data || null;
484 | this.complete = $options.complete || null;
485 | this.refresh = $options.refresh || false;
486 |
487 | if (!this.url) {
488 | console.error('\nAjax Object requires a url to be passed in: e.g. { "url": "some string" }\n')
489 | return undefined;
490 | };
491 |
492 | this.id = Date.now();
493 |
494 | this.createRequest();
495 |
496 | this.req.onreadystatechange = this.stateChange;
497 |
498 | this.req.object = this;
499 |
500 | this.open();
501 |
502 | this.send();
503 |
504 | };
505 |
506 | ATVUtils.Ajax.currentlyRefreshing = false;
507 | ATVUtils.Ajax.activeRequests = {};
508 |
509 | ATVUtils.Ajax.prototype = {
510 | stateChange: function () {
511 | var me = this.object;
512 | switch (this.readyState) {
513 | case 1:
514 | if (typeof (me.connection) === "function") me.connection(this, me);
515 | break;
516 | case 2:
517 | if (typeof (me.received) === "function") me.received(this, me);
518 | break;
519 | case 3:
520 | if (typeof (me.processing) === "function") me.processing(this, me);
521 | break;
522 | case 4:
523 | if (this.status == "200") {
524 | if (typeof (me.success) === "function") me.success(this, me);
525 | } else {
526 | if (typeof (me.failure) === "function") me.failure(this.status, this, me);
527 | }
528 | if (typeof (me.complete) === "function") me.complete(this, me);
529 | if (me.refresh) Ajax.currentlyRefreshing = false;
530 | break;
531 | default:
532 | console.log("I don't think I should be here.");
533 | break;
534 | }
535 | },
536 | cancelRequest: function () {
537 | this.req.abort();
538 | delete ATVUtils.Ajax.activeRequests[this.id];
539 | },
540 | cancelAllActiveRequests: function () {
541 | for (var p in ATVUtils.Ajax.activeRequests) {
542 | if (ATVUtils.Ajax.activeRequests.hasOwnProperty(p)) {
543 | var obj = ATVUtils.Ajax.activeRequests[p];
544 | if (ATVUtils.Ajax.prototype.isPrototypeOf(obj)) {
545 | obj.req.abort();
546 | };
547 | };
548 | };
549 | ATVUtils.Ajax.activeRequests = {};
550 | },
551 | createRequest: function () {
552 | try {
553 | this.req = new XMLHttpRequest();
554 | ATVUtils.Ajax.activeRequests[this.id] = this;
555 | if (this.refresh) ATVUtils.Ajax.currentlyRefreshing = true;
556 | } catch (error) {
557 | alert("The request could not be created: " + error);
558 | console.error("failed to create request: " + error);
559 | }
560 | },
561 | open: function () {
562 | try {
563 | this.req.open(this.method, this.url, this.type);
564 | } catch (error) {
565 | console.log("failed to open request: " + error);
566 | }
567 | },
568 | send: function () {
569 | var data = this.data || null;
570 | try {
571 | this.req.send(data);
572 | } catch (error) {
573 | console.log("failed to send request: " + error);
574 | }
575 | }
576 | };
577 | // Application-level JavaScript. bag.plist links to this file.
578 | console.log("sample-xml application.js begin");
579 |
580 | // atv.onGenerateRequest
581 | // Called when Apple TV is about to make a URL request. Use this method to make changes to the URL. For example, use it
582 | // to decorate the URL with auth tokens or signatures.
583 | atv.onGenerateRequest = function (request) {
584 | console.log('atv.onGenerateRequest: ' + request.url);
585 |
586 | authToken = atv.sessionStorage["auth-token"]; // save to localStorage instead if you want to persist auth-token after reboot
587 | console.log("current auth token is " + authToken);
588 |
589 | if (authToken) {
590 | var separator = "&";
591 | if (request.url.indexOf("?") == -1) {
592 | separator = "?"
593 | }
594 |
595 | request.url = request.url + separator + "auth-token=" + authToken;
596 | }
597 | console.log('--- new url: ' + request.url);
598 | }
599 |
600 | // atv.onAppEntry
601 | // Called when you enter an application but before the root plist is requested. This method should not return until
602 | // application initialization is complete. Once this method has returned Apple TV will assume it can call
603 | // into any other callback. If atv.config.doesJavaScriptLoadRoot is true, then it is atv.onAppEntry's responsibility
604 | // to load the root page. If atv.config.doesJavaScriptLoadRoot is false, the next likely method that will be called
605 | // is atv.onGenerateRequest to decorate the URL for the root plist.
606 | atv.onAppEntry = function () {
607 | atvutils.loadURL("https://appletv.redbull.tv");
608 | }
609 |
610 | // atv.onAppExit
611 | // Called when the application exits. The application doesn't exit when the user goes to the main menu because the application
612 | // is still required to display it's top shelf. Rather, the application exits when an application is entered, even
613 | // if this application is the one that is entered. For example:
614 | // 1. User enters this application: atv.onAppEntry called
615 | // 2. User goes back to the main menu (nothing happens yet)
616 | // 3. User enters this application: atv.onAppExit called, then atv.onAppEntry is called
617 | atv.onAppExit = function () {
618 | console.log('sample-xml app exited');
619 | }
620 |
621 | // atv.onPageLoad
622 | // Called when a plist is loaded.
623 | atv.onPageLoad = function (pageIdentifier) {
624 |
625 | console.log('Application JS: Page ' + pageIdentifier + ' loaded');
626 |
627 | if (pageIdentifier == "com.sample.javascript-logout") {
628 | console.log("JavaScript logout page loaded. Perform logout.");
629 |
630 | // This is not always needed. If you want to perform logout only when the user explicitly asks for it, you
631 | // can use the sign-in-sign-out menu item.
632 | atv.logout();
633 | }
634 | }
635 |
636 | // atv.onPageUnload
637 | // Called when a page is unloaded.
638 | // Note that if you have an app-level javascript context (defined in your bag.plist) in addition to a javascript included a page's head element, onPageUnload will get invoked on both.
639 | atv.onPageUnload = function (pageIdentifier) {
640 | console.log('Application JS: Page ' + pageIdentifier + ' unloaded');
641 | }
642 |
643 | // atv.onPageBuried
644 | // Called when a new paged is pushed on top of the page
645 | // Note that if you have an app-level javascript context (defined in your bag.plist) in addition to a javascript included a page's head element, onPageBuried will get invoked on both.
646 | atv.onPageBuried = function (pageIdentifier) {
647 | console.log('Application JS: Page' + pageIdentifier + ' buried ');
648 | }
649 |
650 | // atv.onPageExhumed
651 | // Called when a new paged is brought back to the top of the stack
652 | // Note that if you have an app-level javascript context (defined in your bag.plist) in addition to a javascript included a page's head element, onPageExhumed will get invoked on both.
653 | atv.onPageExhumed = function (pageIdentifier) {
654 | console.log('Application JS: Page' + pageIdentifier + ' exhumed ');
655 | }
656 |
657 | // atv.onAuthenticate
658 | // Called when the user needs to be authenticated. Some events that would call this are:
659 | // - the user has explicitly tried to login via a sign-in-sign-out menu item
660 | // - the server returned a 401 and silent authentication is occuring
661 | // - non-silent authentication is occuring (because there are no credentials or silent auth failed)
662 | //
663 | // This method should not block. If it makes an XMLHttpRequest, it should do so asynchronously. When authentication is complete, you must notify
664 | // Apple TV of success or failure by calling callback.success() or callback.failure(msg).
665 | //
666 | // Do not save the username or password in atv.localStorage or atv.sessionStorage; Apple TV will manage the user's credentials.
667 | //
668 | // username - The username to authenticate with
669 | // password - The password to authenticate with
670 | // callback - Called to indicate success or failure to Apple TV. Either callback.success() or callback.failure(msg) must be called
671 | // in all situations.
672 | //
673 | atv.onAuthenticate = function (username, password, callback) {
674 |
675 | try {
676 | console.log('---- asked to auth user: ' + username + ', pass: ' + password);
677 | var url = "http://sample-web-server:3000/authenticate?username=" + encodeURIComponent(username) + "&password=" + encodeURIComponent(password);
678 | console.log('Trying to authenticate with ' + url);
679 |
680 | var req = new XMLHttpRequest();
681 |
682 | req.onreadystatechange = function () {
683 |
684 | try {
685 | console.log('Got ready state change of ' + req.readyState);
686 |
687 | if (req.readyState == 4) {
688 | console.log('Got status code of ' + req.status);
689 |
690 | if (req.status == 200) {
691 | console.log('Response text is ' + req.responseText);
692 |
693 | result = JSON.parse(req.responseText);
694 |
695 | console.log("Setting auth token to " + result["auth-token"]);
696 |
697 | if ("auth-token" in result) {
698 | atv.sessionStorage["auth-token"] = result["auth-token"];
699 | callback.success();
700 | }
701 | else {
702 | message = "";
703 | if ("message" in result) {
704 | message = result["message"]
705 | }
706 |
707 | callback.failure(message);
708 | }
709 | }
710 | else {
711 | // Specify a copyedited string because this will be displayed to the user.
712 | callback.failure('Auth failed. Status ' + req.status + ': ' + req.statusText);
713 | }
714 | }
715 | }
716 | catch (e) {
717 | // Specify a copyedited string because this will be displayed to the user.
718 | callback.failure('Caught exception while processing request. Aborting. Exception: ' + e);
719 | req.abort();
720 | }
721 | }
722 |
723 | req.open("GET", url, true);
724 | req.send();
725 | }
726 | catch (e) {
727 | // Specify a copyedited string because this will be displayed to the user.
728 | callback.failure('Caught exception setting up request. Exception: ' + e);
729 | }
730 | }
731 |
732 | // atv.onLogout
733 | // Called when the user is logged out. Use this method to remove any per-user data. For example, you probably
734 | // should call atv.sessionStorage.clear() and atv.localStorage.clear().
735 | atv.onLogout = function () {
736 |
737 | console.log('Notified that our account has logged out, clearing sessionStorage.');
738 |
739 | try {
740 | // Also clear localStorage in case you have any per-user data locally stored.
741 | atv.sessionStorage.clear();
742 | atv.localStorage.clear();
743 | }
744 | catch (e) {
745 | console.log('Caught exception trying to clear sessionStorage. Exception: ' + e);
746 | }
747 | }
748 |
749 | /**
750 | * If a vendor has content in iTunes their bag.plist will include a key under vendor-gk-1:
751 | * vendor-gk-1: itms-link
752 | *
753 | * This is made available as a localStorage resource.
754 | * If no itms-link is defined null is returned.
755 | */
756 | atv.getItmsLink = function () {
757 | return this.localStorage.getItem('itms-link');
758 | }
759 |
760 | function logPlayerAssetAndEventGroups() {
761 | console.log('Logging Player Asset ---------------------------------');
762 |
763 | // Test out asset and event groups on player
764 | var asset = atv.player.asset;
765 | if (asset != null) {
766 | var title = asset.getElementByTagName("title");
767 |
768 | console.log('The current asset is ' + title.textContent);
769 |
770 | var eventGroups = atv.player.eventGroups;
771 | if (eventGroups != null) {
772 | console.log('There are ' + eventGroups.length + ' current event groups');
773 | for (var i = 0, len = eventGroups.length; i < len; ++i) {
774 | var group = eventGroups[i];
775 | var groupTitle = group.getElementByTagName("title");
776 | console.log('event group title: ' + groupTitle.textContent);
777 |
778 | var events = group.getElementsByTagName("event");
779 | for (var j = 0, eventLen = events.length; j < eventLen; ++j) {
780 | var event = events[j];
781 | var eventTitle = event.getElementByTagName("title");
782 | console.log('event title: ' + eventTitle.textContent);
783 | }
784 | }
785 | }
786 | }
787 |
788 | console.log('END ---------------------------------');
789 | }
790 |
791 | if (atv.player) {
792 |
793 | var logTimer = null;
794 | var playlistRequest = null;
795 |
796 | atv.player.willStartPlaying = function () {
797 |
798 | console.log('atv.player.willStartPlaying');
799 | logPlayerAssetAndEventGroups();
800 |
801 | console.log('starting timer ======================');
802 |
803 | logTimer = atv.setInterval(function () {
804 | logPlayerAssetAndEventGroups();
805 | }, 5000);
806 |
807 | // Creates a text view that will be overlayed at the top of the video.
808 | // TextViewController.initiateView("counter");
809 |
810 | atv.sessionStorage["already-watched-ad"] = false;
811 | atv.sessionStorage["in-ad"] = false;
812 |
813 | var metadata = atv.player.asset.getElementByTagName('myMetadata');
814 | if (metadata != null) {
815 | console.log('private metadata found in the asset---------------');
816 |
817 | //
818 | // Setup bookmark.
819 | //
820 | var bookmark = metadata.getElementByTagName('bookmarkURL');
821 | if (bookmark != null) {
822 | console.log('bookmark url detected---------------');
823 | atv.sessionStorage["bookmark-url"] = bookmark.textContent;
824 | }
825 | else {
826 | atv.sessionStorage.removeItem('bookmark-url');
827 | }
828 |
829 | //
830 | // Use loadMoreAssets callback for playlists
831 | //
832 | var playlist = metadata.getElementByTagName('playlistBaseURL');
833 | if (playlist != null) {
834 | console.log('playlist url detected---------------');
835 | var currentPlaylistPart = 1;
836 |
837 | // This function is called whenever more assets are required by the player. The implementation
838 | // should call callback.success or callback.failure at least once as a result. This function
839 | // will not be called again until the invocation from the last call invokes callback.success
840 | // or callback.failure. Also, this function will not be called again for a playback if callback.success
841 | // is called with null argument, or callback.failure is called.
842 | // Calling any of the callback functions more than once during the function execution has no effect.
843 | atv.player.loadMoreAssets = function (callback) {
844 | console.log('load more assets called---------------');
845 |
846 | // Request the next item in the playlist.
847 | playlistRequest = new XMLHttpRequest();
848 | playlistRequest.onreadystatechange = function () {
849 | try {
850 | if (playlistRequest.readyState == 4) {
851 | if (playlistRequest.status == 200) {
852 | responseDocument = playlistRequest.responseXML;
853 | console.log('Playlist response is ' + responseDocument);
854 |
855 | // Pass the loaded assets in callback.success.
856 | callback.success(responseDocument.rootElement.getElementsByTagName('httpFileVideoAsset'));
857 | }
858 | else if (playlistRequest.status == 404) {
859 | // This example implementation counts on a 404 to signal the end of the playlist.
860 | // null will stop any further calls to loadMoreAssets for this playback.
861 | callback.success(null);
862 | }
863 | else {
864 | console.error('HTTP request failed. Status ' + playlistRequest.status + ': ' + playlistRequest.statusText);
865 |
866 | // Signal the failure
867 | callback.failure('HTTP request failed. Status ' + playlistRequest.status + ': ' + playlistRequest.statusText);
868 | }
869 | }
870 | }
871 | catch (e) {
872 | console.error('Caught exception while processing request. Aborting. Exception: ' + e);
873 | playlistRequest.abort();
874 |
875 | // Signal the failure
876 | callback.failure('Caught exception while processing request. Aborting. Exception: ' + e);
877 | }
878 | }
879 |
880 | playlistRequest.open("GET", playlist.textContent + currentPlaylistPart + ".xml");
881 | currentPlaylistPart++;
882 | playlistRequest.send();
883 | };
884 | }
885 | else {
886 | // Don't use dynamic playlists
887 | delete atv.player.loadMoreAssets;
888 | }
889 | }
890 | }
891 |
892 | // atv.player.currentAssetChanged
893 | // Called when the current asset changes to the next item in a playlist.
894 | atv.player.currentAssetChanged = function () {
895 | console.log('atv.player.currentAssetChanged');
896 |
897 | // Log the length of the current player asset
898 | console.log(" == ASSET LENGTH: currentAssetChanged: " + atv.player.currentItem.duration + " == ");
899 | }
900 |
901 | // atv.player.onStartBuffering
902 | // Called when the playhead has moved to a new location (including the initial load) and buffering starts.
903 | // playheadLocation - The location of the playhead in seconds from the beginning
904 | atv.player.onStartBuffering = function (playheadLocation) {
905 | gDateBufferingStarted = new Date();
906 | console.log('onStartBuffering at location ' + playheadLocation + ' at this time: ' + gDateBufferingStarted);
907 | logPlayerAssetAndEventGroups();
908 | console.log('end ---------------------');
909 | }
910 |
911 | // atv.player.onBufferSufficientToPlay
912 | // Called when enough data have buffered to begin playing without interruption.
913 | atv.player.onBufferSufficientToPlay = function () {
914 | var dateBufferBecameSufficientToPlay = new Date();
915 | var elapsed = dateBufferBecameSufficientToPlay - gDateBufferingStarted;
916 | console.log('onBufferSufficientToPlay: it took ' + elapsed + ' milliseconds to buffer enough data to start playback');
917 | // Log the length of the current player asset
918 | console.log(" == ASSET LENGTH: onBufferSufficientToPlay: " + atv.player.currentItem.duration + " == ");
919 | }
920 |
921 | // atv.player.onStallDuringPlayback
922 | // Called when there is a buffer underrun during normal speed video playback (i.e. not fast-forward or rewind).
923 | atv.player.onStallDuringPlayback = function (playheadLocation) {
924 | var now = new Date();
925 | console.log("onStallDuringPlayback: stall occurred at location " + playheadLocation + " at this time: " + now);
926 | }
927 |
928 | // atv.player.onPlaybackError
929 | // Called when an error occurred that terminated playback.
930 | // debugMessage - A debug message for development and reporting purposes only. Not for display to the user.
931 | atv.player.onPlaybackError = function (debugMessage) {
932 | // debugMessage is only intended for debugging purposes. Don't rely on specific values.
933 | console.log('onPlaybackError: error message is ' + debugMessage);
934 | }
935 |
936 | // atv.player.onQualityOfServiceReport
937 | // Called when a quality of service report is available.
938 | atv.player.onQualityOfServiceReport = function (report) {
939 | console.log("QoS report is\n" + report);
940 |
941 | // accessLog and errorLog are not gaurenteed to be present, so check for them before using.
942 |
943 | if ('accessLog' in report) {
944 | console.log("Acces Log:\n" + report.accessLog + "\----------------------------\n");
945 | }
946 |
947 | if ('errorLog' in report) {
948 | console.log("Error Log:\n" + report.errorLog + "\----------------------------\n");
949 | }
950 | }
951 |
952 | atv.player.playerStateChanged = function (newState, timeIntervalSec) {
953 | /*
954 | state constants are:
955 | atv.player.states.FastForwarding
956 | atv.player.states.Loading
957 | atv.player.states.Paused
958 | atv.player.states.Playing
959 | atv.player.states.Rewinding
960 | atv.player.states.Stopped
961 | */
962 |
963 | console.log("Player state changed to " + newState + " at this time " + timeIntervalSec);
964 | }
965 |
966 | // TODO - only show event callbacks example for media asset with a known asset-id, for now, control for all player items via this flag
967 | SHOW_EVENT_EXAMPLE = false;
968 |
969 | // atv.player.playerWillSeekToTime
970 | // Called after the user stops fast forwarding, rewinding, or skipping in the stream
971 | // timeIntervalSec - The elapsed time, in seconds, where the user stopped seeking in the stream
972 | // Returns: the adjusted time offset for the player. If no adjustment is needed, return timeIntervalSec.
973 | // Clients can check whether the playback is within an unskippable event and reset the playhead to the start of that event.
974 | atv.player.playerWillSeekToTime = function (timeIntervalSec) {
975 |
976 | console.log('playerWillSeekToTime: ' + timeIntervalSec);
977 |
978 | if (!SHOW_EVENT_EXAMPLE) {
979 | return timeIntervalSec;
980 | }
981 |
982 | // TODO - replace example using event group config
983 | // Example of event from offset 10-15 sec that is unskippable. If the user seeks within or past, reset to beginning of event
984 | if (timeIntervalSec >= 10 && !atv.sessionStorage["already-watched-event"]) {
985 | if (timeIntervalSec > 15) {
986 | atv.sessionStorage["resume-time"] = timeIntervalSec;
987 | }
988 | atv.sessionStorage["in-event"] = true;
989 | return 10;
990 | }
991 | return timeIntervalSec;
992 | }
993 |
994 | // atv.player.playerShouldHandleEvent
995 | // Called to check if the given event should be allowed given the current player time and state.
996 | // event - One of: atv.player.events.FFwd, atv.player.events.Pause, atv.player.events.Play, atv.player.events.Rew, atv.player.events.SkipBack, atv.player.events.SkipFwd
997 | // timeIntervalSec - The elapsed time, in seconds, where the event would be fired
998 | // Returns: true if the event should be allowed, false otherwise
999 | atv.player.playerShouldHandleEvent = function (event, timeIntervalSec) {
1000 |
1001 | console.log('playerShouldHandleEvent: ' + event + ', timeInterval: ' + timeIntervalSec);
1002 |
1003 | if (!SHOW_EVENT_EXAMPLE) {
1004 | return true;
1005 | }
1006 |
1007 | // TODO - replace example using event group config
1008 | // Disallow all player events while in the sample event
1009 | if (timeIntervalSec >= 10 && timeIntervalSec < 15 && !atv.sessionStorage["already-watched-event"]) {
1010 | return false;
1011 | }
1012 |
1013 | return true;
1014 | }
1015 |
1016 | // atv.player.playerTimeDidChange
1017 | // Called whenever the playhead time changes for the currently playing asset.
1018 | // timeIntervalSec - The elapsed time, in seconds, of the current playhead position
1019 | atv.player.playerTimeDidChange = function (timeIntervalSec) {
1020 |
1021 | var netTime = atv.player.convertGrossToNetTime(timeIntervalSec);
1022 | var andBackToGross = atv.player.convertNetToGrossTime(netTime);
1023 | //console.log('playerTimeDidChange: ' + timeIntervalSec + " net time " + netTime + " and back to gross " + andBackToGross);
1024 |
1025 | if (atv.sessionStorage["bookmark-url"] != null) {
1026 | atv.sessionStorage["bookmark-time"] = timeIntervalSec;
1027 | }
1028 |
1029 | if (!SHOW_EVENT_EXAMPLE) {
1030 | return;
1031 | }
1032 |
1033 | // TODO - replace example using event group config
1034 | // If we are currently in the sample event, and are about to exit, clear our flag, mark that the event was watched, and resume if needed
1035 | if (atv.sessionStorage["in-event"] && timeIntervalSec > 15) {
1036 | atv.sessionStorage["in-event"] = false;
1037 | atv.sessionStorage["already-watched-event"] = true;
1038 | if (atv.sessionStorage["resume-time"]) {
1039 | atv.player.playerSeekToTime(atv.sessionStorage["resume-time"]);
1040 | atv.sessionStorage.removeItem("resume-time");
1041 | }
1042 | }
1043 | }
1044 |
1045 | // atv.player.didStopPlaying
1046 | // Called at some point after playback stops. Use this to to per-playback teardown or reporting.
1047 | atv.player.didStopPlaying = function () {
1048 | console.log('didStopPlaying');
1049 |
1050 | atv.clearInterval(logTimer);
1051 | logTimer = null;
1052 |
1053 | // remove the view timer if it has been set.
1054 | var messageTimer = TextViewController.getConfig("messageTimer");
1055 | if (messageTimer) {
1056 | atv.clearInterval(messageTimer);
1057 | TextViewController.setConfig("messageTimer", null);
1058 | }
1059 |
1060 | // Save the book mark.
1061 | var bookmarkURL = atv.sessionStorage["bookmark-url"];
1062 | if (bookmarkURL != null) {
1063 | console.log('saving bookmark to server---------------');
1064 |
1065 | // Request the next item in the playlist.
1066 | bookmarkRequest = new XMLHttpRequest();
1067 | bookmarkRequest.onreadystatechange = function () {
1068 | try {
1069 | if (bookmarkRequest.readyState == 4) {
1070 | if (bookmarkRequest.status == 200) {
1071 | console.log('Bookmark written');
1072 | }
1073 | else {
1074 | console.error('Bookmark write request failed. Status ' + bookmarkRequest.status + ': ' + bookmarkRequest.statusText);
1075 | }
1076 | }
1077 | }
1078 | catch (e) {
1079 | console.error('Caught exception while processing bookmark write request. Aborting. Exception: ' + e);
1080 | }
1081 | }
1082 |
1083 | bookmarkRequest.open("GET", bookmarkURL + atv.sessionStorage["bookmark-time"]);
1084 | bookmarkRequest.send();
1085 |
1086 | atv.sessionStorage.removeItem('bookmark-url');
1087 | }
1088 |
1089 | // Cancel request
1090 | if (playlistRequest != null) {
1091 | playlistRequest.abort();
1092 | playlistRequest = null;
1093 | }
1094 |
1095 | delete atv.player.loadMoreAssets;
1096 | }
1097 |
1098 | // atv.player.onTransportControlsDisplayed
1099 | // called when the transport control is going to be displayed
1100 | // @params: animation duration - float
1101 | atv.player.onTransportControlsDisplayed = function (animationDuration) {
1102 | console.log("onTransportControlsDisplayed animation duration: " + animationDuration + " <--- ");
1103 | if (TextViewController.getView("counter")) {
1104 | TextViewController.showView("counter", animationDuration);
1105 | }
1106 | }
1107 |
1108 | // atv.player.onTransportControlsDisplayed
1109 | // called when the transport control is going to be hidden
1110 | // @params: animation duration - float
1111 | atv.player.onTransportControlsHidden = function (animationDuration) {
1112 | console.log("onTransportControlsHidden animation duration: " + animationDuration + " <--- ");
1113 | if (TextViewController.getView("counter")) {
1114 | TextViewController.hideView("counter", animationDuration);
1115 | }
1116 | }
1117 |
1118 | }
1119 | atv.config = {
1120 | // If doesJavaScriptLoadRoot is true, then atv.onAppEntry must load the root URL; otherwise, root-url from the bag is used.
1121 | doesJavaScriptLoadRoot: true
1122 | };
1123 |
1124 | /**
1125 | * These two functions are used to add the needed functionality for AppleTV Screen Saver
1126 | */
1127 |
1128 | atv.onScreensaverPhotosSelectionEntry = function () {
1129 | console.log('photoBatch screensaver photos selection begin');
1130 |
1131 | // The collection object is passed to atv.onExecuteQuery as parameters to load Images.
1132 | // Currently only one collection is able to be passed.
1133 | var collection = {
1134 | "id": "screensaver-photos",
1135 | "name": "Popular",
1136 | "type": "collection"
1137 | };
1138 | atv.setScreensaverPhotosCollection(collection);
1139 | }
1140 |
1141 |
1142 | /**
1143 | * This method is called each time the AppleTV updates the Screensaver photos
1144 | */
1145 | atv.onExecuteQuery = function (query, callback) {
1146 | var id = null;
1147 |
1148 | for (i = 0; i < query.filters.length; ++i) {
1149 | var filter = query.filters[i];
1150 | if (filter.property == 'id') {
1151 | id = filter.value;
1152 | break;
1153 | }
1154 | }
1155 |
1156 | var shuffle = query.shuffle; // boolean
1157 | var length = query.length;
1158 |
1159 | console.log('photoBatch execute query: id=' + id + ', shuffle=' + shuffle + ', length=' + length);
1160 |
1161 | // Making a request to the server to get a list of photos for the screensaver, based on the information in the query filters
1162 | var ajax = new ATVUtils.Ajax({
1163 | "url": "http://sample-web-server/sample-xml/images/sample/ScreenSaver.json",
1164 | "success": function (req) {
1165 | console.log(" --- successfully retrieved the list: --- " + req.responseText);
1166 | var ScreensaverPhotos = JSON.parse(req.responseText);
1167 | callback.success(ScreensaverPhotos);
1168 | },
1169 | "failure": function (error, req) {
1170 | console.log("We encountered and error: " + JSON.stringify(error));
1171 | }
1172 | })
1173 | }
1174 |
1175 | // On Screen views with fade animation
1176 | // ===== Here is the textview information =======
1177 | var TextViewController = (function () {
1178 | var __config = {},
1179 | __views = {};
1180 |
1181 | function SetConfig(property, value) {
1182 | if (property) {
1183 | __config[property] = value;
1184 | }
1185 | }
1186 |
1187 | function GetConfig(property) {
1188 | if (property) {
1189 | return __config[property];
1190 | } else {
1191 | return false;
1192 | }
1193 | }
1194 |
1195 | function SaveView(name, value) {
1196 | if (name) {
1197 | __views[name] = value;
1198 | }
1199 | }
1200 |
1201 | function GetView(name) {
1202 | if (name) {
1203 | return __views[name];
1204 | } else {
1205 | return false;
1206 | }
1207 | }
1208 |
1209 | function RemoveView(name) {
1210 | if (GetView(name)) {
1211 | delete __views[name];
1212 | }
1213 | }
1214 |
1215 | function HideView(name, timeIntervalSec) {
1216 | var animation = {
1217 | "type": "BasicAnimation",
1218 | "keyPath": "opacity",
1219 | "fromValue": 1,
1220 | "toValue": 0,
1221 | "duration": timeIntervalSec,
1222 | "removedOnCompletion": false,
1223 | "fillMode": "forwards",
1224 | "animationDidStop": function (finished) { console.log("Animation did finish? " + finished); }
1225 | },
1226 | viewContainer = GetView(name);
1227 |
1228 | console.log("Hiding view " + name + " : " + typeof (viewContainer) + " <--- ");
1229 | if (viewContainer) {
1230 | viewContainer.addAnimation(animation, name);
1231 | }
1232 | }
1233 |
1234 | function ShowView(name, timeIntervalSec) {
1235 | var animation = {
1236 | "type": "BasicAnimation",
1237 | "keyPath": "opacity",
1238 | "fromValue": 0,
1239 | "toValue": 1,
1240 | "duration": timeIntervalSec,
1241 | "removedOnCompletion": false,
1242 | "fillMode": "forwards",
1243 | "animationDidStop": function (finished) { console.log("Animation did finish? " + finished); }
1244 | },
1245 | viewContainer = GetView(name);
1246 |
1247 | console.log("Showing view " + name + " : " + typeof (viewContainer) + " <--- ");
1248 | if (viewContainer) {
1249 | viewContainer.addAnimation(animation, name);
1250 | }
1251 | }
1252 |
1253 | function __updateMessage() {
1254 | var messageView = GetConfig("messageView"),
1255 | seconds = GetConfig("numberOfSeconds");
1256 |
1257 | if (messageView) {
1258 | messageView.attributedString = {
1259 | "string": "We have been playing for " + seconds + " seconds.",
1260 | "attributes": {
1261 | "pointSize": 22.0,
1262 | "color": {
1263 | "red": 1,
1264 | "blue": 1,
1265 | "green": 1
1266 | }
1267 | }
1268 | }
1269 | SetConfig("numberOfSeconds", seconds + 1);
1270 | }
1271 | }
1272 |
1273 | function InitiateView(name) {
1274 | var viewContainer = new atv.View(),
1275 | message = new atv.TextView(),
1276 | screenFrame = atv.device.screenFrame
1277 | width = screenFrame.width,
1278 | height = screenFrame.height * 0.07;
1279 |
1280 | console.log("\nwidth: " + width + "\nheight: " + height + "\nscreenFrame: " + JSON.stringify(screenFrame));
1281 |
1282 |
1283 | // Setup the View container.
1284 | viewContainer.frame = {
1285 | "x": screenFrame.x,
1286 | "y": screenFrame.y + screenFrame.height - height,
1287 | "width": width,
1288 | "height": height
1289 | }
1290 |
1291 | viewContainer.backgroundColor = {
1292 | "red": 0.188,
1293 | "blue": 0.188,
1294 | "green": 0.188,
1295 | "alpha": 0.7
1296 | }
1297 |
1298 | viewContainer.alpha = 1;
1299 |
1300 | var topPadding = viewContainer.frame.height * 0.35,
1301 | horizontalPadding = viewContainer.frame.width * 0.05;
1302 |
1303 | // Setup the message frame
1304 | message.frame = {
1305 | "x": horizontalPadding,
1306 | "y": 0,
1307 | "width": viewContainer.frame.width - (2 * horizontalPadding),
1308 | "height": viewContainer.frame.height - topPadding
1309 | };
1310 |
1311 | // Save the initial number of seconds as 0
1312 | SetConfig("numberOfSeconds", 0);
1313 |
1314 | // Update the overlay message
1315 | var messageTimer = atv.setInterval(__updateMessage, 1000);
1316 | SetConfig("messageTimer", messageTimer)
1317 |
1318 | // Save the message to config
1319 | SetConfig("messageView", message)
1320 |
1321 | __updateMessage();
1322 |
1323 |
1324 | // Add the sub view
1325 | viewContainer.subviews = [message];
1326 |
1327 | // Paint the view on Screen.
1328 | console.log("pushing the image view to screen: ");
1329 | atv.player.overlay = viewContainer;
1330 |
1331 | console.log("Saving view to " + name + " : " + typeof (viewContainer) + " <--- ");
1332 | SaveView(name, viewContainer);
1333 | }
1334 |
1335 | return {
1336 | "initiateView": InitiateView,
1337 | "hideView": HideView,
1338 | "showView": ShowView,
1339 | "saveView": SaveView,
1340 | "getView": GetView,
1341 | "removeView": RemoveView,
1342 | "setConfig": SetConfig,
1343 | "getConfig": GetConfig
1344 | }
1345 | })();
1346 |
1347 | console.log("sample-xml application.js end");
1348 |
--------------------------------------------------------------------------------