266 lines
7.0 KiB
Go
266 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
func main() {
|
|
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
|
|
defer can()
|
|
|
|
jellyFrom, err := NewJelly(ctx, "squeaky2x3", "aFJKcZ4fUuN9FZ", "5c1de748f61145a085f272aea527c759", "213abf9acbe84d9fb9c3b06bbe1eec3b")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
log.Printf("from %+v", jellyFrom)
|
|
|
|
jellyTo, err := NewJelly(ctx, "belly", "qBentOcpHMUjhD", "497b212a22e34b54be091055edbe264d", "b71b931108ba4323b75b675871a7738f")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
log.Printf("to %+v", jellyTo)
|
|
|
|
folders, err := jellyFrom.VirtualFolders(ctx)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
for len(folders) > 0 {
|
|
folder := folders[0]
|
|
folders = folders[1:]
|
|
|
|
items, err := jellyFrom.ListFolder(ctx, folder)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
for _, item := range items {
|
|
if item.IsFolder {
|
|
folders = append([]FolderItem{item}, folders...)
|
|
} else if userData, err := jellyFrom.UserDataOf(ctx, item); err != nil {
|
|
log.Fatal(err)
|
|
} else if userData.PlayCount == 0 && userData.PlaybackPositionTicks == 0 {
|
|
} else if userDataB, err := jellyTo.UserDataOf(ctx, item); err != nil {
|
|
log.Fatal(err)
|
|
} else if userDataB == userData.Plus(userDataB) {
|
|
log.Printf("skipping noop %+v", userData)
|
|
} else if err := jellyTo.SetUserData(ctx, userData.Plus(userDataB)); err != nil {
|
|
log.Fatalf("failed to set user data of %+v: %v", userData, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type Jelly struct {
|
|
u string
|
|
apitoken string
|
|
accesstoken string
|
|
}
|
|
|
|
func NewJelly(ctx context.Context, u, p, apitoken, optAccessToken string) (Jelly, error) {
|
|
jelly := Jelly{u: u, apitoken: apitoken, accesstoken: optAccessToken}
|
|
|
|
if optAccessToken != "" {
|
|
} else if accessToken, err := jelly.Login(ctx, p); err != nil {
|
|
return Jelly{}, err
|
|
} else {
|
|
jelly.accesstoken = accessToken
|
|
}
|
|
|
|
return jelly, nil
|
|
}
|
|
|
|
type FolderItem struct {
|
|
Name string
|
|
Id string
|
|
IsFolder bool
|
|
Parent *FolderItem
|
|
}
|
|
|
|
func (jelly Jelly) ListFolder(ctx context.Context, folder FolderItem) ([]FolderItem, error) {
|
|
resp, err := jelly.do(ctx, http.MethodGet, fmt.Sprintf("/Items?parentId=%s", folder.Id), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a := resp.(map[string]any)["Items"].([]any)
|
|
result := make([]FolderItem, len(a))
|
|
for i := range a {
|
|
m := a[i].(map[string]any)
|
|
name, _ := m["Name"].(string)
|
|
id, _ := m["Id"].(string)
|
|
isFolder, _ := m["IsFolder"].(bool)
|
|
result[i] = FolderItem{
|
|
Name: name,
|
|
Id: id,
|
|
IsFolder: isFolder,
|
|
Parent: &folder,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// get+set item via /UserItems/{itemId}/UserData
|
|
|
|
type UserData struct {
|
|
IsFavorite bool
|
|
ItemId string
|
|
Key string
|
|
PlayCount float64
|
|
PlaybackPositionTicks float64
|
|
Played bool
|
|
FolderItem FolderItem `json:",omitempty"`
|
|
}
|
|
|
|
func (this UserData) Plus(that UserData) UserData {
|
|
this.IsFavorite = this.IsFavorite || that.IsFavorite
|
|
//this.ItemId
|
|
//this.Key
|
|
if this.PlayCount < that.PlayCount {
|
|
this.PlayCount = that.PlayCount
|
|
}
|
|
if this.PlaybackPositionTicks < that.PlaybackPositionTicks {
|
|
this.PlaybackPositionTicks = that.PlaybackPositionTicks
|
|
}
|
|
this.Played = this.Played || that.Played
|
|
return this
|
|
}
|
|
|
|
func (jelly Jelly) SetUserData(ctx context.Context, userData UserData) error {
|
|
fi := userData.FolderItem
|
|
if os.Getenv("DRY_RUN") != "false" {
|
|
b, _ := json.Marshal(userData)
|
|
log.Printf("SET %s", b)
|
|
return nil
|
|
}
|
|
userData.FolderItem = FolderItem{}
|
|
b, _ := json.Marshal(userData)
|
|
log.Printf("SET %s", b)
|
|
_, err := jelly.do(ctx, http.MethodPost, "/UserItems/"+fi.Id+"/UserData", userData)
|
|
return err
|
|
}
|
|
|
|
func (jelly Jelly) UserDataOf(ctx context.Context, folderItem FolderItem) (UserData, error) {
|
|
resp, err := jelly.do(ctx, http.MethodGet, "/UserItems/"+folderItem.Id+"/UserData", nil)
|
|
if err != nil {
|
|
return UserData{}, err
|
|
}
|
|
return UserData{
|
|
IsFavorite: resp.(map[string]any)["IsFavorite"].(bool),
|
|
ItemId: resp.(map[string]any)["ItemId"].(string),
|
|
Key: resp.(map[string]any)["Key"].(string),
|
|
PlayCount: resp.(map[string]any)["PlayCount"].(float64),
|
|
PlaybackPositionTicks: resp.(map[string]any)["PlaybackPositionTicks"].(float64),
|
|
Played: resp.(map[string]any)["Played"].(bool),
|
|
FolderItem: folderItem,
|
|
}, nil
|
|
}
|
|
|
|
func (jelly Jelly) VirtualFolders(ctx context.Context) ([]FolderItem, error) {
|
|
resp, err := jelly.do(ctx, http.MethodGet, "/Library/VirtualFolders", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
a := resp.([]any)
|
|
result := make([]FolderItem, len(a))
|
|
for i, v := range a {
|
|
m := v.(map[string]any)
|
|
result[i] = FolderItem{
|
|
Id: m["ItemId"].(string),
|
|
Name: m["Name"].(string),
|
|
IsFolder: true,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (jelly Jelly) Login(ctx context.Context, p string) (string, error) {
|
|
resp, err := jelly.post(ctx, "/Users/AuthenticateByName", map[string]string{
|
|
"Username": jelly.u,
|
|
"Pw": p,
|
|
}, "Authorization", jelly._authHeader(jelly.apitoken))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
m, _ := resp.(map[string]any)
|
|
if m == nil {
|
|
return "", fmt.Errorf("no map response")
|
|
}
|
|
token, _ := m["AccessToken"]
|
|
if token == nil {
|
|
return "", fmt.Errorf("no .AccessToken response")
|
|
}
|
|
s, _ := token.(string)
|
|
return s, nil
|
|
}
|
|
|
|
func (jelly Jelly) post(ctx context.Context, path string, body any, headers ...string) (any, error) {
|
|
return jelly.do(ctx, http.MethodPost, path, body, headers...)
|
|
}
|
|
|
|
func (jelly Jelly) do(ctx context.Context, method, path string, body any, headers ...string) (any, error) {
|
|
resp, err := jelly._do(ctx, method, path, body, headers...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result any
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
return result, err
|
|
}
|
|
|
|
func (jelly Jelly) _do(ctx context.Context, method, path string, body any, headers ...string) (*http.Response, error) {
|
|
t := http.Transport{
|
|
DisableKeepAlives: true,
|
|
}
|
|
c := http.Client{
|
|
Timeout: time.Minute,
|
|
Transport: &t,
|
|
}
|
|
|
|
b, _ := json.Marshal(body)
|
|
req, err := http.NewRequest(method, fmt.Sprintf("https://jellyfin.home.blapointe.com%s", path), bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", jelly.authHeader())
|
|
req = req.WithContext(ctx)
|
|
for i := 0; i+1 < len(headers); i += 2 {
|
|
req.Header.Set(headers[i], headers[i+1])
|
|
}
|
|
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
b, _ = io.ReadAll(resp.Body)
|
|
resp.Body = io.NopCloser(bytes.NewReader(b))
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("(%d) %s", resp.StatusCode, b)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (jelly Jelly) authHeader() string {
|
|
return jelly._authHeader(jelly.accesstoken)
|
|
}
|
|
|
|
func (jelly Jelly) _authHeader(token string) string {
|
|
return fmt.Sprintf("MediaBrowser Token=%q, Client=%q, Version=%q, DeviceId=%q, Device=%q", token, "client", "version", "deviceId"+jelly.u, "device")
|
|
}
|