Compare commits
3 Commits
main
...
0c811eb404
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c811eb404 | ||
|
|
e74a398e1e | ||
|
|
4d1892f54b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1 @@
|
|||||||
**/*.sw*
|
**/*.sw*
|
||||||
/jellyfin-user-clone
|
|
||||||
|
|||||||
548
main.go
548
main.go
@@ -1,265 +1,417 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"database/sql"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"path"
|
||||||
"syscall"
|
"slices"
|
||||||
"time"
|
"strings"
|
||||||
|
|
||||||
|
//_ "github.com/glebarez/sqlite"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
jellyDB = "jellyfin.db"
|
||||||
|
libDB = "library.db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
|
data := os.Args[1]
|
||||||
defer can()
|
fromU := os.Args[2]
|
||||||
|
toU := os.Args[3]
|
||||||
|
outd := os.Args[4]
|
||||||
|
|
||||||
jellyFrom, err := NewJelly(ctx, "squeaky2x3", "aFJKcZ4fUuN9FZ", "5c1de748f61145a085f272aea527c759", "213abf9acbe84d9fb9c3b06bbe1eec3b")
|
workd, err := ioutil.TempDir(os.TempDir(), "jellyfin-user-clone.*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
log.Printf("from %+v", jellyFrom)
|
defer func() {
|
||||||
|
os.RemoveAll(workd)
|
||||||
|
}()
|
||||||
|
|
||||||
jellyTo, err := NewJelly(ctx, "belly", "qBentOcpHMUjhD", "497b212a22e34b54be091055edbe264d", "b71b931108ba4323b75b675871a7738f")
|
{
|
||||||
|
if err := cp(path.Join(data, jellyDB), path.Join(workd, jellyDB)); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
} else if err := cp(path.Join(data, libDB), path.Join(workd, libDB)); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jellyfinDB, err := sql.Open("sqlite", path.Join(workd, jellyDB))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
log.Printf("to %+v", jellyTo)
|
defer jellyfinDB.Close()
|
||||||
|
|
||||||
folders, err := jellyFrom.VirtualFolders(ctx)
|
libraryDB, err := sql.Open("sqlite", path.Join(workd, libDB))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
for len(folders) > 0 {
|
defer libraryDB.Close()
|
||||||
folder := folders[0]
|
|
||||||
folders = folders[1:]
|
|
||||||
|
|
||||||
items, err := jellyFrom.ListFolder(ctx, folder)
|
fromUUID, err := SelectOne[string](jellyfinDB, `SELECT Id FROM Users WHERE Username = $1`, fromU)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
fromID, err := SelectOne[int](jellyfinDB, `SELECT InternalId FROM Users WHERE Id = $1`, fromUUID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
log.Println(fromU, fromUUID, fromID)
|
||||||
|
|
||||||
|
if n, err := SelectOne[int](jellyfinDB, `SELECT COUNT(*) FROM Users WHERE Username = $1`, toU); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
} else if n != 1 {
|
||||||
|
log.Println("creating user", toU, "from", fromU)
|
||||||
|
|
||||||
|
nextID, err := SelectOne[int](libraryDB, `SELECT COALESCE(MAX(userId), 0)+1 FROM UserDatas`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatalf("failed to get max userid ever: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range items {
|
if err := CloneForColumn(jellyfinDB, `Users`, `Username`, fromU, toU, map[string]any{`InternalId`: nextID}); err != nil {
|
||||||
if item.IsFolder {
|
log.Fatalf("%v", err)
|
||||||
folders = append([]FolderItem{item}, folders...)
|
}
|
||||||
} else if userData, err := jellyFrom.UserDataOf(ctx, item); err != nil {
|
|
||||||
log.Fatal(err)
|
if n, err := SelectOne[int](jellyfinDB, `SELECT COUNT(*) FROM Users WHERE Username = $1 AND InternalId = $2`, toU, nextID); err != nil {
|
||||||
} else if userData.PlayCount == 0 && userData.PlaybackPositionTicks == 0 {
|
log.Fatalf("%v", err)
|
||||||
} else if userDataB, err := jellyTo.UserDataOf(ctx, item); err != nil {
|
} else if n != 1 {
|
||||||
log.Fatal(err)
|
log.Fatalf("still no username=%q after insert", toU)
|
||||||
} else if userDataB == userData.Plus(userDataB) {
|
}
|
||||||
log.Printf("skipping noop %+v", userData)
|
} else {
|
||||||
} else if err := jellyTo.SetUserData(ctx, userData.Plus(userDataB)); err != nil {
|
log.Println("user", toU, "already exists")
|
||||||
log.Fatalf("failed to set user data of %+v: %v", userData, err)
|
}
|
||||||
|
|
||||||
|
toUUID, err := SelectOne[string](jellyfinDB, `SELECT Id FROM Users WHERE Username = $1`, toU)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
toID, err := SelectOne[int](jellyfinDB, `SELECT InternalId FROM Users WHERE Id = $1`, toUUID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
log.Println(toU, toUUID, toID)
|
||||||
|
|
||||||
|
for _, db := range []*sql.DB{jellyfinDB, libraryDB} {
|
||||||
|
tables, err := Tables(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
columns, err := Columns(db, table)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
log.Println(table, column, "...")
|
||||||
|
|
||||||
|
if n, err := SelectOne[int](db, fmt.Sprintf(`SELECT COUNT(*) FROM %q WHERE %q = $1`, table, column), fromID); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
if err := CloneForColumn(db, table, column, fromID, toID, nil); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
} else if n, err := SelectOne[int](db, fmt.Sprintf(`SELECT COUNT(*) FROM %q WHERE %q = $1`, table, column), fromUUID); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
if err := CloneForColumn(db, table, column, fromUUID, toUUID, nil); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
type Jelly struct {
|
entries, err := os.ReadDir(workd)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
a := resp.(map[string]any)["Items"].([]any)
|
for _, entry := range entries {
|
||||||
result := make([]FolderItem, len(a))
|
os.MkdirAll(path.Dir(path.Join(path.Join(outd, entry.Name()))), os.ModePerm)
|
||||||
for i := range a {
|
if err := cp(path.Join(workd, entry.Name()), path.Join(outd, entry.Name())); err != nil {
|
||||||
m := a[i].(map[string]any)
|
log.Fatal(err)
|
||||||
name, _ := m["Name"].(string)
|
} else if err := os.Remove(path.Join(workd, entry.Name())); err != nil {
|
||||||
id, _ := m["Id"].(string)
|
log.Fatal(err)
|
||||||
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
|
func CloneForColumn[T any](db *sql.DB, table, column string, from, to T, fixed map[string]any) error {
|
||||||
|
columns, err := Columns(db, table)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type UserData struct {
|
uniqueIntColumns, err := UniqueTypeColumns(db, "INTEGER", table)
|
||||||
IsFavorite bool
|
if err != nil {
|
||||||
ItemId string
|
return err
|
||||||
Key string
|
}
|
||||||
PlayCount float64
|
uniqueTextColumns, err := UniqueTypeColumns(db, "TEXT", table)
|
||||||
PlaybackPositionTicks float64
|
if err != nil {
|
||||||
Played bool
|
return err
|
||||||
FolderItem FolderItem `json:",omitempty"`
|
}
|
||||||
|
|
||||||
|
log.Printf("unique text columns %+v, unique int columns %+v", uniqueTextColumns, uniqueIntColumns)
|
||||||
|
|
||||||
|
extraUniques := []string{}
|
||||||
|
for k := range fixed {
|
||||||
|
extraUniques = append(extraUniques, k)
|
||||||
|
}
|
||||||
|
omit := append(append(extraUniques, uniqueIntColumns...), uniqueTextColumns...)
|
||||||
|
notTheseColumns := slices.DeleteFunc(slices.Clone(columns), func(s string) bool {
|
||||||
|
return s == column || slices.Contains(omit, s) || slices.Contains(extraUniques, s)
|
||||||
|
})
|
||||||
|
for i := range notTheseColumns {
|
||||||
|
notTheseColumns[i] = fmt.Sprintf("%q", notTheseColumns[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
notNullTextColumns, err := NotNullTextColumns(db, table)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
uuidGenColumns := slices.DeleteFunc(notNullTextColumns, func(s string) bool {
|
||||||
|
return s == column || !slices.Contains(omit, s) || slices.Contains(extraUniques, s)
|
||||||
|
})
|
||||||
|
for i := range uuidGenColumns {
|
||||||
|
uuidGenColumns[i] = fmt.Sprintf("%q", uuidGenColumns[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
notNullIntColumns, err := NotNullIntColumns(db, table)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
incrGenColumns := slices.DeleteFunc(notNullIntColumns, func(s string) bool {
|
||||||
|
return s == column || !slices.Contains(omit, s) || slices.Contains(extraUniques, s)
|
||||||
|
})
|
||||||
|
for i := range incrGenColumns {
|
||||||
|
incrGenColumns[i] = fmt.Sprintf("%q", incrGenColumns[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ForEach(db, func(args []any) error {
|
||||||
|
selectMaxes := ""
|
||||||
|
for _, col := range incrGenColumns {
|
||||||
|
selectMaxes += fmt.Sprintf(`(SELECT COALESCE(MAX(%s)+1, 1) FROM %q), `, col, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := []any{}
|
||||||
|
for _ = range uuidGenColumns {
|
||||||
|
values = append(values, fmt.Sprintf("'%s'", guid()))
|
||||||
|
}
|
||||||
|
for _, arg := range args {
|
||||||
|
values = append(values, *(arg.(*any)))
|
||||||
|
}
|
||||||
|
for _, k := range extraUniques {
|
||||||
|
values = append(values, fixed[k])
|
||||||
|
}
|
||||||
|
values = append(values, to)
|
||||||
|
|
||||||
|
q := fmt.Sprintf(
|
||||||
|
`INSERT INTO %q (%s, %q) VALUES (%s %s)`,
|
||||||
|
table, strings.Join(append(append(incrGenColumns, append(uuidGenColumns, notTheseColumns...)...), extraUniques...), ", "), column,
|
||||||
|
selectMaxes, strings.Join(slices.Repeat([]string{"?"}, len(values)), ", "),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.Printf("INSERT | %s (%+v)", q, values)
|
||||||
|
return Exec(db, q, values...)
|
||||||
|
}, fmt.Sprintf(`SELECT %s FROM %q WHERE %q = $1`, strings.Join(notTheseColumns, ", "), table, column), from)
|
||||||
|
/*
|
||||||
|
q := fmt.Sprintf(
|
||||||
|
`INSERT INTO %q (%q, %s) SELECT $1, %s %s %s FROM %q WHERE %q = $2`,
|
||||||
|
table, column, strings.Join(append(incrGenColumns, append(uuidGenColumns, notTheseColumns...)...), ", "),
|
||||||
|
func() string {
|
||||||
|
incrs := make([]string, len(incrGenColumns))
|
||||||
|
for i := range incrs {
|
||||||
|
incrs[i] = fmt.Sprintf(`(SELECT COALESCE(MAX(%s), 1) FROM %q)`, incrGenColumns[i], table)
|
||||||
|
}
|
||||||
|
s := strings.Join(incrs, ", ")
|
||||||
|
if len(incrs) > 0 {
|
||||||
|
s += ", "
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}(),
|
||||||
|
func() string {
|
||||||
|
uuids := make([]string, len(uuidGenColumns))
|
||||||
|
for i := range uuids {
|
||||||
|
uuids[i] = fmt.Sprintf("'%s'", guid())
|
||||||
|
}
|
||||||
|
s := strings.Join(uuids, ", ")
|
||||||
|
if len(uuids) > 0 {
|
||||||
|
s += ", "
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}(), strings.Join(notTheseColumns, ", "), table, column,
|
||||||
|
)
|
||||||
|
log.Printf("EXEC | %s (%v, %v)", q, to, from)
|
||||||
|
return Insert(db, q, to, from)
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
func (this UserData) Plus(that UserData) UserData {
|
func ForEach(db *sql.DB, cb func([]any) error, q string, args ...any) error {
|
||||||
this.IsFavorite = this.IsFavorite || that.IsFavorite
|
rows, err := db.Query(q, args...)
|
||||||
//this.ItemId
|
if err != nil {
|
||||||
//this.Key
|
return err
|
||||||
if this.PlayCount < that.PlayCount {
|
|
||||||
this.PlayCount = that.PlayCount
|
|
||||||
}
|
}
|
||||||
if this.PlaybackPositionTicks < that.PlaybackPositionTicks {
|
defer rows.Close()
|
||||||
this.PlaybackPositionTicks = that.PlaybackPositionTicks
|
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
this.Played = this.Played || that.Played
|
cols := make([]any, len(columns))
|
||||||
return this
|
|
||||||
|
for rows.Next() {
|
||||||
|
for i := range cols {
|
||||||
|
var a any
|
||||||
|
cols[i] = &a
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Scan(cols...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cb(cols); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (jelly Jelly) SetUserData(ctx context.Context, userData UserData) error {
|
func cp(from, to string) error {
|
||||||
fi := userData.FolderItem
|
fromF, err := os.Open(from)
|
||||||
if os.Getenv("DRY_RUN") != "false" {
|
if err != nil {
|
||||||
b, _ := json.Marshal(userData)
|
return err
|
||||||
log.Printf("SET %s", b)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
userData.FolderItem = FolderItem{}
|
defer fromF.Close()
|
||||||
b, _ := json.Marshal(userData)
|
|
||||||
log.Printf("SET %s", b)
|
toF, err := os.Create(to)
|
||||||
_, err := jelly.do(ctx, http.MethodPost, "/UserItems/"+fi.Id+"/UserData", userData)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer toF.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(toF, fromF)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (jelly Jelly) UserDataOf(ctx context.Context, folderItem FolderItem) (UserData, error) {
|
func Tables(db *sql.DB) ([]string, error) {
|
||||||
resp, err := jelly.do(ctx, http.MethodGet, "/UserItems/"+folderItem.Id+"/UserData", nil)
|
return Select[string](db, `SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
|
||||||
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) {
|
func Columns(db *sql.DB, table string) ([]string, error) {
|
||||||
resp, err := jelly.do(ctx, http.MethodGet, "/Library/VirtualFolders", nil)
|
return Select[string](db, `SELECT name FROM PRAGMA_TABLE_INFO($1)`, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NotNullTextColumns(db *sql.DB, table string) ([]string, error) {
|
||||||
|
return Select[string](db, `SELECT name FROM PRAGMA_TABLE_INFO($1) WHERE "notnull" = 1 AND "type" = 'TEXT'`, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NotNullIntColumns(db *sql.DB, table string) ([]string, error) {
|
||||||
|
return Select[string](db, `SELECT name FROM PRAGMA_TABLE_INFO($1) WHERE "notnull" = 1 AND "type" = 'INTEGER'`, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UniqueTypeColumns(db *sql.DB, t, table string) ([]string, error) {
|
||||||
|
pks, err := Select[string](db, `SELECT name FROM PRAGMA_TABLE_INFO($1) WHERE "pk" AND "type" = $2`, table, t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
a := resp.([]any)
|
idxes, err := Select[string](db, `SELECT name AS idx_name FROM PRAGMA_INDEX_LIST($1) WHERE "unique"`, table)
|
||||||
result := make([]FolderItem, len(a))
|
if err != nil {
|
||||||
for i, v := range a {
|
return nil, err
|
||||||
m := v.(map[string]any)
|
}
|
||||||
result[i] = FolderItem{
|
uniqueIdxCols := []string{}
|
||||||
Id: m["ItemId"].(string),
|
for _, idx := range idxes {
|
||||||
Name: m["Name"].(string),
|
cols, err := Select[string](db, `SELECT name FROM PRAGMA_INDEX_INFO($1)`, idx)
|
||||||
IsFolder: true,
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(cols) > 1 {
|
||||||
|
log.Printf("not impl: compound unique indexes like %s.%s's %+v", table, idx, cols)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
col := cols[0]
|
||||||
|
|
||||||
|
if n, err := SelectOne[int](db, `SELECT COUNT(*) FROM PRAGMA_TABLE_INFO($1) WHERE "name" = $2 AND "type" = $3`, table, col, t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if n > 0 {
|
||||||
|
uniqueIdxCols = append(uniqueIdxCols, col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
|
||||||
|
cols := append(pks, uniqueIdxCols...)
|
||||||
|
slices.Sort(cols)
|
||||||
|
cols = slices.Compact(cols)
|
||||||
|
return cols, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (jelly Jelly) Login(ctx context.Context, p string) (string, error) {
|
func Insert(db *sql.DB, q string, args ...any) error {
|
||||||
resp, err := jelly.post(ctx, "/Users/AuthenticateByName", map[string]string{
|
_, err := db.Exec(q, args...)
|
||||||
"Username": jelly.u,
|
return err
|
||||||
"Pw": p,
|
}
|
||||||
}, "Authorization", jelly._authHeader(jelly.apitoken))
|
|
||||||
|
func Exec(db *sql.DB, q string, args ...any) error {
|
||||||
|
_, 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 {
|
if err != nil {
|
||||||
return "", err
|
return some, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m, _ := resp.(map[string]any)
|
if len(results) != 1 {
|
||||||
if m == nil {
|
return some, fmt.Errorf("expected 1 result but got %d (%+v)", len(results), results)
|
||||||
return "", fmt.Errorf("no map response")
|
|
||||||
}
|
}
|
||||||
token, _ := m["AccessToken"]
|
|
||||||
if token == nil {
|
return results[0], 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) {
|
func Select[T any](db *sql.DB, q string, args ...any) ([]T, error) {
|
||||||
return jelly.do(ctx, http.MethodPost, path, body, headers...)
|
rows, err := db.Query(q, args...)
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var result any
|
defer rows.Close()
|
||||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
return result, err
|
results := []T{}
|
||||||
|
for rows.Next() {
|
||||||
|
var some T
|
||||||
|
if err := rows.Scan(&some); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, some)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (jelly Jelly) _do(ctx context.Context, method, path string, body any, headers ...string) (*http.Response, error) {
|
func guid() string {
|
||||||
t := http.Transport{
|
s := []byte(uuid.New().String())
|
||||||
DisableKeepAlives: true,
|
for i := range s {
|
||||||
|
if ('A' <= s[i] && s[i] <= 'Z') || ('a' <= s[i] && s[i] <= 'z') {
|
||||||
|
s[i] = '0' + byte(int(s[i])%10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c := http.Client{
|
return strings.ToUpper(string(s))
|
||||||
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
Normal file
BIN
testdata/jellyfin.db
vendored
Normal file
Binary file not shown.
BIN
testdata/library.db
vendored
Executable file
BIN
testdata/library.db
vendored
Executable file
Binary file not shown.
Reference in New Issue
Block a user