big refactor, abort on ctrl-c, proper wallet init and backup

This commit is contained in:
Alex Grintsvayg 2017-12-28 12:14:33 -05:00
parent a4d61f487a
commit b15e514638
No known key found for this signature in database
GPG key ID: AEB3F089F86A22B5
11 changed files with 788 additions and 615 deletions

View file

@ -1,12 +1,11 @@
package cmd package cmd
import ( import (
"fmt" "os"
"os/signal"
"sync" "sync"
"syscall"
"github.com/lbryio/lbry.go/jsonrpc"
"github.com/davecgh/go-spew/spew"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -20,51 +19,17 @@ func init() {
RootCmd.AddCommand(testCmd) RootCmd.AddCommand(testCmd)
} }
func strPtr(s string) *string { return &s }
func test(cmd *cobra.Command, args []string) { func test(cmd *cobra.Command, args []string) {
daemon := jsonrpc.NewClient("")
addresses, err := daemon.WalletList()
if err != nil {
panic(err)
} else if addresses == nil || len(*addresses) == 0 {
panic(fmt.Errorf("could not find an address in wallet"))
}
claimAddress := (*addresses)[0]
if claimAddress == "" {
panic(fmt.Errorf("found blank claim address"))
}
var wg sync.WaitGroup var wg sync.WaitGroup
c := make(chan os.Signal, 1)
publishes := []jsonrpc.PublishOptions{ signal.Notify(c, os.Interrupt, syscall.SIGTERM)
{ wg.Add(1)
Title: strPtr("a"), go func() {
Language: strPtr("en"), defer wg.Done()
ClaimAddress: &claimAddress, <-c
ChannelName: strPtr("@x"), log.Println("got signal")
}, }()
{ log.Println("waiting for ctrl+c")
Title: strPtr("b"),
Language: strPtr("en"),
ClaimAddress: &claimAddress,
ChannelName: strPtr("@x"),
},
}
for _, o := range publishes {
wg.Add(1)
go func(o jsonrpc.PublishOptions) {
defer wg.Done()
log.Println("Publishing " + *o.Title)
response, err := daemon.Publish(*o.Title, "/home/grin/Desktop/cake.jpg", 0.01, o)
if err != nil {
spew.Dump([]interface{}{o, err})
}
spew.Dump(response)
}(o)
}
wg.Wait() wg.Wait()
log.Println("done waiting")
} }

View file

@ -7,6 +7,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/go-errors/errors" "github.com/go-errors/errors"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
@ -33,6 +34,17 @@ func NewClient(address string) *Client {
return &d return &d
} }
func NewClientAndWait(address string) *Client {
d := NewClient(address)
for {
_, err := d.WalletBalance()
if err == nil {
return d
}
time.Sleep(5 * time.Second)
}
}
func decode(data interface{}, targetStruct interface{}) error { func decode(data interface{}, targetStruct interface{}) error {
config := &mapstructure.DecoderConfig{ config := &mapstructure.DecoderConfig{
Metadata: nil, Metadata: nil,
@ -402,3 +414,24 @@ func (d *Client) WalletUnusedAddress() (*WalletUnusedAddressResponse, error) {
response := WalletUnusedAddressResponse(address) response := WalletUnusedAddressResponse(address)
return &response, nil return &response, nil
} }
func (d *Client) NumClaimsInChannel(url string) (uint64, error) {
response := new(NumClaimsInChannelResponse)
err := d.call(response, "claim_list_by_channel", map[string]interface{}{
"uri": url,
})
if err != nil {
return 0, err
} else if response == nil {
return 0, errors.New("no response")
}
channel, ok := (*response)[url]
if !ok {
return 0, errors.New("url not in response")
}
if channel.Error != "" {
return 0, errors.New(channel.Error)
}
return channel.ClaimsInChannel, nil
}

View file

@ -311,3 +311,8 @@ type UTXOListResponse []struct {
type WalletNewAddressResponse string type WalletNewAddressResponse string
type WalletUnusedAddressResponse string type WalletUnusedAddressResponse string
type NumClaimsInChannelResponse map[string]struct {
ClaimsInChannel uint64 `json:"claims_in_channel,omitempty"`
Error string `json:"error,omitempty"`
}

24
stopOnce/stopOnce.go Normal file
View file

@ -0,0 +1,24 @@
package stopOnce
import "sync"
type Stopper struct {
ch chan struct{}
once sync.Once
}
func New() *Stopper {
s := Stopper{}
s.ch = make(chan struct{})
return &s
}
func (s Stopper) Chan() <-chan struct{} {
return s.ch
}
func (s Stopper) Stop() {
s.once.Do(func() {
close(s.ch)
})
}

View file

@ -1,9 +1,10 @@
# Current YT Sync Process # YT Sync Process
- make sure you have a clean `.lbryum` dir (delete existing dir if there's nothing you need there) - make sure you don't have a `.lbryum/wallets/default_wallet`
- delete existing wallet if there's nothing you need there, or better yet, move it somewhere else in case you need it later
- make sure daemon is stopped and can be controlled with `systemctl` - make sure daemon is stopped and can be controlled with `systemctl`
- run `lbry ytsync YOUTUBE_KEY YOUTUBE_CHANNEL_ID LBRY_CHANNEL_NAME --max-tries=5` - run `lbry ytsync YOUTUBE_KEY YOUTUBE_CHANNEL_ID LBRY_CHANNEL_NAME --max-tries=5`
- `max-tries` will retry errors that you will undoubtedly get - `max-tries` will retry errors that you will probably get (e.g. failed publishes)
- after sync is complete, daemon will be stopped and wallet will be moved to `~/wallets/` - after sync is complete, daemon will be stopped and wallet will be moved to `~/wallets/`
- now mark content as synced in doc - now mark content as synced in doc
@ -14,4 +15,4 @@ content that was put on Youtube since the last sync.
Add this to cron to delete synced videos that have been published: Add this to cron to delete synced videos that have been published:
`*/10 * * * * /usr/bin/find /tmp/ ! -readable -prune -o -name '*ytsync*' -mmin +20 -print0 | xargs -0 --no-run-if-empty rm -r` `*/10 * * * * (/bin/ls /tmp/ | /bin/grep -q ytsync && /usr/bin/find /tmp/ytsync* -mmin +20 -delete) || true

31
ytsync/count.go Normal file
View file

@ -0,0 +1,31 @@
package ytsync
import (
"net/http"
"github.com/go-errors/errors"
"google.golang.org/api/googleapi/transport"
"google.golang.org/api/youtube/v3"
)
func (s *Sync) CountVideos() (uint64, error) {
client := &http.Client{
Transport: &transport.APIKey{Key: s.YoutubeAPIKey},
}
service, err := youtube.New(client)
if err != nil {
return 0, errors.WrapPrefix(err, "error creating YouTube service", 0)
}
response, err := service.Channels.List("statistics").Id(s.YoutubeChannelID).Do()
if err != nil {
return 0, errors.WrapPrefix(err, "error getting channels", 0)
}
if len(response.Items) < 1 {
return 0, errors.New("youtube channel not found")
}
return response.Items[0].Statistics.VideoCount, nil
}

62
ytsync/redisdb/redisdb.go Normal file
View file

@ -0,0 +1,62 @@
package redisdb
import (
"time"
"github.com/garyburd/redigo/redis"
"github.com/go-errors/errors"
)
const (
redisHashKey = "ytsync"
redisSyncedVal = "t"
)
type DB struct {
pool *redis.Pool
}
func New() *DB {
var r DB
r.pool = &redis.Pool{
MaxIdle: 3,
IdleTimeout: 5 * time.Minute,
Dial: func() (redis.Conn, error) { return redis.Dial("tcp", ":6379") },
TestOnBorrow: func(c redis.Conn, t time.Time) error {
if time.Since(t) < time.Minute {
return nil
}
_, err := c.Do("PING")
return err
},
}
return &r
}
func (r DB) IsPublished(id string) (bool, error) {
conn := r.pool.Get()
defer conn.Close()
alreadyPublished, err := redis.String(conn.Do("HGET", redisHashKey, id))
if err != nil && err != redis.ErrNil {
return false, errors.WrapPrefix(err, "redis error", 0)
}
if alreadyPublished == redisSyncedVal {
return true, nil
}
return false, nil
}
func (r DB) SetPublished(id string) error {
conn := r.pool.Get()
defer conn.Close()
_, err := redis.Bool(conn.Do("HSET", redisHashKey, id, redisSyncedVal))
if err != nil {
return errors.New("redis error: " + err.Error())
}
return nil
}

257
ytsync/setup.go Normal file
View file

@ -0,0 +1,257 @@
package ytsync
import (
"strings"
"time"
"github.com/lbryio/lbry.go/jsonrpc"
"github.com/lbryio/lbry.go/lbrycrd"
"github.com/go-errors/errors"
"github.com/shopspring/decimal"
log "github.com/sirupsen/logrus"
)
func (s *Sync) walletSetup() error {
balanceResp, err := s.daemon.WalletBalance()
if err != nil {
return err
} else if balanceResp == nil {
return errors.New("no response")
}
balance := decimal.Decimal(*balanceResp)
log.Debugf("Starting balance is %s", balance.String())
numOnSource, err := s.CountVideos()
if err != nil {
return err
}
log.Debugf("Source channel has %d videos", numOnSource)
numPublished := uint64(0)
if s.LbryChannelName != "" {
numPublished, err = s.daemon.NumClaimsInChannel(s.LbryChannelName)
if err != nil {
return err
}
}
log.Debugf("We already published %d videos", numPublished)
minBalance := (float64(numOnSource)-float64(numPublished))*publishAmount + channelClaimAmount
amountToAdd, _ := decimal.NewFromFloat(minBalance).Sub(balance).Float64()
if amountToAdd > 0 {
addressResp, err := s.daemon.WalletUnusedAddress()
if err != nil {
return err
} else if addressResp == nil {
return errors.New("no response")
}
address := string(*addressResp)
amountToAdd *= 1.5 // add 50% margin for fees, future publishes, etc
log.Printf("Adding %f credits", amountToAdd)
lbrycrdd, err := lbrycrd.NewWithDefaultURL()
if err != nil {
return err
}
_, err = lbrycrdd.SimpleSend(address, amountToAdd)
if err != nil {
return err
}
wait := 15 * time.Second
log.Println("Waiting " + wait.String() + " for lbryum to let us know we have the new transaction")
time.Sleep(wait)
log.Println("Waiting for transaction to be confirmed")
err = s.waitUntilUTXOsConfirmed()
if err != nil {
return err
}
}
claimAddress, err := s.daemon.WalletUnusedAddress()
if err != nil {
return err
} else if claimAddress == nil {
return errors.New("could not get unused address")
}
s.claimAddress = string(*claimAddress)
if s.claimAddress == "" {
return errors.New("found blank claim address")
}
err = s.ensureEnoughUTXOs()
if err != nil {
return err
}
if s.LbryChannelName != "" {
err = s.ensureChannelOwnership()
if err != nil {
return err
}
}
balanceResp, err = s.daemon.WalletBalance()
if err != nil {
return err
} else if balanceResp == nil {
return errors.New("no response")
}
log.Println("starting with " + decimal.Decimal(*balanceResp).String() + "LBC")
return nil
}
func (s *Sync) ensureEnoughUTXOs() error {
utxolist, err := s.daemon.UTXOList()
if err != nil {
return err
} else if utxolist == nil {
return errors.New("no response")
}
if !allUTXOsConfirmed(utxolist) {
log.Println("Waiting for previous txns to confirm") // happens if you restarted the daemon soon after a previous publish run
s.waitUntilUTXOsConfirmed()
}
target := 50
count := 0
for _, utxo := range *utxolist {
if !utxo.IsClaim && !utxo.IsSupport && !utxo.IsUpdate && utxo.Amount.Cmp(decimal.New(0, 0)) == 1 {
count++
}
}
if count < target {
newAddresses := target - count
balance, err := s.daemon.WalletBalance()
if err != nil {
return err
} else if balance == nil {
return errors.New("no response")
}
log.Println("balance is " + decimal.Decimal(*balance).String())
amountPerAddress := decimal.Decimal(*balance).Div(decimal.NewFromFloat(float64(target)))
log.Infof("Putting %s credits into each of %d new addresses", amountPerAddress.String(), newAddresses)
prefillTx, err := s.daemon.WalletPrefillAddresses(newAddresses, amountPerAddress, true)
if err != nil {
return err
} else if prefillTx == nil {
return errors.New("no response")
} else if !prefillTx.Complete || !prefillTx.Broadcast {
return errors.New("failed to prefill addresses")
}
wait := 15 * time.Second
log.Println("Waiting " + wait.String() + " for lbryum to let us know we have the new addresses")
time.Sleep(wait)
log.Println("Creating UTXOs and waiting for them to be confirmed")
err = s.waitUntilUTXOsConfirmed()
if err != nil {
return err
}
}
return nil
}
func (s *Sync) waitUntilUTXOsConfirmed() error {
for {
r, err := s.daemon.UTXOList()
if err != nil {
return err
} else if r == nil {
return errors.New("no response")
}
if allUTXOsConfirmed(r) {
return nil
}
wait := 30 * time.Second
log.Println("Waiting " + wait.String() + "...")
time.Sleep(wait)
}
}
func (s *Sync) ensureChannelOwnership() error {
if s.LbryChannelName == "" {
return errors.New("no channel name set")
}
channels, err := s.daemon.ChannelListMine()
if err != nil {
return err
} else if channels == nil {
return errors.New("no channel response")
}
isChannelMine := false
for _, channel := range *channels {
if channel.Name == s.LbryChannelName {
isChannelMine = true
} else {
return errors.New("this wallet has multiple channels. maybe something went wrong during setup?")
}
}
if isChannelMine {
return nil
}
resolveResp, err := s.daemon.Resolve(s.LbryChannelName)
if err != nil {
return err
}
channel := (*resolveResp)[s.LbryChannelName]
channelBidAmount := channelClaimAmount
channelNotFound := channel.Error != nil && strings.Contains(*(channel.Error), "cannot be resolved")
if !channelNotFound {
if !s.TakeOverExistingChannel {
return errors.New("Channel exists and we don't own it. Pick another channel.")
}
log.Println("Channel exists and we don't own it. Outbidding existing claim.")
channelBidAmount, _ = channel.Certificate.Amount.Add(decimal.NewFromFloat(channelClaimAmount)).Float64()
}
_, err = s.daemon.ChannelNew(s.LbryChannelName, channelBidAmount)
if err != nil {
return err
}
// niko's code says "unfortunately the queues in the daemon are not yet merged so we must give it some time for the channel to go through"
wait := 15 * time.Second
log.Println("Waiting " + wait.String() + " for channel claim to go through")
time.Sleep(wait)
return nil
}
func allUTXOsConfirmed(utxolist *jsonrpc.UTXOListResponse) bool {
if utxolist == nil {
return false
}
if len(*utxolist) < 1 {
return false
}
for _, utxo := range *utxolist {
if utxo.Height == 0 {
return false
}
}
return true
}

View file

@ -0,0 +1,212 @@
package sources
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/lbryio/lbry.go/jsonrpc"
"github.com/go-errors/errors"
ytdl "github.com/kkdai/youtube"
log "github.com/sirupsen/logrus"
"google.golang.org/api/youtube/v3"
)
type YoutubeVideo struct {
id string
channelTitle string
title string
description string
playlistPosition int64
publishedAt time.Time
dir string
}
func NewYoutubeVideo(directory string, snippet *youtube.PlaylistItemSnippet) YoutubeVideo {
publishedAt, _ := time.Parse(time.RFC3339Nano, snippet.PublishedAt) // ignore parse errors
return YoutubeVideo{
id: snippet.ResourceId.VideoId,
title: snippet.Title,
description: snippet.Description,
channelTitle: snippet.ChannelTitle,
playlistPosition: snippet.Position,
publishedAt: publishedAt,
dir: directory,
}
}
func (v YoutubeVideo) ID() string {
return v.id
}
func (v YoutubeVideo) IDAndNum() string {
return v.ID() + " (" + strconv.Itoa(int(v.playlistPosition)) + " in channel)"
}
func (v YoutubeVideo) PublishedAt() time.Time {
return v.publishedAt
}
func (v YoutubeVideo) getFilename() string {
return v.dir + "/" + v.id + ".mp4"
}
func (v YoutubeVideo) getClaimName() string {
maxLen := 40
reg := regexp.MustCompile(`[^a-zA-Z0-9]+`)
chunks := strings.Split(strings.ToLower(strings.Trim(reg.ReplaceAllString(v.title, "-"), "-")), "-")
name := chunks[0]
if len(name) > maxLen {
return name[:maxLen]
}
for _, chunk := range chunks[1:] {
tmpName := name + "-" + chunk
if len(tmpName) > maxLen {
if len(name) < 20 {
name = tmpName[:maxLen]
}
break
}
name = tmpName
}
return name
}
func (v YoutubeVideo) getAbbrevDescription() string {
maxLines := 10
description := strings.TrimSpace(v.description)
if strings.Count(description, "\n") < maxLines {
return description
}
return strings.Join(strings.Split(description, "\n")[:maxLines], "\n") + "\n..."
}
func (v YoutubeVideo) Download() error {
videoPath := v.getFilename()
_, err := os.Stat(videoPath)
if err != nil && !os.IsNotExist(err) {
return err
} else if err == nil {
log.Debugln(v.id + " already exists at " + videoPath)
return nil
}
downloader := ytdl.NewYoutube(false)
err = downloader.DecodeURL("https://www.youtube.com/watch?v=" + v.id)
if err != nil {
return err
}
err = downloader.StartDownload(videoPath)
if err != nil {
return err
}
return nil
}
func (v YoutubeVideo) TriggerThumbnailSave() error {
client := &http.Client{Timeout: 30 * time.Second}
params, err := json.Marshal(map[string]string{"videoid": v.id})
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPut, "https://jgp4g1qoud.execute-api.us-east-1.amazonaws.com/prod/thumbnail", bytes.NewBuffer(params))
if err != nil {
return err
}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
contents, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
var decoded struct {
error int `json:"error"`
url string `json:"url,omitempty"`
message string `json:"message,omitempty"`
}
err = json.Unmarshal(contents, &decoded)
if err != nil {
return err
}
if decoded.error != 0 {
return errors.New("error creating thumbnail: " + decoded.message)
}
return nil
}
func strPtr(s string) *string { return &s }
func (v YoutubeVideo) Publish(daemon *jsonrpc.Client, claimAddress string, amount float64, channelName string) error {
options := jsonrpc.PublishOptions{
Title: &v.title,
Author: &v.channelTitle,
Description: strPtr(v.getAbbrevDescription() + "\nhttps://www.youtube.com/watch?v=" + v.id),
Language: strPtr("en"),
ClaimAddress: &claimAddress,
Thumbnail: strPtr("http://berk.ninja/thumbnails/" + v.id),
License: strPtr("Copyrighted (contact author)"),
}
if channelName != "" {
options.ChannelName = &channelName
}
_, err := daemon.Publish(v.getClaimName(), v.getFilename(), amount, options)
return err
}
func (v YoutubeVideo) Sync(daemon *jsonrpc.Client, claimAddress string, amount float64, channelName string) error {
//download and thumbnail can be done in parallel
err := v.Download()
if err != nil {
return errors.WrapPrefix(err, "download error", 0)
}
log.Debugln("Downloaded " + v.id)
err = v.TriggerThumbnailSave()
if err != nil {
return errors.WrapPrefix(err, "thumbnail error", 0)
}
log.Debugln("Created thumbnail for " + v.id)
err = v.Publish(daemon, claimAddress, amount, channelName)
if err != nil {
return errors.WrapPrefix(err, "publish error", 0)
}
return nil
}
// sorting videos
//type ByPublishedAt []YoutubeVideo
//
//func (a ByPublishedAt) Len() int { return len(a) }
//func (a ByPublishedAt) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
//func (a ByPublishedAt) Less(i, j int) bool { return a[i].publishedAt.Before(a[j].publishedAt) }
//
//type ByPlaylistPosition []YoutubeVideo
//
//func (a ByPlaylistPosition) Len() int { return len(a) }
//func (a ByPlaylistPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
//func (a ByPlaylistPosition) Less(i, j int) bool { return a[i].playlistPosition < a[j].playlistPosition }

View file

@ -1,69 +0,0 @@
package ytsync
import (
"regexp"
"strings"
"time"
)
type video struct {
id string
channelID string
channelTitle string
title string
description string
playlistPosition int64
publishedAt time.Time
dir string
}
func (v video) getFilename() string {
return v.dir + "/" + v.id + ".mp4"
}
func (v video) getClaimName() string {
maxLen := 40
reg := regexp.MustCompile(`[^a-zA-Z0-9]+`)
chunks := strings.Split(strings.ToLower(strings.Trim(reg.ReplaceAllString(v.title, "-"), "-")), "-")
name := chunks[0]
if len(name) > maxLen {
return name[:maxLen]
}
for _, chunk := range chunks[1:] {
tmpName := name + "-" + chunk
if len(tmpName) > maxLen {
if len(name) < 20 {
name = tmpName[:maxLen]
}
break
}
name = tmpName
}
return name
}
func (v video) getAbbrevDescription() string {
maxLines := 10
description := strings.TrimSpace(v.description)
if strings.Count(description, "\n") < maxLines {
return description
}
return strings.Join(strings.Split(description, "\n")[:maxLines], "\n") + "\n..."
}
// sorting videos
type byPublishedAt []video
func (a byPublishedAt) Len() int { return len(a) }
func (a byPublishedAt) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a byPublishedAt) Less(i, j int) bool { return a[i].publishedAt.Before(a[j].publishedAt) }
type byPlaylistPosition []video
func (a byPlaylistPosition) Len() int { return len(a) }
func (a byPlaylistPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a byPlaylistPosition) Less(i, j int) bool { return a[i].playlistPosition < a[j].playlistPosition }

View file

@ -1,38 +1,47 @@
package ytsync package ytsync
import ( import (
"bytes"
"encoding/json"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"os/signal"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "syscall"
"time" "time"
"github.com/lbryio/lbry.go/jsonrpc" "github.com/lbryio/lbry.go/jsonrpc"
"github.com/lbryio/lbry.go/stopOnce"
"github.com/lbryio/lbry.go/ytsync/redisdb"
"github.com/lbryio/lbry.go/ytsync/sources"
"github.com/garyburd/redigo/redis"
"github.com/go-errors/errors" "github.com/go-errors/errors"
ytdl "github.com/kkdai/youtube"
"github.com/lbryio/lbry.go/lbrycrd"
"github.com/shopspring/decimal"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"google.golang.org/api/googleapi/transport" "google.golang.org/api/googleapi/transport"
"google.golang.org/api/youtube/v3" "google.golang.org/api/youtube/v3"
) )
const ( const (
redisHashKey = "ytsync"
redisSyncedVal = "t"
channelClaimAmount = 0.01 channelClaimAmount = 0.01
publishAmount = 0.01 publishAmount = 0.01
) )
type video interface {
ID() string
IDAndNum() string
PublishedAt() time.Time
Sync(*jsonrpc.Client, string, float64, string) error
}
// sorting videos
type byPublishedAt []video
func (a byPublishedAt) Len() int { return len(a) }
func (a byPublishedAt) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a byPublishedAt) Less(i, j int) bool { return a[i].PublishedAt().Before(a[j].PublishedAt()) }
// Sync stores the options that control how syncing happens // Sync stores the options that control how syncing happens
type Sync struct { type Sync struct {
YoutubeAPIKey string YoutubeAPIKey string
@ -46,117 +55,28 @@ type Sync struct {
daemon *jsonrpc.Client daemon *jsonrpc.Client
claimAddress string claimAddress string
videoDirectory string videoDirectory string
redisPool *redis.Pool db *redisdb.DB
}
func (s *Sync) initDaemon() { stop *stopOnce.Stopper
if s.daemon == nil {
s.daemon = jsonrpc.NewClient("")
log.Infoln("Waiting for daemon to finish starting...")
for {
_, err := s.daemon.WalletBalance()
if err == nil {
break
}
time.Sleep(5 * time.Second)
}
}
}
func (s *Sync) init() error { wg sync.WaitGroup
var err error queue chan video
s.redisPool = &redis.Pool{
MaxIdle: 3,
IdleTimeout: 5 * time.Minute,
Dial: func() (redis.Conn, error) { return redis.Dial("tcp", ":6379") },
TestOnBorrow: func(c redis.Conn, t time.Time) error {
if time.Since(t) < time.Minute {
return nil
}
_, err := c.Do("PING")
return err
},
}
s.videoDirectory, err = ioutil.TempDir("", "ytsync")
if err != nil {
return errors.Wrap(err, 0)
}
s.initDaemon()
address, err := s.daemon.WalletUnusedAddress()
if err != nil {
return err
} else if address == nil {
return errors.New("could not get unused address")
}
s.claimAddress = string(*address)
if s.claimAddress == "" {
return errors.New("found blank claim address")
}
err = s.ensureEnoughUTXOs()
if err != nil {
return err
}
if s.LbryChannelName != "" {
err = s.ensureChannelOwnership()
if err != nil {
return err
}
}
balance, err := s.daemon.WalletBalance()
if err != nil {
return err
} else if balance == nil {
return errors.New("no response")
}
log.Println("starting with " + decimal.Decimal(*balance).String() + "LBC")
return nil
}
func (s *Sync) CountVideos() (uint64, error) {
client := &http.Client{
Transport: &transport.APIKey{Key: s.YoutubeAPIKey},
}
service, err := youtube.New(client)
if err != nil {
return 0, errors.WrapPrefix(err, "error creating YouTube service", 0)
}
response, err := service.Channels.List("statistics").Id(s.YoutubeChannelID).Do()
if err != nil {
return 0, errors.WrapPrefix(err, "error getting channels", 0)
}
if len(response.Items) < 1 {
return 0, errors.New("youtube channel not found")
}
return response.Items[0].Statistics.VideoCount, nil
} }
func (s *Sync) FullCycle() error { func (s *Sync) FullCycle() error {
var err error
if os.Getenv("HOME") == "" { if os.Getenv("HOME") == "" {
return errors.New("no $HOME env var found") return errors.New("no $HOME env var found")
} }
newChannel := true
defaultWalletDir := os.Getenv("HOME") + "/.lbryum/wallets/default_wallet" defaultWalletDir := os.Getenv("HOME") + "/.lbryum/wallets/default_wallet"
walletBackupDir := os.Getenv("HOME") + "/wallets/" + strings.Replace(s.LbryChannelName, "@", "", 1) walletBackupDir := os.Getenv("HOME") + "/wallets/" + strings.Replace(s.LbryChannelName, "@", "", 1)
if _, err := os.Stat(walletBackupDir); !os.IsNotExist(err) { if _, err = os.Stat(walletBackupDir); !os.IsNotExist(err) {
if _, err := os.Stat(defaultWalletDir); !os.IsNotExist(err) { if _, err := os.Stat(defaultWalletDir); !os.IsNotExist(err) {
return errors.New("Tried to continue previous upload, but default_wallet already exists") return errors.New("Tried to continue previous upload, but default_wallet already exists")
} }
newChannel = false
err = os.Rename(walletBackupDir, defaultWalletDir) err = os.Rename(walletBackupDir, defaultWalletDir)
if err != nil { if err != nil {
return errors.Wrap(err, 0) return errors.Wrap(err, 0)
@ -164,291 +84,136 @@ func (s *Sync) FullCycle() error {
log.Println("Continuing previous upload") log.Println("Continuing previous upload")
} }
err := s.startDaemonViaSystemd() defer func() {
if err != nil { log.Printf("Stopping daemon")
return err shutdownErr := stopDaemonViaSystemd()
} if shutdownErr != nil {
log.Errorf("error shutting down daemon: %v", shutdownErr)
if newChannel { log.Errorf("WALLET HAS NOT BEEN MOVED TO THE WALLET BACKUP DIR", shutdownErr)
s.initDaemon() } else {
walletErr := os.Rename(defaultWalletDir, walletBackupDir)
addressResp, err := s.daemon.WalletUnusedAddress() if walletErr != nil {
if err != nil { log.Errorf("error moving wallet to backup dir: %v", walletErr)
return err }
} else if addressResp == nil {
return errors.New("no response")
} }
address := string(*addressResp) }()
count, err := s.CountVideos() s.videoDirectory, err = ioutil.TempDir("", "ytsync")
if err != nil {
return err
}
initialAmount := float64(count)*publishAmount + channelClaimAmount
initialAmount += initialAmount * 0.1 // add 10% margin for fees, etc
log.Printf("Loading wallet with %f initial credits", initialAmount)
lbrycrdd, err := lbrycrd.NewWithDefaultURL()
if err != nil {
return err
}
lbrycrdd.SimpleSend(address, initialAmount)
//lbrycrdd.SendWithSplit(address, initialAmount, 50)
wait := 15 * time.Second
log.Println("Waiting " + wait.String() + " for lbryum to let us know we have the new transaction")
time.Sleep(wait)
log.Println("Waiting for transaction to be confirmed")
err = s.waitUntilUTXOsConfirmed()
if err != nil {
return err
}
}
err = s.Go()
if err != nil {
return err
}
// wait for reflection to finish???
wait := 15 * time.Second // should bump this up to a few min, but keeping it low for testing
log.Println("Waiting " + wait.String() + " to finish reflecting everything")
time.Sleep(wait)
log.Printf("Stopping daemon")
err = s.stopDaemonViaSystemd()
if err != nil {
return err
}
err = os.Rename(defaultWalletDir, walletBackupDir)
if err != nil { if err != nil {
return errors.Wrap(err, 0) return errors.Wrap(err, 0)
} }
s.db = redisdb.New()
s.stop = stopOnce.New()
s.queue = make(chan video)
interruptChan := make(chan os.Signal, 1)
signal.Notify(interruptChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-interruptChan
s.stop.Stop()
}()
log.Printf("Starting daemon")
err = startDaemonViaSystemd()
if err != nil {
return err
}
log.Infoln("Waiting for daemon to finish starting...")
s.daemon = jsonrpc.NewClientAndWait("")
err = s.doSync()
if err != nil {
return err
} else {
// wait for reflection to finish???
wait := 15 * time.Second // should bump this up to a few min, but keeping it low for testing
log.Println("Waiting " + wait.String() + " to finish reflecting everything")
time.Sleep(wait)
}
return nil return nil
} }
func (s *Sync) Go() error { func (s *Sync) doSync() error {
var err error var err error
err = s.init() err = s.walletSetup()
if err != nil { if err != nil {
return err return err
} }
var wg sync.WaitGroup
videoQueue := make(chan video)
queueStopChan := make(chan struct{})
sendStopEnqueuing := sync.Once{}
var videoErrored atomic.Value
videoErrored.Store(false)
if s.StopOnError { if s.StopOnError {
log.Println("Will stop publishing if an error is detected") log.Println("Will stop publishing if an error is detected")
} }
for i := 0; i < s.ConcurrentVideos; i++ { for i := 0; i < s.ConcurrentVideos; i++ {
go func() { go s.startWorker(i)
wg.Add(1)
defer wg.Done()
for {
v, more := <-videoQueue
if !more {
return
}
if s.StopOnError && videoErrored.Load().(bool) {
log.Println("Video errored. Exiting")
return
}
log.Println("========================================")
tryCount := 0
for {
tryCount++
err := s.processVideo(v)
if err != nil {
log.Errorln("error processing video: " + err.Error())
if s.StopOnError {
videoErrored.Store(true)
sendStopEnqueuing.Do(func() {
queueStopChan <- struct{}{}
})
} else if s.MaxTries > 1 {
if strings.Contains(err.Error(), "non 200 status code received") ||
strings.Contains(err.Error(), " reason: 'This video contains content from") ||
strings.Contains(err.Error(), "Playback on other websites has been disabled by the video owner") {
log.Println("This error should not be retried at all")
} else if tryCount >= s.MaxTries {
log.Println("Video failed after " + strconv.Itoa(s.MaxTries) + " retries, exiting")
videoErrored.Store(true)
sendStopEnqueuing.Do(func() {
queueStopChan <- struct{}{}
})
} else {
log.Println("Retrying")
continue
}
}
}
break
}
}
}()
} }
err = s.enqueueVideosFromChannel(s.YoutubeChannelID, &videoQueue, &queueStopChan) err = s.enqueueVideos()
close(videoQueue) close(s.queue)
wg.Wait() s.wg.Wait()
return err return err
} }
func allUTXOsConfirmed(utxolist *jsonrpc.UTXOListResponse) bool { func (s *Sync) startWorker(workerNum int) {
if utxolist == nil { s.wg.Add(1)
return false defer s.wg.Done()
}
if len(*utxolist) < 1 { var v video
return false var more bool
} else {
for _, utxo := range *utxolist {
if utxo.Height == 0 {
return false
}
}
}
return true
}
func (s *Sync) ensureEnoughUTXOs() error {
utxolist, err := s.daemon.UTXOList()
if err != nil {
return err
} else if utxolist == nil {
return errors.New("no response")
}
if !allUTXOsConfirmed(utxolist) {
log.Println("Waiting for previous txns to confirm") // happens if you restarted the daemon soon after a previous publish run
s.waitUntilUTXOsConfirmed()
}
target := 50
count := 0
for _, utxo := range *utxolist {
if !utxo.IsClaim && !utxo.IsSupport && !utxo.IsUpdate && utxo.Amount.Cmp(decimal.New(0, 0)) == 1 {
count++
}
}
if count < target {
newAddresses := target - count
balance, err := s.daemon.WalletBalance()
if err != nil {
return err
} else if balance == nil {
return errors.New("no response")
}
log.Println("balance is " + decimal.Decimal(*balance).String())
amountPerAddress := decimal.Decimal(*balance).Div(decimal.NewFromFloat(float64(target)))
log.Infof("Putting %s credits into each of %d new addresses", amountPerAddress.String(), newAddresses)
prefillTx, err := s.daemon.WalletPrefillAddresses(newAddresses, amountPerAddress, true)
if err != nil {
return err
} else if prefillTx == nil {
return errors.New("no response")
} else if !prefillTx.Complete || !prefillTx.Broadcast {
return errors.New("failed to prefill addresses")
}
wait := 15 * time.Second
log.Println("Waiting " + wait.String() + " for lbryum to let us know we have the new addresses")
time.Sleep(wait)
log.Println("Creating UTXOs and waiting for them to be confirmed")
err = s.waitUntilUTXOsConfirmed()
if err != nil {
return err
}
}
return nil
}
func (s *Sync) waitUntilUTXOsConfirmed() error {
for { for {
r, err := s.daemon.UTXOList() select {
if err != nil { case <-s.stop.Chan():
return err log.Printf("Stopping worker %d", workerNum)
} else if r == nil { return
return errors.New("no response") default:
} }
if allUTXOsConfirmed(r) { select {
return nil case v, more = <-s.queue:
if !more {
return
}
case <-s.stop.Chan():
log.Printf("Stopping worker %d", workerNum)
return
} }
wait := 30 * time.Second log.Println("========================================")
log.Println("Waiting " + wait.String() + "...")
time.Sleep(wait) tryCount := 0
for {
tryCount++
err := s.processVideo(v)
if err != nil {
log.Errorln("error processing video: " + err.Error())
if s.StopOnError {
s.stop.Stop()
} else if s.MaxTries > 1 {
if strings.Contains(err.Error(), "non 200 status code received") ||
strings.Contains(err.Error(), " reason: 'This video contains content from") ||
strings.Contains(err.Error(), "Playback on other websites has been disabled by the video owner") {
log.Println("This error should not be retried at all")
} else if tryCount >= s.MaxTries {
log.Printf("Video failed after %d retries, exiting", s.MaxTries)
s.stop.Stop()
} else {
log.Println("Retrying")
continue
}
}
}
break
}
} }
} }
func (s *Sync) ensureChannelOwnership() error { func (s *Sync) enqueueVideos() error {
channels, err := s.daemon.ChannelListMine()
if err != nil {
return err
} else if channels == nil {
return errors.New("no channels")
}
for _, channel := range *channels {
if channel.Name == s.LbryChannelName {
return nil
}
}
resolveResp, err := s.daemon.Resolve(s.LbryChannelName)
if err != nil {
return err
}
channel := (*resolveResp)[s.LbryChannelName]
channelBidAmount := channelClaimAmount
channelNotFound := channel.Error != nil && strings.Contains(*(channel.Error), "cannot be resolved")
if !channelNotFound {
if !s.TakeOverExistingChannel {
return errors.New("Channel exists and we don't own it. Pick another channel.")
}
log.Println("Channel exists and we don't own it. Outbidding existing claim.")
channelBidAmount, _ = channel.Certificate.Amount.Add(decimal.NewFromFloat(channelClaimAmount)).Float64()
}
_, err = s.daemon.ChannelNew(s.LbryChannelName, channelBidAmount)
if err != nil {
return err
}
// niko's code says "unfortunately the queues in the daemon are not yet merged so we must give it some time for the channel to go through"
wait := 15 * time.Second
log.Println("Waiting " + wait.String() + " for channel claim to go through")
time.Sleep(wait)
return nil
}
func (s *Sync) enqueueVideosFromChannel(channelID string, videoChan *chan video, queueStopChan *chan struct{}) error {
client := &http.Client{ client := &http.Client{
Transport: &transport.APIKey{Key: s.YoutubeAPIKey}, Transport: &transport.APIKey{Key: s.YoutubeAPIKey},
} }
@ -458,7 +223,7 @@ func (s *Sync) enqueueVideosFromChannel(channelID string, videoChan *chan video,
return errors.WrapPrefix(err, "error creating YouTube service", 0) return errors.WrapPrefix(err, "error creating YouTube service", 0)
} }
response, err := service.Channels.List("contentDetails").Id(channelID).Do() response, err := service.Channels.List("contentDetails").Id(s.YoutubeChannelID).Do()
if err != nil { if err != nil {
return errors.WrapPrefix(err, "error getting channels", 0) return errors.WrapPrefix(err, "error getting channels", 0)
} }
@ -476,7 +241,7 @@ func (s *Sync) enqueueVideosFromChannel(channelID string, videoChan *chan video,
return errors.New("no channel playlist") return errors.New("no channel playlist")
} }
videos := []video{} var videos []video
nextPageToken := "" nextPageToken := ""
for { for {
@ -496,26 +261,13 @@ func (s *Sync) enqueueVideosFromChannel(channelID string, videoChan *chan video,
for _, item := range playlistResponse.Items { for _, item := range playlistResponse.Items {
// todo: there's thumbnail info here. why did we need lambda??? // todo: there's thumbnail info here. why did we need lambda???
publishedAt, err := time.Parse(time.RFC3339Nano, item.Snippet.PublishedAt)
if err != nil {
return errors.WrapPrefix(err, "failed to parse time", 0)
}
// normally we'd send the video into the channel here, but youtube api doesn't have sorting // normally we'd send the video into the channel here, but youtube api doesn't have sorting
// so we have to get ALL the videos, then sort them, then send them in // so we have to get ALL the videos, then sort them, then send them in
videos = append(videos, video{ videos = append(videos, sources.NewYoutubeVideo(s.videoDirectory, item.Snippet))
id: item.Snippet.ResourceId.VideoId,
channelID: channelID,
title: item.Snippet.Title,
description: item.Snippet.Description,
channelTitle: item.Snippet.ChannelTitle,
playlistPosition: item.Snippet.Position,
publishedAt: publishedAt,
dir: s.videoDirectory,
})
} }
log.Infoln("Got info for " + strconv.Itoa(len(videos)) + " videos from youtube API") log.Infof("Got info for %d videos from youtube API", len(videos))
nextPageToken = playlistResponse.NextPageToken nextPageToken = playlistResponse.NextPageToken
if nextPageToken == "" { if nextPageToken == "" {
@ -529,8 +281,14 @@ func (s *Sync) enqueueVideosFromChannel(channelID string, videoChan *chan video,
Enqueue: Enqueue:
for _, v := range videos { for _, v := range videos {
select { select {
case *videoChan <- v: case <-s.stop.Chan():
case <-*queueStopChan: break Enqueue
default:
}
select {
case s.queue <- v:
case <-s.stop.Chan():
break Enqueue break Enqueue
} }
} }
@ -539,141 +297,35 @@ Enqueue:
} }
func (s *Sync) processVideo(v video) error { func (s *Sync) processVideo(v video) error {
log.Println("Processing " + v.id + " (" + strconv.Itoa(int(v.playlistPosition)) + " in channel)") log.Println("Processing " + v.IDAndNum())
defer func(start time.Time) { defer func(start time.Time) {
log.Println(v.id + " took " + time.Since(start).String()) log.Println(v.ID() + " took " + time.Since(start).String())
}(time.Now()) }(time.Now())
conn := s.redisPool.Get() alreadyPublished, err := s.db.IsPublished(v.ID())
defer conn.Close() if err != nil {
return err
alreadyPublished, err := redis.String(conn.Do("HGET", redisHashKey, v.id))
if err != nil && err != redis.ErrNil {
return errors.WrapPrefix(err, "redis error", 0)
} }
if alreadyPublished == redisSyncedVal {
log.Println(v.id + " already published") if alreadyPublished {
log.Println(v.ID() + " already published")
return nil return nil
} }
//download and thumbnail can be done in parallel err = v.Sync(s.daemon, s.claimAddress, publishAmount, s.LbryChannelName)
err = downloadVideo(v)
if err != nil { if err != nil {
return errors.WrapPrefix(err, "download error", 0) return err
} }
err = triggerThumbnailSave(v.id) err = s.db.SetPublished(v.ID())
if err != nil { if err != nil {
return errors.WrapPrefix(err, "thumbnail error", 0) return err
}
err = s.publish(v, conn)
if err != nil {
return errors.WrapPrefix(err, "publish error", 0)
} }
return nil return nil
} }
func downloadVideo(v video) error { func startDaemonViaSystemd() error {
verbose := false
videoPath := v.getFilename()
_, err := os.Stat(videoPath)
if err != nil && !os.IsNotExist(err) {
return err
} else if err == nil {
log.Println(v.id + " already exists at " + videoPath)
return nil
}
downloader := ytdl.NewYoutube(verbose)
err = downloader.DecodeURL("https://www.youtube.com/watch?v=" + v.id)
if err != nil {
return err
}
err = downloader.StartDownload(videoPath)
if err != nil {
return err
}
log.Debugln("Downloaded " + v.id)
return nil
}
func triggerThumbnailSave(videoID string) error {
client := &http.Client{Timeout: 30 * time.Second}
params, err := json.Marshal(map[string]string{"videoid": videoID})
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPut, "https://jgp4g1qoud.execute-api.us-east-1.amazonaws.com/prod/thumbnail", bytes.NewBuffer(params))
if err != nil {
return err
}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
contents, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
var decoded struct {
error int `json:"error"`
url string `json:"url,omitempty"`
message string `json:"message,omitempty"`
}
err = json.Unmarshal(contents, &decoded)
if err != nil {
return err
}
if decoded.error != 0 {
return errors.New("error creating thumbnail: " + decoded.message)
}
log.Debugln("Created thumbnail for " + videoID)
return nil
}
func strPtr(s string) *string { return &s }
func (s *Sync) publish(v video, conn redis.Conn) error {
options := jsonrpc.PublishOptions{
Title: &v.title,
Author: &v.channelTitle,
Description: strPtr(v.getAbbrevDescription() + "\nhttps://www.youtube.com/watch?v=" + v.id),
Language: strPtr("en"),
ClaimAddress: &s.claimAddress,
Thumbnail: strPtr("http://berk.ninja/thumbnails/" + v.id),
License: strPtr("Copyrighted (contact author)"),
}
if s.LbryChannelName != "" {
options.ChannelName = &s.LbryChannelName
}
_, err := s.daemon.Publish(v.getClaimName(), v.getFilename(), publishAmount, options)
if err != nil {
return err
}
_, err = redis.Bool(conn.Do("HSET", redisHashKey, v.id, redisSyncedVal))
if err != nil {
return errors.New("redis error: " + err.Error())
}
return nil
}
func (s *Sync) startDaemonViaSystemd() error {
err := exec.Command("/usr/bin/sudo", "/bin/systemctl", "start", "lbrynet.service").Run() err := exec.Command("/usr/bin/sudo", "/bin/systemctl", "start", "lbrynet.service").Run()
if err != nil { if err != nil {
return errors.New(err) return errors.New(err)
@ -681,7 +333,7 @@ func (s *Sync) startDaemonViaSystemd() error {
return nil return nil
} }
func (s *Sync) stopDaemonViaSystemd() error { func stopDaemonViaSystemd() error {
err := exec.Command("/usr/bin/sudo", "/bin/systemctl", "stop", "lbrynet.service").Run() err := exec.Command("/usr/bin/sudo", "/bin/systemctl", "stop", "lbrynet.service").Run()
if err != nil { if err != nil {
return errors.New(err) return errors.New(err)