tangled
alpha
login
or
join now
stream.place
/
streamplace
Live video on the AT Protocol
74
fork
atom
overview
issues
1
pulls
pipelines
iroh: working KV replication!
Eli Mallon
4 months ago
03d34c60
504fd255
+217
-122
10 changed files
expand all
collapse all
unified
split
.vscode
settings.json
pkg
cmd
streamplace.go
config
config.go
director
director.go
iroh
generated
iroh_streamplace
iroh_streamplace.go
replication
iroh_replicator
iroh.go
kv.go
rust
iroh-streamplace
Makefile
go
go.mod
main.go
+2
-2
.vscode/settings.json
···
10
},
11
"mesonbuild.configureOnOpen": false,
12
"cSpell.words": ["Devplace", "streamplace", "webrtc"],
13
-
"go.lintTool": "golangci-lint",
14
"go.lintFlags": ["--path-mode=abs"],
15
"go.formatTool": "custom",
16
"go.alternateTools": {
17
-
"customFormatter": "golangci-lint"
18
},
19
"go.formatFlags": ["fmt", "--stdin"],
20
"editor.codeActionsOnSave": {
···
10
},
11
"mesonbuild.configureOnOpen": false,
12
"cSpell.words": ["Devplace", "streamplace", "webrtc"],
13
+
"go.lintTool": "golangci-lint-v2",
14
"go.lintFlags": ["--path-mode=abs"],
15
"go.formatTool": "custom",
16
"go.alternateTools": {
17
+
"customFormatter": "golangci-lint-v2"
18
},
19
"go.formatFlags": ["fmt", "--stdin"],
20
"editor.codeActionsOnSave": {
+34
-1
pkg/cmd/streamplace.go
···
1
package cmd
2
3
import (
0
4
"context"
5
"crypto"
0
6
"errors"
7
"flag"
8
"fmt"
···
32
"stream.place/streamplace/pkg/notifications"
33
"stream.place/streamplace/pkg/replication"
34
"stream.place/streamplace/pkg/replication/boring"
0
35
"stream.place/streamplace/pkg/rtmps"
36
v0 "stream.place/streamplace/pkg/schema/v0"
37
"stream.place/streamplace/pkg/spmetrics"
···
379
},
380
}
381
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
382
op := oatproxy.New(&oatproxy.Config{
383
Host: cli.PublicHost,
384
CreateOAuthSession: state.CreateOAuthSession,
···
390
DownstreamJWK: cli.AccessJWK,
391
ClientMetadata: clientMetadata,
392
})
393
-
d := director.NewDirector(mm, mod, &cli, b, op, state)
394
a, err := api.MakeStreamplaceAPI(&cli, mod, state, eip712signer, noter, mm, ms, b, atsync, d, op)
395
if err != nil {
396
return err
···
451
452
group.Go(func() error {
453
return mod.StartSegmentCleaner(ctx)
0
0
0
0
454
})
455
456
if cli.LivepeerGateway {
···
1
package cmd
2
3
import (
4
+
"bytes"
5
"context"
6
"crypto"
7
+
"crypto/rand"
8
"errors"
9
"flag"
10
"fmt"
···
34
"stream.place/streamplace/pkg/notifications"
35
"stream.place/streamplace/pkg/replication"
36
"stream.place/streamplace/pkg/replication/boring"
37
+
"stream.place/streamplace/pkg/replication/iroh_replicator"
38
"stream.place/streamplace/pkg/rtmps"
39
v0 "stream.place/streamplace/pkg/schema/v0"
40
"stream.place/streamplace/pkg/spmetrics"
···
382
},
383
}
384
385
+
exists, err := cli.DataFileExists([]string{"iroh-kv-secret"})
386
+
if err != nil {
387
+
return err
388
+
}
389
+
if !exists {
390
+
secret := make([]byte, 32)
391
+
_, err := rand.Read(secret)
392
+
if err != nil {
393
+
return fmt.Errorf("failed to generate random secret: %w", err)
394
+
}
395
+
err = cli.DataFileWrite([]string{"iroh-kv-secret"}, bytes.NewReader(secret), true)
396
+
if err != nil {
397
+
return err
398
+
}
399
+
}
400
+
buf := bytes.Buffer{}
401
+
err = cli.DataFileRead([]string{"iroh-kv-secret"}, &buf)
402
+
if err != nil {
403
+
return err
404
+
}
405
+
secret := buf.Bytes()
406
+
swarm, err := iroh_replicator.StartKV(ctx, cli.Tickets, secret)
407
+
if err != nil {
408
+
return err
409
+
}
410
+
411
op := oatproxy.New(&oatproxy.Config{
412
Host: cli.PublicHost,
413
CreateOAuthSession: state.CreateOAuthSession,
···
419
DownstreamJWK: cli.AccessJWK,
420
ClientMetadata: clientMetadata,
421
})
422
+
d := director.NewDirector(mm, mod, &cli, b, op, state, swarm)
423
a, err := api.MakeStreamplaceAPI(&cli, mod, state, eip712signer, noter, mm, ms, b, atsync, d, op)
424
if err != nil {
425
return err
···
480
481
group.Go(func() error {
482
return mod.StartSegmentCleaner(ctx)
483
+
})
484
+
485
+
group.Go(func() error {
486
+
return swarm.Start(ctx, cli.Tickets)
487
})
488
489
if cli.LivepeerGateway {
+2
pkg/config/config.go
···
118
SQLLogging bool
119
SentryDSN string
120
LivepeerDebug bool
0
121
}
122
123
func (cli *CLI) NewFlagSet(name string) *flag.FlagSet {
···
184
fs.BoolVar(&cli.SQLLogging, "sql-logging", false, "enable sql logging")
185
fs.StringVar(&cli.SentryDSN, "sentry-dsn", "", "sentry dsn for error reporting")
186
fs.BoolVar(&cli.LivepeerDebug, "livepeer-debug", false, "log livepeer segments to $SP_DATA_DIR/livepeer-debug")
0
187
188
lpFlags := flag.NewFlagSet("livepeer", flag.ContinueOnError)
189
_ = starter.NewLivepeerConfig(lpFlags)
···
118
SQLLogging bool
119
SentryDSN string
120
LivepeerDebug bool
121
+
Tickets []string
122
}
123
124
func (cli *CLI) NewFlagSet(name string) *flag.FlagSet {
···
185
fs.BoolVar(&cli.SQLLogging, "sql-logging", false, "enable sql logging")
186
fs.StringVar(&cli.SentryDSN, "sentry-dsn", "", "sentry dsn for error reporting")
187
fs.BoolVar(&cli.LivepeerDebug, "livepeer-debug", false, "log livepeer segments to $SP_DATA_DIR/livepeer-debug")
188
+
cli.StringSliceFlag(fs, &cli.Tickets, "tickets", "[]", "tickets to join the swarm with")
189
190
lpFlags := flag.NewFlagSet("livepeer", flag.ContinueOnError)
191
_ = starter.NewLivepeerConfig(lpFlags)
+11
-1
pkg/director/director.go
···
5
"fmt"
6
"sync"
7
0
8
"github.com/streamplace/oatproxy/pkg/oatproxy"
9
"golang.org/x/sync/errgroup"
10
"stream.place/streamplace/pkg/bus"
···
12
"stream.place/streamplace/pkg/log"
13
"stream.place/streamplace/pkg/media"
14
"stream.place/streamplace/pkg/model"
0
15
"stream.place/streamplace/pkg/statedb"
16
)
17
···
30
streamSessionsMu sync.Mutex
31
op *oatproxy.OATProxy
32
statefulDB *statedb.StatefulDB
0
33
}
34
35
-
func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB) *Director {
36
return &Director{
37
mm: mm,
38
mod: mod,
···
42
streamSessionsMu: sync.Mutex{},
43
op: op,
44
statefulDB: statefulDB,
0
45
}
46
}
47
···
86
})
87
}
88
d.streamSessionsMu.Unlock()
0
0
0
0
0
0
89
err := ss.NewSegment(ctx, not)
90
if err != nil {
91
log.Error(ctx, "could not add segment to stream session", "error", err)
···
5
"fmt"
6
"sync"
7
8
+
"github.com/bluesky-social/indigo/util"
9
"github.com/streamplace/oatproxy/pkg/oatproxy"
10
"golang.org/x/sync/errgroup"
11
"stream.place/streamplace/pkg/bus"
···
13
"stream.place/streamplace/pkg/log"
14
"stream.place/streamplace/pkg/media"
15
"stream.place/streamplace/pkg/model"
16
+
"stream.place/streamplace/pkg/replication/iroh_replicator"
17
"stream.place/streamplace/pkg/statedb"
18
)
19
···
32
streamSessionsMu sync.Mutex
33
op *oatproxy.OATProxy
34
statefulDB *statedb.StatefulDB
35
+
swarm *iroh_replicator.SwarmKV
36
}
37
38
+
func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB, swarm *iroh_replicator.SwarmKV) *Director {
39
return &Director{
40
mm: mm,
41
mod: mod,
···
45
streamSessionsMu: sync.Mutex{},
46
op: op,
47
statefulDB: statefulDB,
48
+
swarm: swarm,
49
}
50
}
51
···
90
})
91
}
92
d.streamSessionsMu.Unlock()
93
+
go func() {
94
+
err := d.swarm.Put(ctx, not.Segment.RepoDID, not.Segment.StartTime.Format(util.ISO8601))
95
+
if err != nil {
96
+
log.Error(ctx, "could not put segment to swarm", "error", err)
97
+
}
98
+
}()
99
err := ss.NewSegment(ctx, not)
100
if err != nil {
101
log.Error(ctx, "could not add segment to stream session", "error", err)
+68
pkg/iroh/generated/iroh_streamplace/iroh_streamplace.go
···
1440
},
1441
)
1442
0
0
0
0
1443
return res, err
1444
}
1445
···
1555
C.ffi_iroh_streamplace_rust_future_free_pointer(handle)
1556
},
1557
)
0
0
0
0
1558
1559
return res, err
1560
}
···
1961
},
1962
)
1963
0
0
0
0
1964
return res, err
1965
}
1966
···
1987
},
1988
)
1989
0
0
0
0
1990
return res, err
1991
}
1992
···
2013
C.ffi_iroh_streamplace_rust_future_free_pointer(handle)
2014
},
2015
)
0
0
0
0
2016
2017
return res, err
2018
}
···
2052
},
2053
)
2054
0
0
0
0
2055
return err
2056
}
2057
···
2081
C.ffi_iroh_streamplace_rust_future_free_pointer(handle)
2082
},
2083
)
0
0
0
0
2084
2085
return res, err
2086
}
···
2122
},
2123
)
2124
0
0
0
0
2125
return err
2126
}
2127
···
2150
},
2151
)
2152
0
0
0
0
2153
return err
2154
}
2155
···
2182
},
2183
)
2184
0
0
0
0
2185
return res, err
2186
}
2187
···
2209
C.ffi_iroh_streamplace_rust_future_free_void(handle)
2210
},
2211
)
0
0
0
0
2212
2213
return err
2214
}
···
2553
},
2554
)
2555
0
0
0
0
2556
return res, err
2557
}
2558
···
2610
},
2611
)
2612
0
0
0
0
2613
return err
2614
}
2615
···
2637
C.ffi_iroh_streamplace_rust_future_free_void(handle)
2638
},
2639
)
0
0
0
0
2640
2641
return err
2642
}
···
2778
C.ffi_iroh_streamplace_rust_future_free_void(handle)
2779
},
2780
)
0
0
0
0
2781
2782
return err
2783
}
···
2872
},
2873
)
2874
0
0
0
0
2875
return res, err
2876
}
2877
···
2972
C.ffi_iroh_streamplace_rust_future_free_void(handle)
2973
},
2974
)
0
0
0
0
2975
2976
return err
2977
}
···
1440
},
1441
)
1442
1443
+
if err == nil {
1444
+
return res, nil
1445
+
}
1446
+
1447
return res, err
1448
}
1449
···
1559
C.ffi_iroh_streamplace_rust_future_free_pointer(handle)
1560
},
1561
)
1562
+
1563
+
if err == nil {
1564
+
return res, nil
1565
+
}
1566
1567
return res, err
1568
}
···
1969
},
1970
)
1971
1972
+
if err == nil {
1973
+
return res, nil
1974
+
}
1975
+
1976
return res, err
1977
}
1978
···
1999
},
2000
)
2001
2002
+
if err == nil {
2003
+
return res, nil
2004
+
}
2005
+
2006
return res, err
2007
}
2008
···
2029
C.ffi_iroh_streamplace_rust_future_free_pointer(handle)
2030
},
2031
)
2032
+
2033
+
if err == nil {
2034
+
return res, nil
2035
+
}
2036
2037
return res, err
2038
}
···
2072
},
2073
)
2074
2075
+
if err == nil {
2076
+
return nil
2077
+
}
2078
+
2079
return err
2080
}
2081
···
2105
C.ffi_iroh_streamplace_rust_future_free_pointer(handle)
2106
},
2107
)
2108
+
2109
+
if err == nil {
2110
+
return res, nil
2111
+
}
2112
2113
return res, err
2114
}
···
2150
},
2151
)
2152
2153
+
if err == nil {
2154
+
return nil
2155
+
}
2156
+
2157
return err
2158
}
2159
···
2182
},
2183
)
2184
2185
+
if err == nil {
2186
+
return nil
2187
+
}
2188
+
2189
return err
2190
}
2191
···
2218
},
2219
)
2220
2221
+
if err == nil {
2222
+
return res, nil
2223
+
}
2224
+
2225
return res, err
2226
}
2227
···
2249
C.ffi_iroh_streamplace_rust_future_free_void(handle)
2250
},
2251
)
2252
+
2253
+
if err == nil {
2254
+
return nil
2255
+
}
2256
2257
return err
2258
}
···
2597
},
2598
)
2599
2600
+
if err == nil {
2601
+
return res, nil
2602
+
}
2603
+
2604
return res, err
2605
}
2606
···
2658
},
2659
)
2660
2661
+
if err == nil {
2662
+
return nil
2663
+
}
2664
+
2665
return err
2666
}
2667
···
2689
C.ffi_iroh_streamplace_rust_future_free_void(handle)
2690
},
2691
)
2692
+
2693
+
if err == nil {
2694
+
return nil
2695
+
}
2696
2697
return err
2698
}
···
2834
C.ffi_iroh_streamplace_rust_future_free_void(handle)
2835
},
2836
)
2837
+
2838
+
if err == nil {
2839
+
return nil
2840
+
}
2841
2842
return err
2843
}
···
2932
},
2933
)
2934
2935
+
if err == nil {
2936
+
return res, nil
2937
+
}
2938
+
2939
return res, err
2940
}
2941
···
3036
C.ffi_iroh_streamplace_rust_future_free_void(handle)
3037
},
3038
)
3039
+
3040
+
if err == nil {
3041
+
return nil
3042
+
}
3043
3044
return err
3045
}
+1
-1
pkg/replication/iroh/iroh.go
pkg/replication/iroh_replicator/iroh.go
···
1
-
package iroh
2
3
import (
4
"context"
···
1
+
package iroh_replicator
2
3
import (
4
"context"
+99
pkg/replication/iroh_replicator/kv.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package iroh_replicator
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"time"
7
+
8
+
"stream.place/streamplace/pkg/iroh/generated/iroh_streamplace"
9
+
"stream.place/streamplace/pkg/log"
10
+
)
11
+
12
+
type SwarmKV struct {
13
+
node *iroh_streamplace.Node
14
+
db *iroh_streamplace.Db
15
+
w *iroh_streamplace.WriteScope
16
+
}
17
+
18
+
func StartKV(ctx context.Context, tickets []string, secret []byte) (*SwarmKV, error) {
19
+
ctx = log.WithLogValues(ctx, "func", "StartKV")
20
+
21
+
log.Log(ctx, "Starting with tickets", "tickets", tickets)
22
+
config := iroh_streamplace.Config{
23
+
Key: secret,
24
+
Topic: make([]byte, 32), // all zero topic for testing
25
+
MaxSendDuration: 1000_000_000, // 1s
26
+
}
27
+
log.Log(ctx, "Config created", "config", config)
28
+
node, err := iroh_streamplace.NodeSender(config)
29
+
if err != nil {
30
+
return nil, fmt.Errorf("failed to create NodeSender: %w", err)
31
+
}
32
+
33
+
db := node.Db()
34
+
w := node.NodeScope()
35
+
36
+
node_id, err := node.NodeId()
37
+
if err != nil {
38
+
return nil, fmt.Errorf("failed to get NodeId: %w", err)
39
+
}
40
+
log.Log(ctx, "Node ID:", "node_id", node_id)
41
+
42
+
ticket, err := node.Ticket()
43
+
if err != nil {
44
+
return nil, fmt.Errorf("failed to get Ticket: %w", err)
45
+
}
46
+
log.Log(ctx, "Ticket:", "ticket", ticket)
47
+
48
+
swarm := SwarmKV{
49
+
node: node,
50
+
db: db,
51
+
w: w,
52
+
}
53
+
return &swarm, nil
54
+
}
55
+
56
+
func (swarm *SwarmKV) Start(ctx context.Context, tickets []string) error {
57
+
if len(tickets) > 0 {
58
+
err := swarm.node.JoinPeers(tickets)
59
+
if err != nil {
60
+
return fmt.Errorf("failed to join peers: %w", err)
61
+
}
62
+
}
63
+
64
+
sub := swarm.db.Subscribe(iroh_streamplace.NewFilter())
65
+
for {
66
+
if ctx.Err() != nil {
67
+
return ctx.Err()
68
+
}
69
+
ev, err := sub.NextRaw()
70
+
if err != nil {
71
+
return fmt.Errorf("failed to get next subscription event: %w", err)
72
+
}
73
+
if ev == nil {
74
+
log.Log(ctx, "Got empty event from sub.NextRaw(), pausing for a second")
75
+
time.Sleep(1 * time.Second)
76
+
continue
77
+
}
78
+
switch item := (*ev).(type) {
79
+
case iroh_streamplace.SubscribeItemEntry:
80
+
keyStr := string(item.Key)
81
+
valueStr := string(item.Value)
82
+
log.Log(ctx, "SubscribeItemEntry", "key", keyStr, "value", valueStr)
83
+
84
+
case iroh_streamplace.SubscribeItemCurrentDone:
85
+
log.Log(ctx, "SubscribeItemCurrentDone", "currentDone", item)
86
+
case iroh_streamplace.SubscribeItemExpired:
87
+
log.Log(ctx, "SubscribeItemExpired", "expired", item)
88
+
case iroh_streamplace.SubscribeItemOther:
89
+
log.Log(ctx, "SubscribeItemOther", "other", item)
90
+
}
91
+
}
92
+
}
93
+
94
+
func (swarm *SwarmKV) Put(ctx context.Context, key, value string) error {
95
+
// streamerBs := []byte(streamer)
96
+
keyBs := []byte(key)
97
+
valueBs := []byte(value)
98
+
return swarm.w.Put(nil, keyBs, valueBs)
99
+
}
-19
rust/iroh-streamplace/Makefile
···
1
-
.PHONY: clean build-rust generate run
2
-
3
-
install:
4
-
cargo install uniffi-bindgen-go --git https://github.com/NordSecurity/uniffi-bindgen-go --tag v0.4.0+v0.28.3
5
-
6
-
clean:
7
-
cargo clean
8
-
rm -rf go/uniffi_example/
9
-
10
-
build-rust:
11
-
cargo build
12
-
13
-
generate: build-rust
14
-
uniffi-bindgen-go --library ../../target/debug/libiroh_streamplace.dylib --out-dir go/
15
-
cp ../../target/debug/libiroh_streamplace.dylib go/
16
-
17
-
run: generate
18
-
cd go && CGO_LDFLAGS="-L. -liroh_streamplace" go run main.go $(TICKET)
19
-
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
-3
rust/iroh-streamplace/go/go.mod
···
1
-
module hello-app
2
-
3
-
go 1.25.1
···
0
0
0
-95
rust/iroh-streamplace/go/main.go
···
1
-
2
-
package main
3
-
4
-
import (
5
-
"fmt"
6
-
"os"
7
-
sp "hello-app/iroh_streamplace"
8
-
"reflect"
9
-
"crypto/rand"
10
-
)
11
-
12
-
func isNilError(err error) bool {
13
-
if err == nil {
14
-
return true
15
-
}
16
-
17
-
v := reflect.ValueOf(err)
18
-
return v.Kind() == reflect.Ptr && v.IsNil()
19
-
}
20
-
21
-
22
-
func panicIfErr(err error) {
23
-
if !isNilError(err) {
24
-
panic(err)
25
-
}
26
-
}
27
-
28
-
func main() {
29
-
tickets := os.Args[1:];
30
-
31
-
secret := make([]byte, 32)
32
-
_, err := rand.Read(secret)
33
-
panicIfErr(err)
34
-
35
-
fmt.Println("Starting with tickets", tickets)
36
-
config := sp.Config {
37
-
Key : secret,
38
-
Topic: make([]byte, 32), // all zero topic for testing
39
-
MaxSendDuration: 1000_000_000, // 1s
40
-
}
41
-
fmt.Printf("Config created %+v\n", config)
42
-
node, err := sp.NodeSender(config)
43
-
panicIfErr(err)
44
-
45
-
db := node.Db()
46
-
w := node.NodeScope()
47
-
48
-
node_id, err := node.NodeId()
49
-
panicIfErr(err)
50
-
fmt.Println("Node ID:", node_id)
51
-
52
-
ticket, err := node.Ticket()
53
-
panicIfErr(err)
54
-
fmt.Println("Ticket:", ticket)
55
-
56
-
if len(tickets) > 0 {
57
-
err = node.JoinPeers(tickets)
58
-
panicIfErr(err)
59
-
}
60
-
61
-
w.Put(nil, []byte("hello"), []byte("world"))
62
-
stream := []byte("stream1")
63
-
w.Put(&stream, []byte("subscribed"), []byte("true"))
64
-
65
-
filter := sp.NewFilter()
66
-
items, err := db.IterWithOpts(filter)
67
-
panicIfErr(err)
68
-
fmt.Printf("Iter items: %+v\n", items)
69
-
70
-
filter2 := sp.NewFilter().Global()
71
-
items2, err := db.IterWithOpts(filter2)
72
-
panicIfErr(err)
73
-
fmt.Printf("Iter items: %+v\n", items2)
74
-
75
-
filter3 := sp.NewFilter().Stream(stream)
76
-
items3, err := db.IterWithOpts(filter3)
77
-
panicIfErr(err)
78
-
fmt.Printf("Iter items: %+v\n", items3)
79
-
80
-
sub := db.Subscribe(sp.NewFilter())
81
-
for {
82
-
ev, err := sub.NextRaw()
83
-
panicIfErr(err)
84
-
switch (*ev).(type) {
85
-
case sp.SubscribeItemEntry:
86
-
fmt.Printf("%+v\n", (*ev).(sp.SubscribeItemEntry))
87
-
case sp.SubscribeItemCurrentDone:
88
-
fmt.Printf("Got current done event: %+v\n", (*ev).(sp.SubscribeItemCurrentDone))
89
-
case sp.SubscribeItemExpired:
90
-
fmt.Printf("Got expired event: %+v\n", (*ev).(sp.SubscribeItemExpired))
91
-
case sp.SubscribeItemOther:
92
-
fmt.Printf("Got other event: %+v\n", (*ev).(sp.SubscribeItemOther))
93
-
}
94
-
}
95
-
}
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0