Rewrite HomeKit pairing API
This commit is contained in:
+18
-2
@@ -50,7 +50,8 @@ func Init() {
|
|||||||
HandleFunc("api/exit", exitHandler)
|
HandleFunc("api/exit", exitHandler)
|
||||||
|
|
||||||
// ensure we can listen without errors
|
// ensure we can listen without errors
|
||||||
listener, err := net.Listen("tcp", cfg.Mod.Listen)
|
var err error
|
||||||
|
ln, err = net.Listen("tcp", cfg.Mod.Listen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("[api] listen")
|
log.Fatal().Err(err).Msg("[api] listen")
|
||||||
return
|
return
|
||||||
@@ -75,7 +76,7 @@ func Init() {
|
|||||||
go func() {
|
go func() {
|
||||||
s := http.Server{}
|
s := http.Server{}
|
||||||
s.Handler = Handler
|
s.Handler = Handler
|
||||||
if err = s.Serve(listener); err != nil {
|
if err = s.Serve(ln); err != nil {
|
||||||
log.Fatal().Err(err).Msg("[api] serve")
|
log.Fatal().Err(err).Msg("[api] serve")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -111,6 +112,13 @@ func Init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Port() int {
|
||||||
|
if ln == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return ln.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MimeJSON = "application/json"
|
MimeJSON = "application/json"
|
||||||
MimeText = "text/plain"
|
MimeText = "text/plain"
|
||||||
@@ -192,6 +200,7 @@ func middlewareCORS(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ln net.Listener
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|
||||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -216,6 +225,7 @@ func exitHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
type Source struct {
|
type Source struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
Info string `json:"info,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -233,3 +243,9 @@ func ResponseSources(w http.ResponseWriter, sources []*Source) {
|
|||||||
}
|
}
|
||||||
ResponseJSON(w, response)
|
ResponseJSON(w, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Error(w http.ResponseWriter, err error) {
|
||||||
|
log.Error().Err(err).Caller(1).Send()
|
||||||
|
|
||||||
|
http.Error(w, err.Error(), http.StatusInsufficientStorage)
|
||||||
|
}
|
||||||
|
|||||||
+20
-1
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -11,9 +12,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "1.6.2"
|
var Version = "1.6.2"
|
||||||
@@ -81,6 +82,8 @@ func Init() {
|
|||||||
modules = cfg.Mod
|
modules = cfg.Mod
|
||||||
|
|
||||||
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
|
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
|
migrateStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLogger(format string, level string) zerolog.Logger {
|
func NewLogger(format string, level string) zerolog.Logger {
|
||||||
@@ -123,6 +126,22 @@ func GetLogger(module string) zerolog.Logger {
|
|||||||
return log.Logger
|
return log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PatchConfig(key string, value any, path ...string) error {
|
||||||
|
if ConfigPath == "" {
|
||||||
|
return errors.New("config file disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty config is OK
|
||||||
|
b, _ := os.ReadFile(ConfigPath)
|
||||||
|
|
||||||
|
b, err := yaml.Patch(b, key, value, path...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(ConfigPath, b, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
// internal
|
// internal
|
||||||
|
|
||||||
type Config []string
|
type Config []string
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func migrateStore() {
|
||||||
|
const name = "go2rtc.json"
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(name)
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var store struct {
|
||||||
|
Streams map[string]string `json:"streams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &store); err != nil {
|
||||||
|
log.Warn().Err(err).Caller().Send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, url := range store.Streams {
|
||||||
|
if err := PatchConfig(id, url, "streams"); err != nil {
|
||||||
|
log.Warn().Err(err).Caller().Send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Remove(name)
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
const name = "go2rtc.json"
|
|
||||||
|
|
||||||
var store map[string]any
|
|
||||||
|
|
||||||
func load() {
|
|
||||||
data, _ := os.ReadFile(name)
|
|
||||||
if data != nil {
|
|
||||||
if err := json.Unmarshal(data, &store); err != nil {
|
|
||||||
// TODO: log
|
|
||||||
log.Warn().Err(err).Msg("[app] read storage")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if store == nil {
|
|
||||||
store = make(map[string]any)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func save() error {
|
|
||||||
data, err := json.Marshal(store)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.WriteFile(name, data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRaw(key string) any {
|
|
||||||
if store == nil {
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
return store[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetDict(key string) map[string]any {
|
|
||||||
raw := GetRaw(key)
|
|
||||||
if raw != nil {
|
|
||||||
return raw.(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
return make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Set(key string, v any) error {
|
|
||||||
if store == nil {
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
store[key] = v
|
|
||||||
|
|
||||||
return save()
|
|
||||||
}
|
|
||||||
+98
-93
@@ -1,12 +1,14 @@
|
|||||||
package homekit
|
package homekit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/app/store"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||||
@@ -15,119 +17,122 @@ import (
|
|||||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
items := make([]any, 0)
|
sources, err := discovery()
|
||||||
|
|
||||||
for name, src := range store.GetDict("streams") {
|
|
||||||
if src := src.(string); strings.HasPrefix(src, "homekit") {
|
|
||||||
u, err := url.Parse(src)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
device := Device{
|
|
||||||
Name: name,
|
|
||||||
Addr: u.Host,
|
|
||||||
Paired: true,
|
|
||||||
}
|
|
||||||
items = append(items, device)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
|
||||||
log.Trace().Msgf("[homekit] mdns=%s", entry)
|
|
||||||
|
|
||||||
if entry.Complete() {
|
|
||||||
device := Device{
|
|
||||||
Name: entry.Name,
|
|
||||||
Addr: entry.Addr(),
|
|
||||||
ID: entry.Info[hap.TXTDeviceID],
|
|
||||||
Model: entry.Info[hap.TXTModel],
|
|
||||||
Paired: entry.Info[hap.TXTStatusFlags] == "0",
|
|
||||||
}
|
|
||||||
items = append(items, device)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
api.Error(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
api.ResponseJSON(w, items)
|
urls := findHomeKitURLs()
|
||||||
|
for id, u := range urls {
|
||||||
|
deviceID := u.Query().Get("device_id")
|
||||||
|
for _, source := range sources {
|
||||||
|
if strings.Contains(source.URL, deviceID) {
|
||||||
|
source.Location = id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Location == "" {
|
||||||
|
source.Location = " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.ResponseSources(w, sources)
|
||||||
|
|
||||||
case "POST":
|
case "POST":
|
||||||
// TODO: post params...
|
if err := r.ParseMultipartForm(1024); err != nil {
|
||||||
|
api.Error(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := r.URL.Query().Get("id")
|
if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil {
|
||||||
pin := r.URL.Query().Get("pin")
|
api.Error(w, err)
|
||||||
name := r.URL.Query().Get("name")
|
|
||||||
if err := hkPair(id, pin, name); err != nil {
|
|
||||||
log.Error().Err(err).Caller().Send()
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
src := r.URL.Query().Get("src")
|
if err := r.ParseMultipartForm(1024); err != nil {
|
||||||
if err := hkDelete(src); err != nil {
|
api.Error(w, err)
|
||||||
log.Error().Err(err).Caller().Send()
|
return
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
}
|
||||||
|
|
||||||
|
if err := apiUnpair(r.Form.Get("id")); err != nil {
|
||||||
|
api.Error(w, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hkPair(deviceID, pin, name string) (err error) {
|
func discovery() ([]*api.Source, error) {
|
||||||
var conn *hap.Client
|
var sources []*api.Source
|
||||||
|
|
||||||
if conn, err = hap.Pair(deviceID, pin); err != nil {
|
// 1. Get streams from Discovery
|
||||||
return
|
err := mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
||||||
|
log.Trace().Msgf("[homekit] mdns=%s", entry)
|
||||||
|
|
||||||
|
if entry.Complete() && entry.Info[hap.TXTCategory] == hap.CategoryCamera {
|
||||||
|
source := &api.Source{
|
||||||
|
Name: entry.Name,
|
||||||
|
Info: entry.Info[hap.TXTModel],
|
||||||
|
URL: fmt.Sprintf(
|
||||||
|
"homekit://%s:%d?device_id=%s&feature=%s&status=%s",
|
||||||
|
entry.IP, entry.Port, entry.Info[hap.TXTDeviceID],
|
||||||
|
entry.Info[hap.TXTFeatureFlags], entry.Info[hap.TXTStatusFlags],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
sources = append(sources, source)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
streams.New(name, conn.URL())
|
return sources, nil
|
||||||
|
|
||||||
dict := store.GetDict("streams")
|
|
||||||
dict[name] = conn.URL()
|
|
||||||
|
|
||||||
return store.Set("streams", dict)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hkDelete(name string) (err error) {
|
func apiPair(id, url string) error {
|
||||||
dict := store.GetDict("streams")
|
conn, err := hap.Pair(url)
|
||||||
for key, rawURL := range dict {
|
if err != nil {
|
||||||
if key != name {
|
return err
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var conn *hap.Client
|
|
||||||
|
|
||||||
if conn, err = hap.NewClient(rawURL.(string)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = conn.Dial(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = conn.ListPairings(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = conn.DeletePairing(conn.ClientID); err != nil {
|
|
||||||
log.Error().Err(err).Caller().Send()
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(dict, name)
|
|
||||||
|
|
||||||
return store.Set("streams", dict)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
streams.New(id, conn.URL())
|
||||||
|
|
||||||
|
return app.PatchConfig(id, conn.URL(), "streams")
|
||||||
}
|
}
|
||||||
|
|
||||||
type Device struct {
|
func apiUnpair(id string) error {
|
||||||
ID string `json:"id"`
|
stream := streams.Get(id)
|
||||||
Name string `json:"name"`
|
if stream == nil {
|
||||||
Addr string `json:"addr"`
|
return errors.New(api.StreamNotFound)
|
||||||
Model string `json:"model"`
|
}
|
||||||
Paired bool `json:"paired"`
|
|
||||||
//Type string `json:"type"`
|
rawURL := findHomeKitURL(stream)
|
||||||
|
if rawURL == "" {
|
||||||
|
return errors.New("not homekit source")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hap.Unpair(rawURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.Delete(id)
|
||||||
|
|
||||||
|
return app.PatchConfig(id, nil, "streams")
|
||||||
|
}
|
||||||
|
|
||||||
|
func findHomeKitURLs() map[string]*url.URL {
|
||||||
|
urls := map[string]*url.URL{}
|
||||||
|
for id, stream := range streams.Streams() {
|
||||||
|
if rawURL := findHomeKitURL(stream); rawURL != "" {
|
||||||
|
if u, err := url.Parse(rawURL); err == nil {
|
||||||
|
urls[id] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package homekit
|
package homekit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/app"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
"github.com/AlexxIT/go2rtc/internal/srtp"
|
"github.com/AlexxIT/go2rtc/internal/srtp"
|
||||||
@@ -23,3 +25,24 @@ var log zerolog.Logger
|
|||||||
func streamHandler(url string) (core.Producer, error) {
|
func streamHandler(url string) (core.Producer, error) {
|
||||||
return homekit.Dial(url, srtp.Server)
|
return homekit.Dial(url, srtp.Server)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findHomeKitURL(stream *streams.Stream) string {
|
||||||
|
sources := stream.Sources()
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
url := sources[0]
|
||||||
|
if strings.HasPrefix(url, "homekit") {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(url, "hass") {
|
||||||
|
location, _ := streams.Location(url)
|
||||||
|
if strings.HasPrefix(location, "homekit") {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ func NewStream(source any) *Stream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Stream) Sources() (sources []string) {
|
||||||
|
for _, prod := range s.producers {
|
||||||
|
sources = append(sources, prod.url)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Stream) SetSource(source string) {
|
func (s *Stream) SetSource(source string) {
|
||||||
for _, prod := range s.producers {
|
for _, prod := range s.producers {
|
||||||
prod.SetSource(source)
|
prod.SetSource(source)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/app"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
"github.com/AlexxIT/go2rtc/internal/app/store"
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,10 +24,6 @@ func Init() {
|
|||||||
streams[name] = NewStream(item)
|
streams[name] = NewStream(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, item := range store.GetDict("streams") {
|
|
||||||
streams[name] = NewStream(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
api.HandleFunc("api/streams", streamsHandler)
|
api.HandleFunc("api/streams", streamsHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +113,14 @@ func GetAll() (names []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Streams() map[string]*Stream {
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
func Delete(id string) {
|
||||||
|
delete(streams, id)
|
||||||
|
}
|
||||||
|
|
||||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
src := query.Get("src")
|
src := query.Get("src")
|
||||||
@@ -141,6 +144,11 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if New(name, src) == nil {
|
if New(name, src) == nil {
|
||||||
http.Error(w, "", http.StatusBadRequest)
|
http.Error(w, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.PatchConfig(name, src, "streams"); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "PATCH":
|
case "PATCH":
|
||||||
@@ -173,6 +181,10 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
delete(streams, src)
|
delete(streams, src)
|
||||||
|
|
||||||
|
if err := app.PatchConfig(src, nil, "streams"); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-23
@@ -5,43 +5,74 @@ import (
|
|||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
|
||||||
"github.com/tadglines/go-pkgs/crypto/srp"
|
"github.com/tadglines/go-pkgs/crypto/srp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Pair(deviceID, pin string) (*Client, error) {
|
// Pair homekit
|
||||||
var addr string
|
func Pair(rawURL string) (*Client, error) {
|
||||||
var mfi bool
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
_ = mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
return nil, err
|
||||||
if entry.Complete() && entry.Info[TXTDeviceID] == deviceID {
|
|
||||||
addr = entry.Addr()
|
|
||||||
mfi = entry.Info[TXTFeatureFlags] == "1"
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if addr == "" {
|
|
||||||
return nil, errors.New("hap: mdns.Discovery")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
|
||||||
c := &Client{
|
c := &Client{
|
||||||
DeviceAddress: addr,
|
DeviceAddress: u.Host,
|
||||||
DeviceID: deviceID,
|
DeviceID: query.Get("device_id"),
|
||||||
ClientID: GenerateUUID(),
|
ClientID: query.Get("client_id"),
|
||||||
ClientPrivate: GenerateKey(),
|
ClientPrivate: DecodeKey(query.Get("client_private")),
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, c.Pair(mfi, pin)
|
if c.ClientID == "" {
|
||||||
|
c.ClientID = GenerateUUID()
|
||||||
|
}
|
||||||
|
if c.ClientPrivate == nil {
|
||||||
|
c.ClientPrivate = GenerateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = c.Pair(query.Get("feature"), query.Get("pin")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Pair(mfi bool, pin string) (err error) {
|
func Unpair(rawURL string) error {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
conn := &Client{
|
||||||
|
DeviceAddress: u.Host,
|
||||||
|
DeviceID: query.Get("device_id"),
|
||||||
|
DevicePublic: DecodeKey(query.Get("device_public")),
|
||||||
|
ClientID: query.Get("client_id"),
|
||||||
|
ClientPrivate: DecodeKey(query.Get("client_private")),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = conn.Dial(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if err = conn.ListPairings(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn.DeletePairing(conn.ClientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Pair(feature, pin string) (err error) {
|
||||||
if pin, err = SanitizePin(pin); err != nil {
|
if pin, err = SanitizePin(pin); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -61,7 +92,7 @@ func (c *Client) Pair(mfi bool, pin string) (err error) {
|
|||||||
Method: MethodPair,
|
Method: MethodPair,
|
||||||
State: StateM1,
|
State: StateM1,
|
||||||
}
|
}
|
||||||
if mfi {
|
if feature == "1" {
|
||||||
plainM1.Method = MethodPairMFi // ff=1 => method=1, ff=2 => method=0
|
plainM1.Method = MethodPairMFi // ff=1 => method=1, ff=2 => method=0
|
||||||
}
|
}
|
||||||
res, err := c.Post(PathPairSetup, MimeTLV8, tlv8.MarshalReader(plainM1))
|
res, err := c.Post(PathPairSetup, MimeTLV8, tlv8.MarshalReader(plainM1))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
func TestPatch(t *testing.T) {
|
func TestPatch(t *testing.T) {
|
||||||
b := []byte(`# prefix`)
|
b := []byte(`# prefix`)
|
||||||
|
|
||||||
|
// 1. Add first
|
||||||
b, err := Patch(b, "camera1", "url1", "streams")
|
b, err := Patch(b, "camera1", "url1", "streams")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ streams:
|
|||||||
camera1: url1
|
camera1: url1
|
||||||
`, string(b))
|
`, string(b))
|
||||||
|
|
||||||
|
// 2. Add second
|
||||||
b, err = Patch(b, "camera2", []string{"url2", "url3"}, "streams")
|
b, err = Patch(b, "camera2", []string{"url2", "url3"}, "streams")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ streams:
|
|||||||
- url3
|
- url3
|
||||||
`, string(b))
|
`, string(b))
|
||||||
|
|
||||||
|
// 3. Replace first
|
||||||
b, err = Patch(b, "camera1", "url4", "streams")
|
b, err = Patch(b, "camera1", "url4", "streams")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ streams:
|
|||||||
- url3
|
- url3
|
||||||
`, string(b))
|
`, string(b))
|
||||||
|
|
||||||
|
// 4. Replace second
|
||||||
b, err = Patch(b, "camera2", "url5", "streams")
|
b, err = Patch(b, "camera2", "url5", "streams")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
@@ -48,6 +52,7 @@ streams:
|
|||||||
camera2: url5
|
camera2: url5
|
||||||
`, string(b))
|
`, string(b))
|
||||||
|
|
||||||
|
// 5. Delete first
|
||||||
b, err = Patch(b, "camera1", nil, "streams")
|
b, err = Patch(b, "camera1", nil, "streams")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
@@ -65,10 +70,8 @@ streams:
|
|||||||
camera1: url1
|
camera1: url1
|
||||||
`)
|
`)
|
||||||
|
|
||||||
pairings := map[string]string{
|
// 1. Add new key
|
||||||
"client1": "public1",
|
pairings := []string{"client1", "client2"}
|
||||||
"client2": "public2",
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := Patch(b, "pairings", pairings, "homekit", "camera1")
|
b, err := Patch(b, "pairings", pairings, "homekit", "camera1")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -77,8 +80,8 @@ streams:
|
|||||||
camera1:
|
camera1:
|
||||||
pin: 123-45-678
|
pin: 123-45-678
|
||||||
pairings:
|
pairings:
|
||||||
client1: public1
|
- client1
|
||||||
client2: public2
|
- client2
|
||||||
streams:
|
streams:
|
||||||
camera1: url1
|
camera1: url1
|
||||||
`, string(b))
|
`, string(b))
|
||||||
|
|||||||
+86
-84
@@ -59,7 +59,18 @@
|
|||||||
<body>
|
<body>
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
<script>
|
<script>
|
||||||
async function getSources(url, tableID) {
|
function drawTable(table, data) {
|
||||||
|
const cols = ['id', 'name', 'info', 'url', 'location'];
|
||||||
|
const th = (row) => cols.reduce((html, k) => k in row ? `${html}<th>${k}</th>` : html, '<tr>') + '</tr>';
|
||||||
|
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td>${row[k]}</td>` : html, '<tr>') + '</tr>';
|
||||||
|
|
||||||
|
const thead = th(data.sources[0]);
|
||||||
|
const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
|
||||||
|
|
||||||
|
table.innerHTML = `<thead>${thead}</thead><tbody>${tbody}</tbody>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSources(tableID, url) {
|
||||||
const table = document.getElementById(tableID);
|
const table = document.getElementById(tableID);
|
||||||
table.innerText = 'loading...';
|
table.innerText = 'loading...';
|
||||||
|
|
||||||
@@ -69,16 +80,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const td = value => value ? `<td>${value}</td>` : '';
|
drawTable(table, await r.json());
|
||||||
const th = (name, value) => value ? `<th>${name}</th>` : '';
|
|
||||||
|
|
||||||
/** @type {{sources:Array<{id:string,name:string,url:string,location:string}>}} */
|
|
||||||
const data = await r.json();
|
|
||||||
const i0 = data.sources[0];
|
|
||||||
const thead = `<tr>${th('ID', i0.id)}${th('Name', i0.name)}${th('URL', i0.url)}${th('Location', i0.location)}</tr>`;
|
|
||||||
table.innerHTML = data.sources.reduce((html, item) => {
|
|
||||||
return `${html}<tr>${td(item.id)}${td(item.name)}${td(item.url)}${td(item.location)}</tr>`;
|
|
||||||
}, `<thead>${thead}</thead><tbody>`) + '</tbody>';
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -104,76 +106,79 @@
|
|||||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||||
|
|
||||||
const r = await fetch(url, {method: 'PUT'});
|
const r = await fetch(url, {method: 'PUT'});
|
||||||
alert(r.ok ? 'OK' : 'ERROR');
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<button id="homekit">Apple HomeKit</button>
|
<button id="homekit">Apple HomeKit</button>
|
||||||
<div class="module">
|
<div class="module">
|
||||||
<div style="margin-bottom: 10px">
|
<form id="homekit-pair" style="margin-bottom: 10px">
|
||||||
<label for="pin">PIN</label>
|
<input type="text" name="id" placeholder="stream id" size="20">
|
||||||
<input id="pin" type="text">
|
<input type="text" name="url" placeholder="url" size="40">
|
||||||
</div>
|
<input type="text" name="pin" placeholder="pin" size="10">
|
||||||
<table>
|
<input type="submit" value="Pair">
|
||||||
<thead>
|
</form>
|
||||||
<tr>
|
<form id="homekit-unpair" style="margin-bottom: 10px">
|
||||||
<th>Name</th>
|
<input type="text" name="id" placeholder="stream id" size="20">
|
||||||
<th>Address</th>
|
<input type="submit" value="Unpair">
|
||||||
<th>Model</th>
|
</form>
|
||||||
<th>Commands</th>
|
<table id="homekit-table"></table>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="homekit-body">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
async function reloadHomeKit() {
|
||||||
|
await getSources('homekit-table', 'api/homekit');
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll('#homekit-table tr');
|
||||||
|
rows.forEach((row) => {
|
||||||
|
let commands = '';
|
||||||
|
if (row.children[2].innerText.indexOf('status=1') > 0) {
|
||||||
|
commands += '<a href="#">pair</a>';
|
||||||
|
} else if (row.children[3].innerText) {
|
||||||
|
commands += '<a href="#">unpair</a>';
|
||||||
|
}
|
||||||
|
row.innerHTML += `<td>${commands}</td>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('homekit').addEventListener('click', async ev => {
|
document.getElementById('homekit').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
const r = await fetch('api/homekit', {cache: 'no-cache'});
|
await reloadHomeKit();
|
||||||
|
|
||||||
/** @type {Array<{id:string,name:string,addr:string,model:string,paired:boolean}>} */
|
|
||||||
const data = await r.json();
|
|
||||||
|
|
||||||
const tbody = document.getElementById('homekit-body');
|
|
||||||
tbody.innerHTML =
|
|
||||||
data.reduce((res, item) => {
|
|
||||||
let commands = '';
|
|
||||||
if (item.id === '') {
|
|
||||||
commands = `<a href="#" onclick="unpair('${item.name}')">unpair</a>`;
|
|
||||||
} else if (item.paired === false) {
|
|
||||||
commands = `<a href="#" onclick="pair('${item.id}','${item.name}')">pair</a>`;
|
|
||||||
}
|
|
||||||
return res + `<tr>
|
|
||||||
<td>${item.name}</td>
|
|
||||||
<td>${item.addr}</td>
|
|
||||||
<td>${item.model}</td>
|
|
||||||
<td>${commands}</td>
|
|
||||||
</tr>`;
|
|
||||||
}, '');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function pair(id, name) {
|
document.getElementById('homekit-table').addEventListener('click', ev => {
|
||||||
const pin = document.querySelector('#pin').value;
|
if (ev.target.innerText === 'pair') {
|
||||||
fetch(`api/homekit?id=${id}&name=${name}&pin=${pin}`, {method: 'POST'})
|
const form = document.querySelector('#homekit-pair');
|
||||||
.then(r => r.text())
|
const row = ev.target.closest('tr');
|
||||||
.then(data => {
|
form.children[0].value = row.children[0].innerText;
|
||||||
if (data.length > 0) alert(data);
|
form.children[1].value = row.children[2].innerText;
|
||||||
else window.location.reload();
|
} else if (ev.target.innerText === 'unpair') {
|
||||||
})
|
const form = document.querySelector('#homekit-unpair');
|
||||||
.catch(console.error);
|
const row = ev.target.closest('tr');
|
||||||
}
|
form.children[0].value = row.children[3].innerText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function unpair(src) {
|
document.getElementById('homekit-pair').addEventListener('submit', async ev => {
|
||||||
fetch(`api/homekit?src=${src}`, {method: 'DELETE'})
|
ev.preventDefault();
|
||||||
.then(r => r.text())
|
|
||||||
.then(data => {
|
const body = new FormData(ev.target);
|
||||||
if (data.length > 0) alert(data);
|
body.set('url', body.get('url') + '&pin=' + body.get('pin'));
|
||||||
else window.location.reload();
|
body.delete('pin');
|
||||||
})
|
|
||||||
.catch(console.error);
|
const r = await fetch('api/homekit', {method: 'POST', body: body});
|
||||||
}
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
|
|
||||||
|
await reloadHomeKit();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('homekit-unpair').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const r = await fetch('api/homekit', {method: 'DELETE', body: new FormData(ev.target)});
|
||||||
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
|
|
||||||
|
await reloadHomeKit();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -184,33 +189,31 @@
|
|||||||
<script>
|
<script>
|
||||||
document.getElementById('dvrip').addEventListener('click', async ev => {
|
document.getElementById('dvrip').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
await getSources('api/dvrip', 'dvrip-table');
|
await getSources('dvrip-table', 'api/dvrip');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<button id="devices">FFmpeg Devices (USB)</button>
|
<button id="devices">FFmpeg Devices (USB)</button>
|
||||||
<div class="module">
|
<div class="module">
|
||||||
<table id="devices-table">
|
<table id="devices-table"></table>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('devices').addEventListener('click', async ev => {
|
document.getElementById('devices').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
await getSources('api/ffmpeg/devices', 'devices-table');
|
await getSources('devices-table', 'api/ffmpeg/devices');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<button id="hardware">FFmpeg Hardware</button>
|
<button id="hardware">FFmpeg Hardware</button>
|
||||||
<div class="module">
|
<div class="module">
|
||||||
<table id="hardware-table">
|
<table id="hardware-table"></table>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('hardware').addEventListener('click', async ev => {
|
document.getElementById('hardware').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
await getSources('api/ffmpeg/hardware', 'hardware-table');
|
await getSources('hardware-table', 'api/ffmpeg/hardware');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -224,8 +227,7 @@
|
|||||||
<input type="text" name="project_id" placeholder="project_id">
|
<input type="text" name="project_id" placeholder="project_id">
|
||||||
<input type="submit" value="Login">
|
<input type="submit" value="Login">
|
||||||
</form>
|
</form>
|
||||||
<table id="nest-table">
|
<table id="nest-table"></table>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('nest').addEventListener('click', async ev => {
|
document.getElementById('nest').addEventListener('click', async ev => {
|
||||||
@@ -239,7 +241,7 @@
|
|||||||
const url = new URL('api/nest?' + query.toString(), location.href);
|
const url = new URL('api/nest?' + query.toString(), location.href);
|
||||||
|
|
||||||
const r = await fetch(url, {cache: 'no-cache'});
|
const r = await fetch(url, {cache: 'no-cache'});
|
||||||
await getSources(r, 'nest-table');
|
await getSources('nest-table', r);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -251,7 +253,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.getElementById('hass').addEventListener('click', async ev => {
|
document.getElementById('hass').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
await getSources('api/hass', 'hass-table');
|
await getSources('hass-table', 'api/hass');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -267,7 +269,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.getElementById('onvif').addEventListener('click', async ev => {
|
document.getElementById('onvif').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
await getSources('api/onvif', 'onvif-table');
|
await getSources('onvif-table', 'api/onvif');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('onvif-form').addEventListener('submit', async ev => {
|
document.getElementById('onvif-form').addEventListener('submit', async ev => {
|
||||||
@@ -276,7 +278,7 @@
|
|||||||
const url = new URL('api/onvif', location.href);
|
const url = new URL('api/onvif', location.href);
|
||||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||||
|
|
||||||
await getSources(url.toString(), 'onvif-table');
|
await getSources('onvif-table', url.toString());
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -294,13 +296,13 @@
|
|||||||
<script>
|
<script>
|
||||||
document.getElementById('roborock').addEventListener('click', async ev => {
|
document.getElementById('roborock').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
await getSources('api/roborock', 'roborock-table');
|
await getSources('roborock-table', 'api/roborock');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('roborock-form').addEventListener('submit', async ev => {
|
document.getElementById('roborock-form').addEventListener('submit', async ev => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
|
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
|
||||||
await getSources(r, 'roborock-table');
|
await getSources('roborock-table', r);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -312,7 +314,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
await getSources('api/webtorrent', 'webtorrent-table');
|
await getSources('webtorrent-table', 'api/webtorrent');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user