Compare commits

..

21 Commits

Author SHA1 Message Date
bel
47f2052955 resume, nocrash 2025-06-05 21:19:12 -06:00
bel
d69c068c86 byebye testdata 2025-06-05 21:09:20 -06:00
bel
0b148442c3 log pls 2025-06-05 21:07:02 -06:00
bel
06fefaca60 send it 2025-06-05 21:06:24 -06:00
bel
6da0ebb063 traversing 2025-06-05 20:13:56 -06:00
bel
ed79119f3f shuff 2025-06-05 19:20:49 -06:00
bel
66ebae34b0 shuffle 2025-06-05 19:20:02 -06:00
bel
839b7f8bdd unique deviceid 2025-06-05 19:15:50 -06:00
bel
5bdd1ee0dc login 2025-06-05 19:13:54 -06:00
bel
b7a4afd366 welp that didnt work use API to get+put instead 2025-06-04 20:49:39 -06:00
Bel LaPointe
2309633b30 ignore bin 2025-06-04 18:51:40 -06:00
bel
491bd4e976 cleanup 2025-06-04 18:50:00 -06:00
bel
21ec6f32d3 fix lowercase to num 2025-06-04 18:48:28 -06:00
Bel LaPointe
c463bd16d8 numeric uuids 2025-06-04 18:32:53 -06:00
Bel LaPointe
e21b316627 todo 2025-06-04 17:28:33 -06:00
Bel LaPointe
28b80f0b6b cp result to outd 2025-06-04 11:15:25 -06:00
Bel LaPointe
e543b119e8 forgetit csv 2025-06-04 11:11:17 -06:00
Bel LaPointe
80a8577f96 o that is kinda runnin 2025-06-04 10:35:44 -06:00
Bel LaPointe
5a343d80f8 welp at least it runs 2025-06-04 10:23:08 -06:00
Bel LaPointe
701b619d04 log order != exec order 2025-06-04 09:04:36 -06:00
Bel LaPointe
b088241a51 grrr i can cli insert select but neither sqlite lib does it 2025-06-04 09:03:47 -06:00
6 changed files with 243 additions and 130 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
**/*.sw* **/*.sw*
/jellyfin-user-clone

11
go.mod
View File

@@ -4,16 +4,23 @@ go 1.23.0
toolchain go1.23.9 toolchain go1.23.9
require modernc.org/sqlite v1.37.1 require (
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
modernc.org/sqlite v1.37.1 // indirect
)
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
gorm.io/gorm v1.25.7 // indirect
modernc.org/libc v1.65.7 // indirect modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

10
go.sum
View File

@@ -1,9 +1,17 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -21,6 +29,8 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=

349
main.go
View File

@@ -1,170 +1,265 @@
package main package main
import ( import (
"database/sql" "bytes"
"context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"log" "log"
"net/http"
"os" "os"
"path" "os/signal"
"slices" "syscall"
"strings" "time"
_ "modernc.org/sqlite"
)
const (
jellyDB = "jellyfin.db"
libraryDB = "library.db"
) )
func main() { func main() {
data := os.Args[1] ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
fromU := os.Args[2] defer can()
toU := os.Args[3]
_ = toU
workd, err := ioutil.TempDir(os.TempDir(), "jellyfin-user-clone.*") jellyFrom, err := NewJelly(ctx, "squeaky2x3", "aFJKcZ4fUuN9FZ", "5c1de748f61145a085f272aea527c759", "213abf9acbe84d9fb9c3b06bbe1eec3b")
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
log.Printf("from %+v", jellyFrom)
{ jellyTo, err := NewJelly(ctx, "belly", "qBentOcpHMUjhD", "497b212a22e34b54be091055edbe264d", "b71b931108ba4323b75b675871a7738f")
if err := cp(path.Join(data, jellyDB), path.Join(workd, jellyDB)); err != nil {
panic(err)
} else if err := cp(path.Join(data, libraryDB), path.Join(workd, libraryDB)); err != nil {
panic(err)
}
}
jellyfinDB, err := sql.Open("sqlite", path.Join(workd, jellyDB))
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
defer jellyfinDB.Close() log.Printf("to %+v", jellyTo)
fromUUID, err := SelectOne[string](jellyfinDB, `SELECT Id FROM Users WHERE Username = $1`, fromU) folders, err := jellyFrom.VirtualFolders(ctx)
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
fromID, err := SelectOne[int](jellyfinDB, `SELECT InternalId FROM Users WHERE Id = $1`, fromUUID) for len(folders) > 0 {
folder := folders[0]
folders = folders[1:]
items, err := jellyFrom.ListFolder(ctx, folder)
if err != nil { if err != nil {
panic(err) log.Fatal(err)
}
log.Println(fromU, fromUUID, fromID)
if n, err := SelectOne[int](jellyfinDB, `SELECT COUNT(*) FROM Users WHERE Username = $1`, toU); err != nil {
panic(err)
} else if n == 1 {
} else if err := Exec(jellyfinDB, `INSERT INTO Users () SELECT * FROM Users WHERE Id = $1`, fromUUID); err != nil {
panic(err)
} }
panic("not impl get toU, toUUID") for _, item := range items {
if item.IsFolder {
tables, err := Tables(jellyfinDB) folders = append([]FolderItem{item}, folders...)
if err != nil { } else if userData, err := jellyFrom.UserDataOf(ctx, item); err != nil {
panic(err) log.Fatal(err)
} } else if userData.PlayCount == 0 && userData.PlaybackPositionTicks == 0 {
} else if userDataB, err := jellyTo.UserDataOf(ctx, item); err != nil {
for _, table := range tables { log.Fatal(err)
columns, err := Columns(jellyfinDB, table) } else if userDataB == userData.Plus(userDataB) {
if err != nil { log.Printf("skipping noop %+v", userData)
panic(err) } else if err := jellyTo.SetUserData(ctx, userData.Plus(userDataB)); err != nil {
} log.Fatalf("failed to set user data of %+v: %v", userData, err)
log.Println(table, columns)
userColumns := slices.DeleteFunc(slices.Clone(columns), func(s string) bool {
return !slices.Contains([]string{
"user",
"userid",
}, strings.ToLower(s))
})
if len(userColumns) == 0 {
continue
}
for _, column := range userColumns {
if n, err := SelectOne[int](jellyfinDB, fmt.Sprintf(`SELECT COUNT(*) FROM %q WHERE %q = $1`, table, column), fromID); err != nil {
panic(err)
} else if n > 0 {
notThisColumn := strings.Join(slices.DeleteFunc(slices.Clone(columns), func(s string) bool { return s == column }), ", ")
if err := Exec(jellyfinDB, fmt.Sprintf(`INSERT INTO %q (%q, %s) SELECT $2, %s FROM %q WHERE %q = $1`, table, column, notThisColumn, notThisColumn, table, column), fromID, toID); err != nil {
panic(err)
}
} else if n, err := SelectOne[int](jellyfinDB, fmt.Sprintf(`SELECT COUNT(*) FROM %q WHERE %q = $1`, table, column), fromUUID); err != nil {
panic(err)
} else if n > 0 {
panic("not impl: col is uuid")
} }
} }
log.Println(table, userColumns)
panic("not impl")
} }
} }
func cp(from, to string) error { type Jelly struct {
fromF, err := os.Open(from) u string
if err != nil { apitoken string
return err accesstoken string
}
defer fromF.Close()
toF, err := os.Create(to)
if err != nil {
return err
}
defer toF.Close()
_, err = io.Copy(toF, fromF)
return err
} }
func Tables(db *sql.DB) ([]string, error) { func NewJelly(ctx context.Context, u, p, apitoken, optAccessToken string) (Jelly, error) {
return Select[string](jellyfinDB, `SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`) jelly := Jelly{u: u, apitoken: apitoken, accesstoken: optAccessToken}
}
func Columns(db *sql.DB, table string) ([]string, error) { if optAccessToken != "" {
return Select[string](jellyfinDB, `SELECT name FROM PRAGMA_TABLE_INFO($1)`, table) } else if accessToken, err := jelly.Login(ctx, p); err != nil {
} return Jelly{}, err
} else {
func Exec(db *sql.DB, q string, args ...any) error { jelly.accesstoken = accessToken
_, err := db.Exec(q, args...)
return err
}
func SelectOne[T any](db *sql.DB, q string, args ...any) (T, error) {
var some T
results, err := Select[T](db, q, args...)
if err != nil {
return some, err
} }
if len(results) != 1 { return jelly, nil
return some, fmt.Errorf("expected 1 result but got %d (%+v)", len(results), results)
}
return results[0], nil
} }
func Select[T any](db *sql.DB, q string, args ...any) ([]T, error) { type FolderItem struct {
rows, err := db.Query(q, args...) 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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() 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
}
results := []T{} // get+set item via /UserItems/{itemId}/UserData
for rows.Next() {
var some T type UserData struct {
if err := rows.Scan(&some); err != nil { 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 return nil, err
} }
results = append(results, some)
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
} }
return results, rows.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")
} }

BIN
testdata/jellyfin.db vendored

Binary file not shown.

BIN
testdata/library.db vendored

Binary file not shown.