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") }