-
Notifications
You must be signed in to change notification settings - Fork 0
/
server.go
284 lines (245 loc) · 7.26 KB
/
server.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
package plex
import (
"bufio"
"encoding/xml"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/jinzhu/gorm"
)
// CacheLifetime controls when a cached image will be refreshed in days
var CacheLifetime int
var cachePath = filepath.Join(".cache", "show")
// Host defines the data to be stored for server objects
type Host struct {
gorm.Model
Name string
Hostname string
Port int
Ssl bool
Token string
}
var (
tvShowFile = filepath.Join("config", "tvshows.txt")
)
// CreateURI assembles the URI for an API request
func CreateURI(server Host, path string) string {
if server.Ssl {
return fmt.Sprintf("https://%v:%v/%v", server.Hostname, server.Port, path)
}
return fmt.Sprintf("http://%v:%v/%v", server.Hostname, server.Port, path)
}
// SearchShow returns all episodes for a given TV Show
func SearchShow(server Host, title string) (Show, error) {
uri := CreateURI(server, fmt.Sprintf("search?type=2&query=%v", url.PathEscape(title)))
// log.Printf("Performing REST request to %q", uri)
resp, err := apiRequest("GET", uri, server.Token, nil)
if err != nil {
return Show{}, fmt.Errorf("error getting episodes for show %q from server %q: %v", title, server.Name, err)
}
defer resp.Body.Close() // nolint: errcheck
if resp.StatusCode != http.StatusOK {
return Show{}, fmt.Errorf("unexpected HTTP Response %q", resp.Status)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return Show{}, fmt.Errorf("error reading response %v", err)
}
results := SR{}
err = xml.Unmarshal(body, &results)
if err != nil {
return Show{}, fmt.Errorf("error parsing xml response: %v", err)
}
for _, x := range results.Directories {
if x.Name == title {
return x, nil
}
}
return Show{}, fmt.Errorf("no show found matching name %q", title)
}
// SR contains search results
type SR struct {
XMLName xml.Name `xml:"MediaContainer"`
Directories []Show `xml:"Directory"`
}
// Show defines the structure of a Plex TV Show
type Show struct {
ID int `xml:"ratingKey,attr"`
Name string `xml:"title,attr"`
EpisodeCount int `xml:"leafCount,attr"`
Thumbnail string `xml:"thumb,attr"`
Banner string `xml:"banner,attr"`
}
// ER contains episode results
type ER struct {
XMLName xml.Name `xml:"MediaContainer"`
Video []Episode `xml:"Video"`
}
// Episode defines the structure of a Plex TV Episode
type Episode struct {
ID int `xml:"ratingKey,attr"`
Name string `xml:"title,attr"`
Episode int `xml:"index,attr"`
Season int `xml:"parentIndex,attr"`
ViewCount int `xml:"viewCount,attr"`
LastWatched int `xml:"lastViewedAt,attr"`
}
// SyncWatchedTv synchronises the watched TV Shows
func SyncWatchedTv(source, destination Host) error {
log.Printf("Syncing watched Tv Shows from %q to %q", source.Name, destination.Name)
// Return all selected shows
ss, err := SelectedShows()
if err != nil {
return err
}
// For each show, enumerate all source and destination episodes
for _, s := range ss {
log.Printf("Processing show %q", s)
destShow, err := SearchShow(destination, s)
if err != nil {
log.Println(err)
continue
}
err = destShow.cacheImages(destination)
if err != nil {
log.Println(err)
}
dEps, err := allEpisodes(destination, destShow.ID)
if err != nil {
log.Println(err)
continue
}
srcShow, err := SearchShow(source, s)
if err != nil {
log.Println(err)
continue
}
sEps, err := allEpisodes(source, srcShow.ID)
if err != nil {
log.Println(err)
continue
}
for _, e := range sEps {
// If the local show is marked as watched check if the remote episode is watched
log.Printf("- Checking %v - Season %v, Episode %v", srcShow.Name, e.Season, e.Episode)
destEp, err := findEpisode(dEps, e.Season, e.Episode)
if err != nil {
log.Println(err)
continue
}
if e.ViewCount > 0 && destEp.ViewCount < 1 {
// Scrobble the episode on the remote server
err = scrobble(destination, destEp.ID)
if err != nil {
log.Printf("failed to scrobble episode. Error: %v", err)
continue
}
log.Printf("* Scrobbled on %q", destination.Name)
} else if destEp.ViewCount >= 1 {
log.Println("- Already scrobbled, skipping...")
} else {
log.Println("- Episode not yet watched, skipping...")
}
}
}
return nil
}
// SelectedShows returns the selected tv shows from the tvShowsFile
func SelectedShows() ([]string, error) {
file, err := os.Open(tvShowFile)
if err != nil {
return nil, fmt.Errorf("failed to open tvshows file %q", tvShowFile)
}
defer file.Close() // nolint: errcheck
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
sort.Strings(lines)
return lines, scanner.Err()
}
// allEpisodes returns all child episodes of a tv show regardless of the season they belong to
func allEpisodes(server Host, sID int) ([]Episode, error) {
uri := CreateURI(server, fmt.Sprintf("library/metadata/%v/allLeaves", sID))
resp, err := apiRequest("GET", uri, server.Token, nil)
if err != nil {
return []Episode{}, err
}
defer resp.Body.Close() // nolint: errcheck
if resp.StatusCode != http.StatusOK {
return []Episode{}, fmt.Errorf("unexpected HTTP Response %q", resp.Status)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return []Episode{}, fmt.Errorf("error reading response %v", err)
}
results := ER{}
err = xml.Unmarshal(body, &results)
if err != nil {
return []Episode{}, fmt.Errorf("error parsing xml response: %v", err)
}
return results.Video, nil
}
// findEpisode returns a single episode from a slice of Episodes based on the season and episode number
func findEpisode(eps []Episode, s, e int) (Episode, error) {
for _, i := range eps {
if i.Season == s && i.Episode == e {
return i, nil
}
}
return Episode{}, fmt.Errorf("could not find episode on destination server")
}
// scrobble marks an episode as watched
func scrobble(server Host, eID int) error {
uri := CreateURI(server, fmt.Sprintf(":/scrobble?key=%v&identifier=com.plexapp.plugins.library", eID))
resp, err := apiRequest("GET", uri, server.Token, nil)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected HTTP Response: %v", resp.Status)
}
return nil
}
// cacheImage downloads an image from the specified server to the cache location
func (s Show) cacheImages(server Host) error {
itemname := fmt.Sprintf("%s_thumb.jpg", s.Name)
fullpath := filepath.Join(cachePath, itemname)
// Check if file is already cached
if fs, err := os.Stat(fullpath); !os.IsNotExist(err) {
if !expired(fs) {
return nil
}
log.Println("Cached image is expired, will refresh")
}
uri := CreateURI(server, strings.TrimPrefix(s.Thumbnail, "/"))
resp, err := apiRequest("GET", uri, server.Token, nil)
if err != nil {
return err
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response %v", err)
}
err = os.MkdirAll(cachePath, os.ModePerm)
if err != nil {
return err
}
err = ioutil.WriteFile(fullpath, body, 0777)
if err != nil {
return err
}
log.Printf("- Cached banner image to path %q", fullpath)
return nil
}
func expired(fs os.FileInfo) bool {
return fs.ModTime().Before(time.Now().AddDate(0, 0, CacheLifetime*-1))
}