From 9cfe2edb12cb06f9a0dc840d6b05a5c84f774ff6 Mon Sep 17 00:00:00 2001 From: himananiito Date: Wed, 2 Jan 2019 03:12:44 +0900 Subject: [PATCH] livedl2 first commit --- Readme.md | 26 +- replacelocal.pl | 4 +- run.ps1 | 4 + src/httpcommon/httpcommon.go | 92 +++++ src/livedl2.go | 520 ++++++++++++++++++++++++ src/niconico/api/action_track_id.go | 54 +++ src/niconico/find/find.go | 267 ++++++++++++ src/niconico/nicocas.go | 1 + src/niconico/nicocas/cas.go | 518 +++++++++++++++++++++++ src/niconico/nicocas/media.go | 130 ++++++ src/niconico/nicocas/playlist.go | 424 +++++++++++++++++++ src/niconico/nicocas/watching.go | 236 +++++++++++ src/niconico/nicocasprop/nicocasprop.go | 41 ++ src/niconico/nicodb/nico_db.go | 505 +++++++++++++++++++++++ src/niconico/nicoprop/nicoprop.go | 57 +++ src/niconico/property/nico.go | 10 + view/index.html | 375 +++++++++++++++++ view/template1.html | 5 + 18 files changed, 3263 insertions(+), 6 deletions(-) create mode 100644 run.ps1 create mode 100644 src/httpcommon/httpcommon.go create mode 100644 src/livedl2.go create mode 100644 src/niconico/api/action_track_id.go create mode 100644 src/niconico/find/find.go create mode 100644 src/niconico/nicocas.go create mode 100644 src/niconico/nicocas/cas.go create mode 100644 src/niconico/nicocas/media.go create mode 100644 src/niconico/nicocas/playlist.go create mode 100644 src/niconico/nicocas/watching.go create mode 100644 src/niconico/nicocasprop/nicocasprop.go create mode 100644 src/niconico/nicodb/nico_db.go create mode 100644 src/niconico/nicoprop/nicoprop.go create mode 100644 src/niconico/property/nico.go create mode 100644 view/index.html create mode 100644 view/template1.html diff --git a/Readme.md b/Readme.md index 6351dc4..3c553bb 100644 --- a/Readme.md +++ b/Readme.md @@ -1,10 +1,28 @@ -# livedl -新配信(HTML5)に対応したニコ生録画ツール。ニコ生以外のサイトにも対応予定 +# livedl2 αバージョン + +次期バージョンとなる予定のlivedl2の実験的機能評価用バージョンです。 ## 使い方 -https://himananiito.hatenablog.jp/entry/livedl -を参照 +`user-session.txt`にユーザーセッション情報(`user_session_XXXX_XXXXXX`)を書いて保存 + +livedl2の実行ファイルを起動し、 +http://localhost:8080/ +にアクセスして下さい。 + +### セッション情報の調べ方 + +**セッション情報は「絶対に」他人に流出しないようにして下さい** + +例えばログイン済みのブラウザでニコ生トップページからデバッグコンソールを開き、以下のスクリプトを実行し、ブラウザ画面の文字列をコピーする。 + +``` +var match = document.cookie.match(/user_session=(\w+)/); if(match) document.write(match[1]); +``` + +## ビルド方法 + +以下の`lived.go`を`livedl2.go`に読み換えて下さい。 ## Linux(Ubuntu)でのビルド方法 ``` diff --git a/replacelocal.pl b/replacelocal.pl index b1084bc..38d599a 100644 --- a/replacelocal.pl +++ b/replacelocal.pl @@ -3,7 +3,7 @@ use strict; use v5.20; -for my $file("livedl.exe", "livedl.x86.exe", "livedl-logger.exe") { +for my $file("livedl2.exe") { open my $f, "<:raw", $file or die; undef $/; my $s = <$f>; @@ -16,7 +16,7 @@ while($s =~ m{(?<=\0)[^\0]{5,512}\.go(?=\0)|(?<=[[:cntrl:]])_/[A-Z]_/[^\0]{5,512}}g) { my $s = $&; if($s =~ m{\A(.*(?:/Users/.+?/go/src|/Go/src))(/.*)\z}s or - $s =~ m{\A(.*(?=/livedl/src/))(/.*)\z}s) { + $s =~ m{\A(.*(?=/livedl[^/]*/src/))(/.*)\z}s) { my($all, $p, $f) = ($s, $1, $2); my $p2 = $p; diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..f275424 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,4 @@ +go build src/livedl2.go +if ($?) { + ./livedl2 +} diff --git a/src/httpcommon/httpcommon.go b/src/httpcommon/httpcommon.go new file mode 100644 index 0000000..9190078 --- /dev/null +++ b/src/httpcommon/httpcommon.go @@ -0,0 +1,92 @@ +package httpcommon + +import ( + "errors" + "net/http" + "strings" + "sync" + "time" +) + +type Callback func(*http.Response, error, interface{}, interface{}, time.Time, time.Time) + +type HttpWork struct { + Client *http.Client + Request *http.Request + Callback Callback + This interface{} + QueuedAt time.Time + Option interface{} +} + +func GetClient() *http.Client { + var client = &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) (err error) { + if req != nil && via != nil && len(via) > 0 { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + + // 元のRequest.URLでリダイレクト後のURLを取れるようにしたい + via[0].URL = req.URL + + // ニコニコならCookieを引き継ぐ + if strings.HasSuffix(req.URL.Host, ".nicovideo.jp") { + req.Header = via[0].Header + } + } + return nil + }, + } + return client +} + +func Launch(num int) (q chan HttpWork) { + q = make(chan HttpWork, 10) + var m sync.Mutex + + requestPerSec := 6.0 // [リクエスト数/秒] 超える場合に一定期間Sleepする + sleepTime := 500 * time.Millisecond // Sleep時間 + arrSize := 5 // サンプル数 + + arr := make([]int64, 0, arrSize) + + checkLimit := func(t time.Time) { + nano := t.UnixNano() + m.Lock() + defer m.Unlock() + + if len(arr) >= arrSize { + arr = arr[1:arrSize] + arr = append(arr, nano) + + diff := arr[arrSize-1] - arr[0] // total sec + delta := float64(len(arr)) / float64(diff) * 1000 * 1000 * 1000 // requests per sec + + //fmt.Printf("delta is %v\n", delta) + if delta >= requestPerSec { + arr = arr[:0] + time.Sleep(sleepTime) + //return true + } + } else { + arr = append(arr, nano) + } + //return false + } + + for i := 0; i < num; i++ { + go func() { + for htw := range q { + + startedAt := time.Now() + + checkLimit(startedAt) + + res, err := htw.Client.Do(htw.Request) + htw.Callback(res, err, htw.This, htw.Option, htw.QueuedAt, startedAt) + } + }() + } + return q +} diff --git a/src/livedl2.go b/src/livedl2.go new file mode 100644 index 0000000..4fa2464 --- /dev/null +++ b/src/livedl2.go @@ -0,0 +1,520 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/pprof" + _ "net/http/pprof" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" + + "./files" + "./httpcommon" + "./niconico/api" + "./niconico/find" + "./niconico/nicocas" + "github.com/gin-gonic/gin" +) + +type register struct { + Target string `form:"target"` + NicocasArchive bool `form:"nicocasArchive"` + StartPositionEn bool `form:"archive-position-en"` + StartPosition float64 `form:"startPositionSec"` + ArchiveWait float64 `form:"archive-wait"` +} + +var defaultArchiveWait = float64(2) + +type nicoFinder struct { + Community []string `json:"community"` + User []string `json:"user"` + Title []string `json:"title"` +} + +var nicoFinderFile = "nico-finder-setting.txt" + +func loadNicoFinder() (data nicoFinder) { + _, err := os.Stat(nicoFinderFile) + if err == nil { + f, err := os.Open(nicoFinderFile) + if err != nil { + fmt.Println(err) + return + } + defer f.Close() + + bs, err := ioutil.ReadAll(f) + if err != nil { + fmt.Println(err) + return + } + + err = json.Unmarshal(bs, &data) + if err != nil { + fmt.Println(err) + return + } + + } + + return +} + +var limit = "2019/01/20 23:59:59 JST" + +const layout = "2006/01/02 15:04:05 MST" + +var actionTrackIDFile = "action-track-id.txt" +var actionTrackID string + +var userSessionFile = "user-session.txt" +var userSession string + +func main() { + + limitTime, err := time.Parse(layout, limit) + if err != nil { + fmt.Println(err) + return + } + + now := time.Now() + if limitTime.Unix() < now.Unix() { + fmt.Println("kigengire") + return + } + + ctx := context.Background() + + // userSession + func() { + f, err := os.Open(userSessionFile) + if err != nil { + fmt.Println(err) + return + } + defer f.Close() + + fmt.Fscanln(f, &userSession) + }() + if userSession == "" { + fmt.Printf("%sにuser-session情報を保存して下さい", userSessionFile) + return + } + + // nico-actiontrackid + func() { + f, err := os.Open(actionTrackIDFile) + if err != nil { + fmt.Println(err) + } + fmt.Fscanln(f, &actionTrackID) + defer f.Close() + + if actionTrackID == "" { + actionTrackID, err = api.GetActionTrackID(ctx) + if err != nil { + fmt.Println(err) + return + } + + f2, err := os.Create(actionTrackIDFile) + if err != nil { + fmt.Println(err) + return + } + defer f2.Close() + f2.WriteString(actionTrackID) + } + }() + if actionTrackID == "" { + fmt.Printf("%sにactionTrackID情報を保存して下さい", actionTrackIDFile) + return + } + + finder := find.NewFinder(ctx) + // TODO:デフォルトの監視リストを設定から読み込む + data := loadNicoFinder() + if data.Community != nil { + finder.SetCommunityList(data.Community) + } + if data.User != nil { + finder.SetUserList(data.User) + } + if data.Title != nil { + finder.SetTitleList(data.Title) + } + finder.Launch() + + go func() { + for { + select { + case pid := <-finder.Found: + + fmt.Printf("found %v\n", pid) + + reg := register{ + Target: fmt.Sprintf("lv%d", pid), + NicocasArchive: true, + StartPositionEn: true, + StartPosition: 0, + ArchiveWait: defaultArchiveWait, + } + registerTarget(ctx, reg) + + case <-finder.Closed: + fmt.Println("[FIXME] 監視が終了しました") + return + } + } + }() + + convertQueueMap := sync.Map{} + convertQueue := make(chan string, 256) + go func() { + for { + path := <-convertQueue + convertQueueMap.Store(path, true) + convertTarget(ctx, path) + convertQueueMap.Delete(path) + } + }() + + r := gin.New() + r.LoadHTMLGlob("view/*") + r.GET("/", func(c *gin.Context) { + + var nicoFinderWorking bool + select { + case <-finder.Closed: + default: + nicoFinderWorking = true + } + + converts := map[string]bool{} + convertQueueMap.Range(func(key, value interface{}) bool { + converts[key.(string)] = value.(bool) + return true + }) + + c.HTML(http.StatusOK, "index.html", gin.H{ + "title": "livedl2 α0.1", + "limit": limitTime.Format(layout), + "workers": works, + "working": len(works) > 0, + + // ニコ生監視 + "NicoFinderWorking": nicoFinderWorking, + "NicoCommunityList": finder.GetCommunityList(), + "NicoUserList": finder.GetUserList(), + "NicoTitleList": finder.GetTitleList(), + // 変換リスト + "converts": converts, + }) + }) + r.POST("/register", func(c *gin.Context) { + var reg register + if e := c.ShouldBind(®); e != nil { + fmt.Printf("%+v\n", reg) + } else { + if strings.HasPrefix(reg.Target, `"`) && strings.HasSuffix(reg.Target, `"`) { + reg.Target = strings.TrimPrefix(reg.Target, `"`) + reg.Target = strings.TrimSuffix(reg.Target, `"`) + } + + if strings.HasSuffix(reg.Target, ".sqlite3") || + strings.HasSuffix(reg.Target, ".sqlite") || strings.HasSuffix(reg.Target, ".db") { + + convertQueue <- reg.Target + convertQueueMap.Store(reg.Target, false) + //convertTarget(ctx, reg) + } else { + registerTarget(ctx, reg) + } + } + c.Redirect(http.StatusSeeOther, "/") + }) + r.POST("/close", func(c *gin.Context) { + var reg register + fmt.Println("closing") + if e := c.ShouldBind(®); e != nil { + fmt.Printf("%+v\n", reg) + } else { + fmt.Printf("%#v\n", reg) + if reg.Target == "all" { + closeTargetAll() + } else { + closeTarget(reg.Target) + } + } + c.Redirect(http.StatusSeeOther, "/") + }) + r.GET("/get-list", func(c *gin.Context) { + type work struct { + WorkerID string `json:"workerId"` + ID string `json:"id"` + Title string `json:"title"` + Name string `json:"name"` + Progress string `json:"progress"` + } + + list := make([]work, 0, len(works)) + + for k, v := range works { + if k == "" { + continue + } + + w := work{ + WorkerID: k, + ID: v.GetID(), + Title: v.GetTitle(), + Name: v.GetName(), + Progress: v.GetProgress(), + } + list = append(list, w) + } + + c.JSON(200, gin.H{ + "result": list, + }) + }) + + // ニコ生監視設定 + r.POST("/nico-finder", func(c *gin.Context) { + + defer c.Request.Body.Close() + bs, _ := ioutil.ReadAll(c.Request.Body) + + u, _ := url.Parse("?" + string(bs)) + v := u.Query() + + data := nicoFinder{ + Community: []string{}, + User: []string{}, + Title: []string{}, + } + for k, v := range v { + arr := make([]string, 0) + for _, v := range v { + if v != "" { + arr = append(arr, v) + } + } + switch k { + case "community[]": + finder.SetCommunityList(arr) + data.Community = arr + case "user[]": + finder.SetUserList(arr) + data.User = arr + case "title[]": + finder.SetTitleList(arr) + data.Title = arr + } + } + + bs, err := json.MarshalIndent(data, "", "\t") + if err != nil { + fmt.Println(err) + } else { + f, err := os.Create(nicoFinderFile) + if err != nil { + fmt.Println(err) + } else { + _, err = f.Write(bs) + if err != nil { + fmt.Println(err) + } + f.Close() + } + } + + c.Redirect(http.StatusSeeOther, "/") + }) + + r.GET("/pprof/:name", func(c *gin.Context) { + c.Request.Form = url.Values{} + c.Request.Form.Set("debug", "1") + name := c.Param("name") + pprof.Handler(name).ServeHTTP(c.Writer, c.Request) + }) + r.Run() // listen and serve on 0.0.0.0:8080 +} + +type downloadWork interface { + Close() + GetWorkerID() string + GetID() string + GetTitle() string + GetName() string + GetProgress() string +} + +var works map[string]downloadWork + +var closed chan string + +var q chan httpcommon.HttpWork + +func init() { + works = make(map[string]downloadWork) + closed = make(chan string, 10) + + q = httpcommon.Launch(10) + + go func() { + for id := range closed { + fmt.Printf("got close notify: %v\n", id) + wrk, ok := works[id] + if ok { + closeWork(wrk) + fmt.Printf("done close notify: %v\n", id) + delete(works, id) + } + } + }() +} + +func closeWork(w downloadWork) { + w.Close() +} + +func closeTarget(target string) { + wrk, ok := works[target] + if ok { + closeWork(wrk) + } +} + +func closeTargetAll() { + for _, val := range works { + closeWork(val) + } +} + +func registerTarget(ctx context.Context, reg register) { + + fmt.Printf("%#v\n", reg) + + var re *regexp.Regexp + // 新配信 + /* + re = regexp.MustCompile(`live2\.nicovideo\.jp/.+?/(lv\d+)`) + if m := re.FindStringSubmatch(reg.Target); len(m) > 1 { + fmt.Printf("%s\n", m[1]) + wrk = nicocas.ThisIsTest(ctx, m[1], 0) + fmt.Printf("%#v\n", wrk) + //cancelWork(wrk) + return + } + // Nicocas + re = regexp.MustCompile(`cas\.nicovideo\.jp/.+?/(lv\d+)`) + if m := re.FindStringSubmatch(reg.Target); len(m) > 1 { + fmt.Printf("cas %s\n", m[1]) + wrk = nicocas.ThisIsTest(ctx, m[1], 0) + fmt.Printf("%#v\n", wrk) + return + } + */ + + // youtube + + // twitcas + + // ニコ生 + + re = regexp.MustCompile(`(lv\d+)`) + if m := re.FindStringSubmatch(reg.Target); len(m) > 1 { + id := m[1] + fmt.Printf("%s\n", id) + + props, err := nicocas.GetProps(ctx, id, userSession) + if err != nil { + fmt.Println(err) + return + } + + workerID := nicocas.GetWorkerID(id) + if _, ok := works[workerID]; !ok { + + fmt.Printf("%#v\n", props) + + wrk := nicocas.Create(ctx, q, closed, props, reg.NicocasArchive, reg.StartPositionEn, reg.StartPosition, reg.ArchiveWait, userSession, actionTrackID) + + works[workerID] = wrk + } else { + fmt.Printf("already started %v\n", id) + } + + return + } +} + +func convertTarget(ctx context.Context, path string) { + + db, err := sql.Open("sqlite3", path) + if err != nil { + return + } + defer db.Close() + + mediaName := files.ChangeExtention(path, "ts") + + rows, err := db.QueryContext(ctx, `SELECT seqno, bandwidth, data FROM media ORDER BY seqno`) + if err != nil { + fmt.Println(err) + return + } + defer rows.Close() + + var seqno int64 + var bandwidth int64 + var data []byte + + prevSeqno := int64(-2) // 1足してもマイナスのseqNoになるようにする + prevBandwidth := int64(-1) + + var f *os.File + for rows.Next() { + rows.Scan(&seqno, &bandwidth, &data) + if prevSeqno+1 != seqno || prevBandwidth != bandwidth { + + fmt.Printf("prevSeqno%v != seqno%v, prevBandwidth%v != bandwidth%v\n", + prevSeqno, seqno, prevBandwidth, bandwidth) + + if f != nil { + f.Close() + } + nextFile, _ := files.GetFileNameNext(mediaName) + f, err = os.Create(nextFile) + if err != nil { + fmt.Println(err) + return + } + } + prevSeqno = seqno + prevBandwidth = bandwidth + + _, err := f.Write(data) + if err != nil { + fmt.Println(err) + return + } + } + + if f != nil { + f.Close() + } + + fmt.Printf("変換終了: %s\n", mediaName) +} diff --git a/src/niconico/api/action_track_id.go b/src/niconico/api/action_track_id.go new file mode 100644 index 0000000..94f8a38 --- /dev/null +++ b/src/niconico/api/action_track_id.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "../../httpcommon" +) + +type metaInt struct { + Status int `json:"status"` +} +type actionTrackID struct { + Meta metaInt `json:"meta"` + Data string `json:"data"` +} + +func GetActionTrackID(ctx context.Context) (id string, err error) { + + req, err := http.NewRequest("POST", "https://public.api.nicovideo.jp/v1/action-track-ids.json", nil) + if err != nil { + return + } + + req = req.WithContext(ctx) + + client := httpcommon.GetClient() + res, err := client.Do(req) + if err != nil { + fmt.Println(err) + return + } + defer res.Body.Close() + + bs, err := ioutil.ReadAll(res.Body) + if err != nil { + return + } + + fmt.Println(string(bs)) + var atid actionTrackID + json.Unmarshal(bs, &atid) + + if atid.Data != "" { + id = atid.Data + } else { + err = fmt.Errorf("action-track-id not found") + return + } + return +} diff --git a/src/niconico/find/find.go b/src/niconico/find/find.go new file mode 100644 index 0000000..70efa77 --- /dev/null +++ b/src/niconico/find/find.go @@ -0,0 +1,267 @@ +package find + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "../../httpcommon" +) + +var keepSecond = int64(300) + +type doc struct { + CategoryTags string `json:"category_tags"` // "一般(その他)" + ChannelOnly string `json:"channel_only"` // "0" + Community string `json:"community"` // coXXXX + CommunityOnly string `json:"community_only"` // "0" コミュ限なら "1" + Communityname string `json:"communityname"` // コミュ名 + Description string `json:"description"` // 説明文 + ID int64 `json:"id"` // 放送ID(lvなし Number) + Ownername string `json:"ownername"` // 放送者名 + ProviderClass string `json:"provider_class"` // "community" + ProviderType string `json:"provider_type"` // "community" + StartTime int64 `json:"start_time"` // 1546101768 + StreamStatus string `json:"stream_status"` // "onair" + Title string `json:"title"` // Title + User string `json:"user"` // UserId(string) +} + +type docList map[int64]doc + +type docs struct { + Docs []doc `json:"docs"` + //OnairDocs []doc `json:"onair_docs"` +} + +var keywords = []string{ + "一般", + "ゲーム", + "やってみた", + "動画紹介", +} + +type Finder struct { + _communityList []string + _userList []string + _titleList []string + mtx sync.Mutex + list docList + ctx context.Context + cancelFunc func() + Closed chan struct{} // 監視ループの終了 + Found chan int64 // 見つかった番組ID +} + +func NewFinder(ctx context.Context) *Finder { + ctx2, cancelFunc := context.WithCancel(ctx) + f := &Finder{ + ctx: ctx2, + cancelFunc: cancelFunc, + Closed: make(chan struct{}), + Found: make(chan int64, 100), + } + f.list = make(docList) + return f +} +func (f *Finder) Close() { + f.cancelFunc() + select { + case <-f.Closed: + default: + close(f.Closed) + } +} + +func (f *Finder) GetCommunityList() []string { + f.mtx.Lock() + defer f.mtx.Unlock() + return f._communityList +} + +func (f *Finder) SetCommunityList(l []string) { + f.mtx.Lock() + defer f.mtx.Unlock() + f._communityList = l +} + +func (f *Finder) GetUserList() []string { + f.mtx.Lock() + defer f.mtx.Unlock() + return f._userList +} + +func (f *Finder) SetUserList(l []string) { + f.mtx.Lock() + defer f.mtx.Unlock() + f._userList = l +} + +func (f *Finder) GetTitleList() []string { + f.mtx.Lock() + defer f.mtx.Unlock() + return f._titleList +} + +func (f *Finder) SetTitleList(l []string) { + f.mtx.Lock() + defer f.mtx.Unlock() + f._titleList = l +} + +func (f *Finder) filter(d doc, communityList, userList, titleList []string) bool { + + // community + for _, c := range communityList { + if d.Community == c { + return true + } + } + + // user + for _, c := range userList { + if d.User == c { + return true + } + } + + // title + for _, t := range titleList { + if strings.Contains(d.Title, t) { + return true + } + } + + return false +} + +func (f *Finder) Launch() { + go f.run() +} + +// メインループ +func (f *Finder) run() { + for { + + // community + communityList := f.GetCommunityList() + // user + userList := f.GetUserList() + // title + titleList := f.GetTitleList() + + if len(communityList) > 0 || len(userList) > 0 || len(titleList) > 0 { + + err := f.forKeywords(communityList, userList, titleList) + if err != nil { + fmt.Println(err) + } + + // 一定時間以上経ったリストは削除 + now := time.Now().Unix() + t := now - keepSecond + + for k, v := range f.list { + if t > v.StartTime { + delete(f.list, k) + } + } + + } + + select { + case <-time.After(30 * time.Second): + case <-f.Closed: + return + } + } +} + +func (f *Finder) forKeywords(communityList, userList, titleList []string) error { + for _, k := range keywords { + err := f.find(k, communityList, userList, titleList) + if err != nil { + return err + } + select { + case <-time.After(1 * time.Second): + case <-f.Closed: + return nil + } + } + return nil +} + +func (f *Finder) find(keyword string, communityList, userList, titleList []string) (err error) { + + query := url.Values{} + + query.Set("track", "") + query.Set("sort", "recent") + query.Set("date", "") + query.Set("keyword", keyword) + query.Set("filter", " :nocommunitygroup:") + query.Set("kind", "tags") + query.Set("page", "1") + + uri := fmt.Sprintf("https://live.nicovideo.jp/search?%s", query.Encode()) + + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + return + } + + client := httpcommon.GetClient() + res, err := client.Do(req) + if err != nil { + return + } + + defer res.Body.Close() + + dat, err := ioutil.ReadAll(res.Body) + if err != nil { + return + } + res.Body.Close() + + now := time.Now().Unix() + if ma := regexp.MustCompile(`Nicolive_JS_Conf\.Search\s*=\s*({.+?})\s*;`).FindSubmatch(dat); len(ma) > 0 { + + var docs docs + if err = json.Unmarshal([]byte(string(ma[1])), &docs); err != nil { + return + } + //objs.PrintAsJson(docs) + + for _, d := range docs.Docs { + // まだ見つかってなかったもの + if _, ok := f.list[d.ID]; !ok { + f.list[d.ID] = d + start := d.StartTime + diff := now - start + + if diff < keepSecond && f.filter(d, communityList, userList, titleList) { + fmt.Printf("Found lv%d %s\n", d.ID, d.Title) + select { + case f.Found <- d.ID: + default: + fmt.Printf("登録できませんでした: lv%d", d.ID) + } + } + } + } + + } else { + //fmt.Println(string(dat)) + } + + return +} diff --git a/src/niconico/nicocas.go b/src/niconico/nicocas.go new file mode 100644 index 0000000..3c14e2e --- /dev/null +++ b/src/niconico/nicocas.go @@ -0,0 +1 @@ +package niconico diff --git a/src/niconico/nicocas/cas.go b/src/niconico/nicocas/cas.go new file mode 100644 index 0000000..da27cf2 --- /dev/null +++ b/src/niconico/nicocas/cas.go @@ -0,0 +1,518 @@ +package nicocas + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "html" + "io" + "io/ioutil" + "net/http" + "net/url" + "regexp" + "sync" + "sync/atomic" + "time" + + "../../httpcommon" + "../../objs" + "../nicocasprop" + "../nicodb" + "../nicoprop" + sqlite3 "github.com/mattn/go-sqlite3" +) + +const xFrontendID = "91" + +// NicoCasWork +type NicoCasWork struct { + id string + ctx context.Context + cancel func() + + chError chan interface{} + //chUnrecoverable chan error + + chPlaylistRequest chan playlistRequest + //chStreamServer chan streamServer + + masterPlaylist *url.URL + + chHeartbeat chan putHeartbeat + startPositionEn bool + startPosition float64 + startPositionEnSecond bool + closed chan struct{} + closeNotify chan string + chMedia chan media + + mediaStatus sync.Map + + httpQueue chan httpcommon.HttpWork + + useArchive bool + + db *nicodb.NicoDB + + _actionTrackID atomic.Value + _userSession atomic.Value + + // medialoop + mediaLoopClosed chan bool + closeMediaLoop chan bool + mtxMediaLoop sync.Mutex + + // アーカイブ高速DL時の待ち時間:秒 + archiveWait float64 + + property property + + _seqNo atomic.Value + _position atomic.Value + _treamDuration atomic.Value + processingMedia sync.Map + playlistDone chan struct{} + hbBreak chan struct{} +} + +func (w *NicoCasWork) setSeqNo(no uint64) { + w._seqNo.Store(no) +} +func (w *NicoCasWork) getSeqNo() uint64 { + val := w._seqNo.Load() + switch v := val.(type) { + case uint64: + return v + default: + return 0 + } +} + +func (w *NicoCasWork) setPosition(pos float64) { + w._position.Store(pos) +} +func (w *NicoCasWork) getPosition() float64 { + val := w._position.Load() + switch v := val.(type) { + case float64: + return v + default: + return 0 + } +} + +func (w *NicoCasWork) setStreamDuration(pos float64) { + w._treamDuration.Store(pos) +} +func (w *NicoCasWork) getStreamDuration() float64 { + val := w._treamDuration.Load() + switch v := val.(type) { + case float64: + return v + default: + return 0 + } +} + +// GetWorkerID returns +func GetWorkerID(id string) string { + return fmt.Sprintf("nico:%s", id) +} + +func (w *NicoCasWork) GetWorkerID() string { + return GetWorkerID(w.property.GetID()) +} + +// GetID returns 放送ID +func (w *NicoCasWork) GetID() string { + return w.property.GetID() +} + +func (w *NicoCasWork) GetTitle() string { + return w.property.GetTitle() +} + +func (w *NicoCasWork) GetName() string { + return w.property.GetName() +} + +func (w *NicoCasWork) GetProgress() string { + + var s string + if w.useArchive { + pos := w.getPosition() + streamPos := w.getStreamDuration() + + var percent float64 + if streamPos == 0 { + percent = 0 + } else { + percent = (pos / streamPos) * 100 + } + + pos_t := time.Date(2018, time.January, 1, 0, 0, int(pos), 0, time.UTC) + spos_t := time.Date(2018, time.January, 1, 0, 0, int(streamPos), 0, time.UTC) + + pos_s := pos_t.Format("15:04:05") + spos_s := spos_t.Format("15:04:05") + s = fmt.Sprintf("%s / %s (%.2f%%)", pos_s, spos_s, percent) + + //s = fmt.Sprintf("%.2f / %.2f (%.2f%%)", pos_t.Format(""), streamPos, percent) + + } else { + seqNo := w.getSeqNo() + s = fmt.Sprintf("SeqNo=%d", seqNo) + } + + return s +} + +func (w *NicoCasWork) setActionTrackID(s string) { + w._actionTrackID.Store(s) +} +func (w *NicoCasWork) getActionTrackID() string { + return w._actionTrackID.Load().(string) +} + +func (w *NicoCasWork) setUserSession(s string) { + w._userSession.Store(s) +} +func (w *NicoCasWork) getUserSession() string { + return w._userSession.Load().(string) +} + +func (w *NicoCasWork) newRequest(method, uri string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, uri, body) + if err != nil { + return nil, err + } + req = req.WithContext(w.ctx) + return req, nil +} + +// Close コンテキストキャンセル、終了チャンネルを閉じる +func (w NicoCasWork) Close() { + select { + case <-w.closed: + default: + w.cancel() // ctx + w.dbClose() + close(w.closed) + w.closeNotify <- w.GetWorkerID() + } +} + +func (w NicoCasWork) dbClose() { + + select { + case <-w.mediaLoopClosed: + // mediaLoop already closed + default: + select { + case w.closeMediaLoop <- true: + select { + case <-w.mediaLoopClosed: + case <-time.After(10 * time.Second): + } + default: + } + } + + if w.db != nil { + w.db.Close() + } +} + +// エラーを集約する +func (w *NicoCasWork) launchErrorHandler() { + go func() { + for { + select { + case err := <-w.chError: + switch e := err.(type) { + case postWatchingError: + + fmt.Printf("got postWatchingError %+v\n", e) + switch e.ErrorCode { + case "NOT_PLAYABLE": + w.Close() + } + w.Close() + + case playlistError: + fmt.Printf("got playlistError: %+v\n", e) + + if e.retry { + time.Sleep(time.Duration(e.retryDelayMs) * time.Millisecond) + fmt.Printf("playlistError 1") + + w.dbClose() + fmt.Printf("playlistError 2") + go w.start() + } + + case sqlite3.Error: + w.Close() + return + + default: + fmt.Printf("got error %#v\n", e) + } + case <-w.closed: + return + } + } + }() +} + +func (w *NicoCasWork) addHTTPRequest(req *http.Request, cb httpcommon.Callback) { + w.addHTTPRequestOption(req, cb, nil) +} +func (w *NicoCasWork) addHTTPRequestOption(req *http.Request, cb httpcommon.Callback, opt interface{}) { + w.httpQueue <- httpcommon.HttpWork{ + QueuedAt: time.Now(), + Client: http.DefaultClient, + Request: req, + Callback: cb, + This: w, + Option: opt, + } +} + +func (w *NicoCasWork) launchPlaylistLoop() { + go w.playlistLoop() +} + +func (w *NicoCasWork) launchHeartbeatLoop() { + go w.heartbeatLoop() +} + +func newNicoCasWork(parent context.Context, q chan httpcommon.HttpWork, closeNotify chan string, + prop property, useArchive, startPositionEn bool, startPosition float64, archiveWait float64, userSession, actionTrackID string) *NicoCasWork { + + ctx, cancel := context.WithCancel(parent) + + chError := make(chan interface{}, 100) + chPlaylistRequest := make(chan playlistRequest, 10) + //chStreamServer := make(chan streamServer) + chHeartbeat := make(chan putHeartbeat, 10) + closed := make(chan struct{}) + chMedia := make(chan media, 100) + mediaLoopClosed := make(chan bool, 0) + closeMediaLoop := make(chan bool, 0) + playlistDone := make(chan struct{}) + hbBreak := make(chan struct{}) + w := &NicoCasWork{ + ctx: ctx, + cancel: cancel, + chError: chError, + chPlaylistRequest: chPlaylistRequest, + id: prop.GetID(), + chHeartbeat: chHeartbeat, + startPositionEn: startPositionEn, + startPosition: startPosition, + closed: closed, + closeNotify: closeNotify, + chMedia: chMedia, + mediaStatus: sync.Map{}, + processingMedia: sync.Map{}, + httpQueue: q, + useArchive: useArchive, + mediaLoopClosed: mediaLoopClosed, + closeMediaLoop: closeMediaLoop, + archiveWait: archiveWait, + property: prop, + playlistDone: playlistDone, + hbBreak: hbBreak, + } + w.setActionTrackID(actionTrackID) + w.setUserSession(userSession) + + w.launchErrorHandler() + w.launchPlaylistLoop() + w.launchHeartbeatLoop() + + return w +} + +func (w *NicoCasWork) openDB() (err error) { + + var dbType int + if w.useArchive { + dbType = dbTypeCasArchive + } else { + dbType = dbTypeCasLive + } + + var suffix string + switch dbType { + case dbTypeCasLive: + suffix = "cas.sqlite3" + case dbTypeCasArchive: + suffix = "cas-archive.sqlite3" + default: + suffix = "sqlite3" + } + name := fmt.Sprintf("%v.%v", w.id, suffix) + + db, err := nicodb.Open(w.ctx, name) + if err != nil { + fmt.Println(err) + return + } + w.db = db + + w.launchMediaLoop() + + return +} + +func (w *NicoCasWork) getNextPosition() float64 { + return w.db.GetNextPosition(w.ctx) +} +func (w *NicoCasWork) findBySeqNo(seqNo uint64) bool { + return w.db.FindBySeqNo(w.ctx, seqNo) +} + +func (w *NicoCasWork) start() { + var uri string + var postOpt postWatchingOption + + fmt.Println("start") + // 必ず最初にDBを開かないと落ちる + if err := w.openDB(); err != nil { + w.chError <- err + return + } + fmt.Println("done openDB") + + if w.useArchive { + uri = fmt.Sprintf("https://api.cas.nicovideo.jp/v1/services/live/programs/%s/watching-archive", w.id) + + if w.startPositionEn { + postOpt.positionEn = true + + pos := w.getNextPosition() + fmt.Printf("\n\ngetNextPosition: %v\n\n", pos) + if !w.startPositionEnSecond { + w.startPositionEnSecond = true + if pos > w.startPosition { + postOpt.position = pos + } else { + postOpt.position = w.startPosition + } + } else { + postOpt.position = pos + } + } + + } else { + uri = fmt.Sprintf("https://api.cas.nicovideo.jp/v1/services/live/programs/%s/watching", w.id) + } + + fmt.Println(uri) + + dat, _ := json.Marshal(map[string]interface{}{ + "actionTrackId": w.getActionTrackID(), + "isBroadcaster": false, + "isLowLatencyStream": false, + "streamCapacity": "superhigh", + "streamProtocol": "https", + "streamQuality": "auto", + }) + + req, _ := w.newRequest("POST", uri, bytes.NewBuffer(dat)) + userSession := w.getUserSession() + req.Header.Set("Cookie", "user_session="+userSession) + req.Header.Set("X-Frontend-Id", xFrontendID) + req.Header.Set("X-Connection-Environment", "ethernet") + req.Header.Set("Content-Type", "application/json") + + w.addHTTPRequestOption(req, cbPostWatching, postOpt) +} + +const ( + dbTypeCasLive = iota + dbTypeCasArchive +) + +func (w *NicoCasWork) launchMediaLoop() { + go w.mediaLoop() +} + +type property interface { + GetID() string + GetTitle() string + GetName() string +} + +func Create(parent context.Context, q chan httpcommon.HttpWork, closeNotify chan string, + prop property, useArchive, startPositionEn bool, startPosition, archiveWait float64, userSession, actionTrackID string) *NicoCasWork { + + work := newNicoCasWork(parent, q, closeNotify, prop, useArchive, startPositionEn, startPosition, archiveWait, userSession, actionTrackID) + go work.start() + + return work +} + +func GetProps(ctx context.Context, id, userSession string) (props property, err error) { + uri := fmt.Sprintf("https://live.nicovideo.jp/watch/%s", id) + + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + return + } + req = req.WithContext(ctx) + + req.Header.Set("Cookie", "user_session="+userSession) + + client := httpcommon.GetClient() + + res, err := client.Do(req) + if err != nil { + return + } + defer res.Body.Close() + + dat, err := ioutil.ReadAll(res.Body) + if err != nil { + return + } + res.Body.Close() + + if ma := regexp.MustCompile(`data-props="(.+?)"`).FindSubmatch(dat); len(ma) > 0 { + str := html.UnescapeString(string(ma[1])) + + switch req.URL.Host { + case "live2.nicovideo.jp": + p := nicoprop.NicoProperty{} + + //var p2 interface{} + if err = json.Unmarshal([]byte(str), &p); err != nil { + return + } + + objs.PrintAsJson(p) + + props = p + case "cas.nicovideo.jp": + p := nicocasprop.NicocasProperty{} + if err = json.Unmarshal([]byte(str), &p); err != nil { + return + } + props = p + default: + err = fmt.Errorf("対応していません:%s", req.URL) + return + } + + objs.PrintAsJson(props) + + } else { + err = fmt.Errorf("対応していません:%s", req.URL) + return + } + + return +} diff --git a/src/niconico/nicocas/media.go b/src/niconico/nicocas/media.go new file mode 100644 index 0000000..f74f9ae --- /dev/null +++ b/src/niconico/nicocas/media.go @@ -0,0 +1,130 @@ +package nicocas + +import ( + "fmt" + "io/ioutil" + "net/http" + "time" +) + +type media struct { + seqNo uint64 + duration float64 + position float64 // 現在の再生時刻 + bandwidth int64 + size int64 + data []byte +} + +type mediaReadError struct { + error +} + +type mediaChunkOption struct { + seqNo uint64 + duration float64 + position float64 + bandwidth int64 +} + +func cbMediaChunk(res *http.Response, err error, this, opt interface{}, queuedAt, startedAt time.Time) { + w := this.(*NicoCasWork) + chunkOpt := opt.(mediaChunkOption) + + var ok bool + var is404 bool + defer func() { + if ok { + w.mediaStatus.Store(chunkOpt.seqNo, true) + } else if is404 { + w.processingMedia.Delete(chunkOpt.seqNo) + w.mediaStatus.Store(chunkOpt.seqNo, true) + } else { + w.processingMedia.Delete(chunkOpt.seqNo) + w.mediaStatus.Delete(chunkOpt.seqNo) + } + }() + + if err != nil { + w.chError <- mediaReadError{err} + return + } + defer res.Body.Close() + + switch res.StatusCode { + case 200: + default: + if res.StatusCode == 404 { + is404 = true + } + w.chError <- mediaReadError{fmt.Errorf("StatusCode is %v: %v", res.StatusCode, res.Request.URL)} + return + } + + if res.ContentLength < 10*1024*1024 { + bs, err := ioutil.ReadAll(res.Body) + if err != nil { + w.chError <- mediaReadError{err} + return + } + + if res.ContentLength == int64(len(bs)) { + + w.chMedia <- media{ + seqNo: chunkOpt.seqNo, + duration: chunkOpt.duration, + position: chunkOpt.position, + bandwidth: chunkOpt.bandwidth, + size: int64(len(bs)), + data: bs, + } + + ok = true + + } else { + w.chError <- mediaReadError{fmt.Errorf("read error: %v != %v", res.ContentLength, len(bs))} + } + } else { + w.chError <- mediaReadError{fmt.Errorf("[FIXME] too large: %v", res.ContentLength)} + } +} + +func (w *NicoCasWork) saveMedia(seqNo uint64, position, duration float64, bandwidth, size int64, data []byte) error { + return w.db.InsertMedia(seqNo, position, duration, bandwidth, size, data) +} + +// チャンネルからシーケンスを受け取ってDBに入れていく +func (w *NicoCasWork) mediaLoop() { + // this is guard + w.mtxMediaLoop.Lock() + defer w.mtxMediaLoop.Unlock() + + defer func() { + fmt.Printf("Closing mediaLoop\n") + select { + case w.mediaLoopClosed <- true: + case <-time.After(10 * time.Second): + fmt.Println("[FIXME] Closing mediaLoop") + } + }() + + for { + select { + case media := <-w.chMedia: + fmt.Printf("inserting DB %v %v %v %v %v\n", media.seqNo, media.duration, media.position, media.size, media.bandwidth) + + err := w.saveMedia(media.seqNo, media.position, media.duration, media.bandwidth, media.size, media.data) + w.processingMedia.Delete(media.seqNo) + if err != nil { + fmt.Println(err) + return + } + + case <-w.closeMediaLoop: + return + + case <-w.closed: + return + } + } +} diff --git a/src/niconico/nicocas/playlist.go b/src/niconico/nicocas/playlist.go new file mode 100644 index 0000000..0b1bb78 --- /dev/null +++ b/src/niconico/nicocas/playlist.go @@ -0,0 +1,424 @@ +package nicocas + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/himananiito/m3u8" +) + +const ( + playlistTypeUnknown = iota + playlistTypeMaster + playlistTypeMedia +) + +type playlistRequest struct { + waitSecond float64 + uri string + playlistType int + useArchive bool + //fastQueueing bool + //positionEn bool + //masterURL string + //bandwidth int64 + playlistOption +} + +type playlistError struct { + error + retry bool + retryDelayMs int64 +} + +type playlistOption struct { + fastQueueing bool + positionEn bool + masterURL string + bandwidth int64 +} + +func cbPlaylist(res *http.Response, err error, this, opt interface{}, queuedAt, startedAt time.Time) { + w := this.(*NicoCasWork) + playlistOpt := opt.(playlistOption) + //defer fmt.Println("done cbPlaylist") + if err != nil { + w.chError <- playlistError{error: err} + return + } + defer res.Body.Close() + + switch res.StatusCode { + case 200: + default: + var retry bool + if res.StatusCode != 404 { + retry = true + } + + w.chError <- playlistError{ + error: fmt.Errorf("StatusCode is %v", res.StatusCode), + retry: retry, + retryDelayMs: 1000, + } + return + } + + playlist, listType, err := m3u8.DecodeFrom(res.Body, true) + if err != nil { + w.chError <- playlistError{error: err} + return + } + + switch listType { + case m3u8.MEDIA: + w.handleMediaPlaylist(playlist.(*m3u8.MediaPlaylist), res.Request.URL, playlistOpt) + + case m3u8.MASTER: + w.handleMasterPlaylist(playlist.(*m3u8.MasterPlaylist), res.Request.URL, playlistOpt) + } +} + +// createMasterURLWithPosition MasterプレイリストのURLを指定した開始時間付きの文字列で返す +func createMasterURLWithPosition(URL string, currentPos float64) string { + masterURL, _ := url.Parse(URL) + query := masterURL.Query() + query.Set("start", fmt.Sprintf("%f", currentPos)) + var format string + if strings.HasPrefix(masterURL.Path, "/") { + format = "%s://%s%s?%s" + } else { + format = "%s://%s/%s?%s" + } + + return fmt.Sprintf( + format, + masterURL.Scheme, + masterURL.Host, + masterURL.Path, + query.Encode(), + ) +} + +func (w *NicoCasWork) handleMasterPlaylist(masterpl *m3u8.MasterPlaylist, URL *url.URL, opt playlistOption) { + fmt.Printf("%+v\n", masterpl) + + bws := make([]int, 0) + playlists := map[int]*m3u8.Variant{} + for _, variant := range masterpl.Variants { + if variant == nil { + break + } + + bw := int(variant.Bandwidth) + bws = append(bws, bw) + playlists[bw] = variant + } + + if len(bws) == 0 { + fmt.Println("No playlist") + w.chError <- playlistError{ + error: fmt.Errorf("No playlist in master"), + } + return + } + + limit := 999999999 + sort.Ints(bws) + + var selected int + for _, bw := range bws { + if bw > limit { + break + } + selected = bw + } + + // 全てが制限値以上のBWの場合は一番小さいものを選ぶ + if selected == 0 { + selected = bws[0] + } + + pl, _ := playlists[selected] + + uri, err := url.Parse(pl.URI) + if err != nil { + panic(err) + } + + streamURL := URL.ResolveReference(uri).String() + + //fmt.Printf("handleMasterPlaylist => %v\n", URL.String()) + w.addPlaylistRequest(streamURL, 0, playlistTypeMaster, opt.positionEn, URL.String(), int64(selected)) +} + +func (w *NicoCasWork) handleMediaPlaylist(mediapl *m3u8.MediaPlaylist, uri *url.URL, opt playlistOption) { + + fmt.Printf("%+v\n", opt) + fmt.Printf("%+v\n", mediapl) + + w.setSeqNo(mediapl.SeqNo) + + var pos float64 + var posDefined bool + if mediapl.CurrentPosition != nil { + pos = *mediapl.CurrentPosition + posDefined = true + } else if mediapl.DMCCurrentPosition != nil { + pos = *mediapl.DMCCurrentPosition + posDefined = true + } + + var streamDuration float64 + var streamDurationDefined bool + if mediapl.DMCStreamDuration != nil { + streamDuration = *mediapl.DMCStreamDuration + streamDurationDefined = true + } else if mediapl.StreamDuration != nil { + streamDuration = *mediapl.StreamDuration + streamDurationDefined = true + } + + var registered int + var totalDuration float64 + for i, seg := range mediapl.Segments { + if seg == nil { + mediapl.Segments = mediapl.Segments[:i] + break + } + + if i == 0 && seg.Duration > 0 && !w.useArchive && !opt.fastQueueing { + fmt.Println("add fast queueing!!!!!!!!") + // 一番最初だけ実行される + w.addPlaylistRequestFast(uri.String(), seg.Duration, playlistTypeMedia, opt.masterURL, opt.bandwidth) + } + + seqNo := mediapl.SeqNo + uint64(i) + + if posDefined { + pos += seg.Duration + } + totalDuration += seg.Duration + + b, e := url.Parse(seg.URI) + if e != nil { + panic(e) + } + + req, _ := w.newRequest("GET", uri.ResolveReference(b).String(), nil) + + var posActual float64 + if posDefined { + posActual = pos + } else { + posActual = 0 + } + if w.downloadMedia(req, seqNo, seg.Duration, posActual, opt.bandwidth) { + registered++ + } + } + + var diff float64 + if posDefined && streamDurationDefined { + diff = streamDuration - pos + fmt.Printf("DIFF is %v\n", diff) + } + + if posDefined { + w.setPosition(pos) + } + if streamDurationDefined { + w.setStreamDuration(streamDuration) + } + + if mediapl.Closed { + // #EXT-X-ENDLIST + select { + case w.playlistDone <- struct{}{}: + default: + } + + return + } + + // アーカイブの場合はここで次のHLS取得設定する + if w.useArchive && !opt.fastQueueing { + var playlistURL string + var wait float64 + + var masterURL = opt.masterURL + + if opt.positionEn && posDefined { + // 位置移動する場合 + playlistURL = createMasterURLWithPosition(opt.masterURL, pos) + + masterURL = playlistURL + + fmt.Printf("\n%v -->\n%v\n\n", opt.masterURL, playlistURL) + + fmt.Printf("registered=%v totalDuration=%v\n", registered, totalDuration) + if diff < totalDuration { + playlistURL = uri.String() + if diff > mediapl.Segments[0].Duration { + wait = mediapl.Segments[0].Duration + } else { + wait = 5.001 + } + } else if registered > 1 { + + wait = w.archiveWait + + } else { + if len(mediapl.Segments) > 0 { + if diff > mediapl.Segments[0].Duration { + + fmt.Println("yarinaosi") + + w.chError <- playlistError{ + error: errors.New("retry playlist"), + retry: true, + retryDelayMs: 1000, + } + + return + + } else { + wait = mediapl.Segments[0].Duration + fmt.Printf("wait: if, 1 \n") + } + + } else { + wait = 5.01 + fmt.Printf("wait: if, 2 \n") + } + playlistURL = uri.String() + } + + } else { + // 位置移動しない + playlistURL = uri.String() + + if len(mediapl.Segments) > 0 { + wait = mediapl.Segments[0].Duration + fmt.Printf("wait: else, 1 \n") + } else { + wait = 5.01 + } + } + + fmt.Printf("wait is %v\n", wait) + w.addPlaylistRequest(playlistURL, wait, playlistTypeMaster, opt.positionEn, masterURL, opt.bandwidth) + } +} + +func (w *NicoCasWork) addPlaylistRequest(uri string, waitSecond float64, playlistType int, positionEn bool, masterURL string, bandwidth int64) { + opt := playlistOption{ + fastQueueing: false, + positionEn: positionEn, + masterURL: masterURL, + bandwidth: bandwidth, + } + w.addPlaylistRequestCommon(uri, waitSecond, playlistType, opt) +} +func (w *NicoCasWork) addPlaylistRequestFast(uri string, waitSecond float64, playlistType int, masterURL string, bandwidth int64) { + opt := playlistOption{ + fastQueueing: true, + positionEn: false, + masterURL: masterURL, + bandwidth: bandwidth, + } + w.addPlaylistRequestCommon(uri, waitSecond, playlistType, opt) +} +func (w *NicoCasWork) addPlaylistRequestCommon(uri string, waitSecond float64, playlistType int, opt playlistOption) { + w.chPlaylistRequest <- playlistRequest{ + uri: uri, + waitSecond: waitSecond, + playlistType: playlistType, + useArchive: w.useArchive, + playlistOption: opt, + } +} + +func (w *NicoCasWork) downloadMedia(req *http.Request, seqNo uint64, duration, position float64, bandwidth int64) bool { + if w.findBySeqNo(seqNo) { + w.mediaStatus.Store(seqNo, true) + return false + } + + if _, ok := w.mediaStatus.Load(seqNo); !ok { + w.mediaStatus.Store(seqNo, false) + w.processingMedia.Store(seqNo, true) + chunkOpt := mediaChunkOption{ + seqNo: seqNo, + duration: duration, + position: position, + bandwidth: bandwidth, + } + w.addHTTPRequestOption(req, cbMediaChunk, chunkOpt) + return true + } + return false +} + +func (w *NicoCasWork) playlistLoop() { + for { + select { + case preq := <-w.chPlaylistRequest: + go func(preq playlistRequest) { + //fmt.Printf("sleep duration: %+v\n", preq.waitSecond) + select { + case <-time.After(time.Duration(preq.waitSecond * float64(time.Second))): + case <-w.closed: + return + } + + req, _ := w.newRequest("GET", preq.uri, nil) + + w.addHTTPRequestOption(req, cbPlaylist, preq.playlistOption) + + // Liveの場合 + // レスポンスを待たずに次のプレイリクエストをキューイング + if preq.fastQueueing && !preq.useArchive && preq.playlistType == playlistTypeMedia { + w.addPlaylistRequestFast(preq.uri, preq.waitSecond, preq.playlistType, preq.masterURL, preq.bandwidth) + } + + }(preq) + + case <-w.playlistDone: + fmt.Println("got playlistDone") + + for i := 0; i < 60; i++ { + + // 完了を待つ + var wait bool + w.processingMedia.Range(func(k, v interface{}) bool { + wait = true + return false + }) + + if wait { + select { + case <-time.After(time.Second): + case <-w.closed: + return + } + } else { + w.Close() + + // TODO: 変換キューに積む + + return + } + } + + return + case <-w.closed: + return + } + } +} diff --git a/src/niconico/nicocas/watching.go b/src/niconico/nicocas/watching.go new file mode 100644 index 0000000..5c2d50e --- /dev/null +++ b/src/niconico/nicocas/watching.go @@ -0,0 +1,236 @@ +package nicocas + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +type putHeartbeat struct { + expire int64 + uri string +} + +type messageServer struct { + HTTP string `json:"http"` + HTTPS string `json:"https"` + ProtocolVersion int64 `json:"protocolVersion"` // 20061206 + Service string `json:"service"` // LIVE + URL string `json:"url"` // xmlsocket://msg101.live.nicovideo.jp:xxxx/xxxx + Version int64 `json:"version"` + Ws string `json:"ws"` + Wss string `json:"wss"` +} +type playConfig struct { + // +} + +type timeCommon struct { + BeginAt string `json:"beginAt"` + EndAt string `json:"endAt"` +} +type streamServer struct { + Status string `json:"status"` + SyncURL string `json:"syncUrl"` + URL string `json:"url"` +} +type keys struct { + ChatThreadKey string `json:"chatThreadKey"` + ControlThreadKey string `json:"controlThreadKey"` + StoreThreadKey string `json:"storeThreadKey"` +} +type threads struct { + Chat string `json:"chat"` + Control string `json:"control"` + Keys keys `json:"keys"` + Store string `json:"store"` +} +type program struct { + Description string `json:"description"` + ID string `json:"id"` // lvxxxxs + OnAirTime timeCommon `json:"onAirTime"` + ShowTime timeCommon `json:"showTime"` + Title string `json:"title"` +} +type data struct { + ExpireIn int64 `json:"expireIn"` + MessageServer messageServer `json:"messageServer"` + PlayConfig playConfig `json:"playConfig"` + PlayConfigStatus string `json:"playConfigStatus"` // "ready" + Program program `json:"program"` + StreamServer streamServer `json:"streamServer"` + Threads threads `json:"threads"` +} + +type meta struct { + Status int64 `json:"status"` + ErrorCode string `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` +} +type watchingResponse struct { + Data data `json:"data"` + Meta meta `json:"meta"` +} + +type dataPut struct { + ExpireIn int64 `json:"expireIn"` + StreamServer streamServer `json:"streamServer"` +} +type putResponse struct { + Meta meta `json:"meta"` + DataPut dataPut `json:"data"` +} + +type postWatchingError struct { + Error error + Status int64 + ErrorCode string + ErrorMessage string +} + +type postWatchingOption struct { + positionEn bool // 位置指定有効 + position float64 // 位置の値 +} + +func cbPostWatching(res *http.Response, err error, this, opt interface{}, queuedAt, startedAt time.Time) { + //fmt.Println("cbPostWatching!") + //defer fmt.Println("cbPostWatching done!") + w := this.(*NicoCasWork) + if err != nil { + w.chError <- postWatchingError{Error: err} + return + } + defer res.Body.Close() + + bs, err := ioutil.ReadAll(res.Body) + if err != nil { + w.chError <- postWatchingError{Error: err} + return + } + + var response watchingResponse + fmt.Printf("%s\n", string(bs)) + json.Unmarshal(bs, &response) + + //objs.PrintAsJson(response) + + switch response.Meta.Status { + case 201: + if response.Data.StreamServer.URL != "" { + optPos := opt.(postWatchingOption) + if w.useArchive { + if optPos.positionEn { + master := createMasterURLWithPosition(response.Data.StreamServer.URL, optPos.position) + w.addPlaylistRequest(master, 0, playlistTypeUnknown, optPos.positionEn, response.Data.StreamServer.URL, 0) + } else { + w.addPlaylistRequest(response.Data.StreamServer.URL, 0, playlistTypeUnknown, optPos.positionEn, response.Data.StreamServer.URL, 0) + } + + } else { + w.addPlaylistRequest(response.Data.StreamServer.URL, 0, playlistTypeUnknown, optPos.positionEn, response.Data.StreamServer.URL, 0) + } + + } else { + w.chError <- postWatchingError{Error: errors.New("StreamServer.URL is null")} + } + default: + w.chError <- postWatchingError{ + Error: errors.New("meta.Status Not OK"), + Status: response.Meta.Status, + ErrorCode: response.Meta.ErrorCode, + ErrorMessage: response.Meta.ErrorMessage, + } + } + + // heartbeat + w.putRequest(response.Data.ExpireIn, res.Request.URL.String()) +} + +func cbPutWatching(res *http.Response, err error, this, _ interface{}, queuedAt, startedAt time.Time) { + w := this.(*NicoCasWork) + if err != nil { + fmt.Println(err) + return + } + defer res.Body.Close() + + bs, err := ioutil.ReadAll(res.Body) + if err != nil { + fmt.Println(err) + return + } + + obj := putResponse{} + err = json.Unmarshal(bs, &obj) + if err != nil { + fmt.Println(err) + return + } + + w.putRequest(obj.DataPut.ExpireIn, res.Request.URL.String()) +} + +func (w *NicoCasWork) putRequest(expire int64, uri string) { + if expire > 0 { + + select { + case w.hbBreak <- struct{}{}: + default: + } + + hb := putHeartbeat{ + expire: expire, + uri: uri, + } + + select { + case w.chHeartbeat <- hb: + case <-w.closed: + return + } + } +} + +func (w *NicoCasWork) heartbeatLoop() { + for { + select { + case hb := <-w.chHeartbeat: + if hb.expire > 0 { + fmt.Printf("putRequest %#v\n", hb) + select { + case <-time.After(time.Duration(hb.expire) * time.Millisecond): + dat, _ := json.Marshal(map[string]interface{}{ + "actionTrackId": w.getActionTrackID(), + "isBroadcaster": false, + }) + req, _ := http.NewRequest("PUT", hb.uri, bytes.NewBuffer(dat)) + req.Header.Set("Content-Type", "application/json") + userSession := w.getUserSession() + req.Header.Set("Cookie", "user_session="+userSession) + req.Header.Set("X-Frontend-Id", xFrontendID) + + w.addHTTPRequest(req, cbPutWatching) + case <-w.hbBreak: + labelHbBreak: + for { + select { + case <-w.chHeartbeat: + default: + break labelHbBreak + } + } + break + case <-w.closed: + return + } + } + case <-w.closed: + return + } + } +} diff --git a/src/niconico/nicocasprop/nicocasprop.go b/src/niconico/nicocasprop/nicocasprop.go new file mode 100644 index 0000000..cb9d778 --- /dev/null +++ b/src/niconico/nicocasprop/nicocasprop.go @@ -0,0 +1,41 @@ +package nicocasprop + +type broadcaster struct { + BroadcasterPageURL string `json:"broadcasterPageUrl"` + ID string `json:"id"` + IsBroadcaster bool `json:"isBroadcaster"` + IsClosedBataTestUser bool `json:"isClosedBataTestUser"` + IsOperator bool `json:"isOperator"` + Level int64 `json:"level"` + Nickname string `json:"nickname"` + PageURL string `json:"pageUrl"` // "http://www.nicovideo.jp/user/XXXX" +} +type community struct { + ID string `json:"id"` +} +type propsNicoCas struct { + Broadcaster broadcaster `json:"broadcaster"` + Community community `json:"community"` +} + +type program struct { + NicoliveProgramID string `json:"nicoliveProgramId"` + Title string `json:"title"` +} + +type NicocasProperty struct { + Broadcaster broadcaster `json:"broadcaster"` + Program program `json:"program"` +} + +func (p NicocasProperty) GetID() string { + return p.Program.NicoliveProgramID +} + +func (p NicocasProperty) GetName() string { + return p.Broadcaster.Nickname +} + +func (p NicocasProperty) GetTitle() string { + return p.Program.Title +} diff --git a/src/niconico/nicodb/nico_db.go b/src/niconico/nicodb/nico_db.go new file mode 100644 index 0000000..0773dba --- /dev/null +++ b/src/niconico/nicodb/nico_db.go @@ -0,0 +1,505 @@ +package nicodb + +import ( + "context" + "database/sql" + "fmt" + "sync" + + _ "github.com/mattn/go-sqlite3" +) + +var SelMedia = `SELECT + seqno, bandwidth, size, data FROM media + WHERE IFNULL(notfound, 0) == 0 AND data IS NOT NULL + ORDER BY seqno` + +var SelComment = `SELECT + vpos, + date, + date_usec, + IFNULL(no, -1) AS no, + IFNULL(anonymity, 0) AS anonymity, + user_id, + content, + IFNULL(mail, "") AS mail, + IFNULL(premium, 0) AS premium, + IFNULL(score, 0) AS score, + thread, + IFNULL(origin, "") AS origin, + IFNULL(locale, "") AS locale + FROM comment + ORDER BY date2` + +type NicoDB struct { + db *sql.DB + stInsertMedia *sql.Stmt + mtx sync.Mutex +} + +func Open(ctx context.Context, name string) (nicodb *NicoDB, err error) { + db, err := sql.Open("sqlite3", name) + if err != nil { + return + } + + _, err = db.Exec(` + PRAGMA synchronous = OFF; + PRAGMA journal_mode = WAL; + PRAGMA locking_mode = EXCLUSIVE; + `) + if err != nil { + return + } + + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS media ( + seqno INTEGER PRIMARY KEY NOT NULL UNIQUE, + notfound INTEGER, + position REAL, + duration REAL, + bandwidth INTEGER, + size INTEGER, + data BLOB + ) + `) + if err != nil { + db.Close() + return + } + + stInsertMedia, err := db.PrepareContext(ctx, + `INSERT OR IGNORE INTO media + (seqno, position, duration, bandwidth, size, data) VALUES + ( ?, ?, ?, ?, ?, ?)`, + ) + if err != nil { + db.Close() + return + } + + nicodb = &NicoDB{ + db: db, + stInsertMedia: stInsertMedia, + } + + return +} +func (ndb *NicoDB) Close() { + ndb.mtx.Lock() + defer ndb.mtx.Unlock() + + if ndb.stInsertMedia != nil { + ndb.stInsertMedia.Close() + } + if ndb.db != nil { + ndb.db.Close() + } +} + +func (ndb *NicoDB) FindBySeqNo(ctx context.Context, seqNo uint64) bool { + ndb.mtx.Lock() + defer ndb.mtx.Unlock() + + var val uint64 + err := ndb.db.QueryRowContext(ctx, `SELECT seqno FROM media WHERE seqno = ?`, seqNo).Scan(&val) + if err != nil { + return false + } + + return true +} + +func (ndb *NicoDB) GetNextPosition(ctx context.Context) (position float64) { + ndb.mtx.Lock() + defer ndb.mtx.Unlock() + + rows, err := ndb.db.QueryContext(ctx, `SELECT seqno, position FROM media ORDER BY seqno`) + if err != nil { + fmt.Println(err) + return + } + defer rows.Close() + + var seqno uint64 + var pos float64 + var i uint64 + for rows.Next() { + rows.Scan(&seqno, &pos) + //fmt.Printf("%v %v\n", seqno, pos) + if i != seqno { + break + } else { + position = pos + i++ + } + } + + fmt.Printf("position is %v\n", position) + + return +} + +// InsertMedia HLSのチャンクをDBに入れる +func (ndb *NicoDB) InsertMedia(seqNo uint64, position, duration float64, bandwidth, size int64, data []byte) error { + ndb.mtx.Lock() + defer ndb.mtx.Unlock() + + _, err := ndb.stInsertMedia.Exec(seqNo, position, duration, bandwidth, size, data) + return err +} + +/* +func (db *NicoDB) dbCreate() (err error) { + hls.dbMtx.Lock() + defer hls.dbMtx.Unlock() + + // table media + + _, err = hls.db.Exec(` + CREATE TABLE IF NOT EXISTS media ( + seqno INTEGER PRIMARY KEY NOT NULL UNIQUE, + current INTEGER, + position REAL, + notfound INTEGER, + bandwidth INTEGER, + size INTEGER, + data BLOB + ) + `) + if err != nil { + return + } + + _, err = hls.db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS media0 ON media(seqno); + CREATE INDEX IF NOT EXISTS media1 ON media(position); + ---- for debug ---- + CREATE INDEX IF NOT EXISTS media100 ON media(size); + CREATE INDEX IF NOT EXISTS media101 ON media(notfound); + `) + if err != nil { + return + } + + // table comment + + _, err = hls.db.Exec(` + CREATE TABLE IF NOT EXISTS comment ( + vpos INTEGER NOT NULL, + date INTEGER NOT NULL, + date_usec INTEGER NOT NULL, + date2 INTEGER NOT NULL, + no INTEGER, + anonymity INTEGER, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + mail TEXT, + premium INTEGER, + score INTEGER, + thread INTEGER, + origin TEXT, + locale TEXT, + hash TEXT UNIQUE NOT NULL + )`) + if err != nil { + return + } + + _, err = hls.db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS comment0 ON comment(hash); + ---- for debug ---- + CREATE INDEX IF NOT EXISTS comment100 ON comment(date2); + CREATE INDEX IF NOT EXISTS comment101 ON comment(no); + `) + if err != nil { + return + } + + // kvs media + + _, err = hls.db.Exec(` + CREATE TABLE IF NOT EXISTS kvs ( + k TEXT PRIMARY KEY NOT NULL UNIQUE, + v BLOB + ) + `) + if err != nil { + return + } + _, err = hls.db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS kvs0 ON kvs(k); + `) + if err != nil { + return + } + + //hls.__dbBegin() + + return +} + +// timeshift +func (hls *NicoHls) dbSetPosition() { + hls.dbExec(`UPDATE media SET position = ? WHERE seqno=?`, + hls.playlist.position, + hls.playlist.seqNo, + ) +} + +// timeshift +func (hls *NicoHls) dbGetLastPosition() (res float64) { + hls.dbMtx.Lock() + defer hls.dbMtx.Unlock() + + hls.db.QueryRow("SELECT position FROM media ORDER BY POSITION DESC LIMIT 1").Scan(&res) + return +} + +//func (hls *NicoHls) __dbBegin() { +// return +/////////////////////////////////////////// +//hls.db.Exec(`BEGIN TRANSACTION`) +//} +//func (hls *NicoHls) __dbCommit(t time.Time) { +// return +/////////////////////////////////////////// + +//// Never hls.dbMtx.Lock() +//var start int64 +//hls.db.Exec(`COMMIT; BEGIN TRANSACTION`) +//if t.UnixNano() - hls.lastCommit.UnixNano() > 500000000 { +// log.Printf("Commit: %s\n", hls.dbName) +//} +//hls.lastCommit = t +//} +func (hls *NicoHls) dbCommit() { + // hls.dbMtx.Lock() + // defer hls.dbMtx.Unlock() + + // hls.__dbCommit(time.Now()) +} +func (hls *NicoHls) dbExec(query string, args ...interface{}) { + hls.dbMtx.Lock() + defer hls.dbMtx.Unlock() + + if hls.nicoDebug { + start := time.Now().UnixNano() + defer func() { + t := (time.Now().UnixNano() - start) / (1000 * 1000) + if t > 100 { + fmt.Fprintf(os.Stderr, "%s:[WARN]dbExec: %d(ms):%s\n", debug_Now(), t, query) + } + }() + } + + if _, err := hls.db.Exec(query, args...); err != nil { + fmt.Printf("dbExec %#v\n", err) + //hls.db.Exec("COMMIT") + hls.db.Close() + os.Exit(1) + } +} + +func (hls *NicoHls) dbKVSet(k string, v interface{}) { + query := `INSERT OR REPLACE INTO kvs (k,v) VALUES (?,?)` + hls.startDBGoroutine(func(sig <-chan struct{}) int { + hls.dbExec(query, k, v) + return OK + }) +} + +func (hls *NicoHls) dbInsertReplaceOrIgnore(table string, data map[string]interface{}, replace bool) { + var keys []string + var qs []string + var args []interface{} + + for k, v := range data { + keys = append(keys, k) + qs = append(qs, "?") + args = append(args, v) + } + + var replaceOrIgnore string + if replace { + replaceOrIgnore = "REPLACE" + } else { + replaceOrIgnore = "IGNORE" + } + + query := fmt.Sprintf( + `INSERT OR %s INTO %s (%s) VALUES (%s)`, + replaceOrIgnore, + table, + strings.Join(keys, ","), + strings.Join(qs, ","), + ) + + hls.startDBGoroutine(func(sig <-chan struct{}) int { + hls.dbExec(query, args...) + return OK + }) +} + +func (hls *NicoHls) dbInsert(table string, data map[string]interface{}) { + hls.dbInsertReplaceOrIgnore(table, data, false) +} +func (hls *NicoHls) dbReplace(table string, data map[string]interface{}) { + hls.dbInsertReplaceOrIgnore(table, data, true) +} + +// timeshift +func (hls *NicoHls) dbGetFromWhen() (res_from int, when float64) { + hls.dbMtx.Lock() + defer hls.dbMtx.Unlock() + var date2 int64 + var no int + + hls.db.QueryRow("SELECT date2, no FROM comment ORDER BY date2 ASC LIMIT 1").Scan(&date2, &no) + res_from = no + if res_from <= 0 { + res_from = 1 + } + + if date2 == 0 { + var endTime float64 + hls.db.QueryRow(`SELECT v FROM kvs WHERE k = "endTime"`).Scan(&endTime) + + when = endTime + } else { + when = float64(date2) / (1000 * 1000) + } + + return +} + +func WriteComment(db *sql.DB, fileName string, skipHb bool) { + + rows, err := db.Query(SelComment) + if err != nil { + log.Println(err) + return + } + defer rows.Close() + + fileName = files.ChangeExtention(fileName, "xml") + + dir := filepath.Dir(fileName) + base := filepath.Base(fileName) + base, err = files.GetFileNameNext(base) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + fileName = filepath.Join(dir, base) + f, err := os.Create(fileName) + if err != nil { + log.Fatalln(err) + } + defer f.Close() + fmt.Fprintf(f, "%s\r\n", ``) + fmt.Fprintf(f, "%s\r\n", ``) + + for rows.Next() { + var vpos int64 + var date int64 + var date_usec int64 + var no int64 + var anonymity int64 + var user_id string + var content string + var mail string + var premium int64 + var score int64 + var thread int64 + var origin string + var locale string + err = rows.Scan( + &vpos, + &date, + &date_usec, + &no, + &anonymity, + &user_id, + &content, + &mail, + &premium, + &score, + &thread, + &origin, + &locale, + ) + if err != nil { + log.Println(err) + return + } + + // skip /hb + if (premium > 1) && skipHb && strings.HasPrefix(content, "/hb ") { + continue + } + + line := fmt.Sprintf( + `= 0 { + line += fmt.Sprintf(` no="%d"`, no) + } + if anonymity != 0 { + line += fmt.Sprintf(` anonymity="%d"`, anonymity) + } + if mail != "" { + mail = strings.Replace(mail, `"`, """, -1) + mail = strings.Replace(mail, "&", "&", -1) + mail = strings.Replace(mail, "<", "<", -1) + line += fmt.Sprintf(` mail="%s"`, mail) + } + if origin != "" { + origin = strings.Replace(origin, `"`, """, -1) + origin = strings.Replace(origin, "&", "&", -1) + origin = strings.Replace(origin, "<", "<", -1) + line += fmt.Sprintf(` origin="%s"`, origin) + } + if premium != 0 { + line += fmt.Sprintf(` premium="%d"`, premium) + } + if score != 0 { + line += fmt.Sprintf(` score="%d"`, score) + } + if locale != "" { + locale = strings.Replace(locale, `"`, """, -1) + locale = strings.Replace(locale, "&", "&", -1) + locale = strings.Replace(locale, "<", "<", -1) + line += fmt.Sprintf(` locale="%s"`, locale) + } + line += ">" + content = strings.Replace(content, "&", "&", -1) + content = strings.Replace(content, "<", "<", -1) + line += content + line += "" + fmt.Fprintf(f, "%s\r\n", line) + } + fmt.Fprintf(f, "%s\r\n", ``) +} + +// ts +func (hls *NicoHls) dbGetLastMedia(i int) (res []byte) { + hls.dbMtx.Lock() + defer hls.dbMtx.Unlock() + hls.db.QueryRow("SELECT data FROM media WHERE seqno = ?", i).Scan(&res) + return +} +func (hls *NicoHls) dbGetLastSeqNo() (res int64) { + hls.dbMtx.Lock() + defer hls.dbMtx.Unlock() + hls.db.QueryRow("SELECT seqno FROM media ORDER BY seqno DESC LIMIT 1").Scan(&res) + return +} + +*/ diff --git a/src/niconico/nicoprop/nicoprop.go b/src/niconico/nicoprop/nicoprop.go new file mode 100644 index 0000000..f343c5a --- /dev/null +++ b/src/niconico/nicoprop/nicoprop.go @@ -0,0 +1,57 @@ +package nicoprop + +import "fmt" + +type stream struct { + MaxQuality string `json:"maxQuality"` // normal high +} +type supplier struct { + Name string `json:"name"` + PageURL string `json:"pageUrl"` // http://www.nicovideo.jp/user/XXXX +} +type program struct { + NicoliveProgramID string `json:"nicoliveProgramId"` + ProviderType string `json:"providerType"` + Status string `json:"status"` // ON_AIR + Stream stream `json:"stream"` + Supplier supplier `json:"supplier"` + Title string `json:"title"` +} + +type socialGroup struct { + CompanyName string `json:"companyName"` // 株式会社 ドワンゴ + ID string `json:"id"` // コミュID + Name string `json:"name"` // コミュ名 + Type string `json:"type"` // "channel" +} +type community struct { + ID string `json:"id"` +} +type channel struct { + ID string `json:"id"` +} +type relive struct { + WebSocketUrl string `json:"webSocketUrl"` +} +type site struct { + Relive relive `json:"relive"` +} +type NicoProperty struct { + Channel channel `json:"channel"` + Community community `json:"community"` + Program program `json:"program"` + SocialGroup socialGroup `json:"socialGroup"` + Site site `json:"site"` +} + +func (p NicoProperty) GetID() string { + return p.Program.NicoliveProgramID +} + +func (p NicoProperty) GetName() string { + return fmt.Sprintf("%s/%s", p.SocialGroup.Name, p.Program.Supplier.Name) +} + +func (p NicoProperty) GetTitle() string { + return p.Program.Title +} diff --git a/src/niconico/property/nico.go b/src/niconico/property/nico.go new file mode 100644 index 0000000..57f8b3e --- /dev/null +++ b/src/niconico/property/nico.go @@ -0,0 +1,10 @@ +package nicoprop + +type socialGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} +type nicoProperty struct { + SocialGroup socialGroup `json:"socialGroup"` +} diff --git a/view/index.html b/view/index.html new file mode 100644 index 0000000..9220da2 --- /dev/null +++ b/view/index.html @@ -0,0 +1,375 @@ + +
+ {{ .title }} + +
+ + + + + + + +
+ +

+ {{ .title }} +

+ + + + + + +
+ + + + \ No newline at end of file diff --git a/view/template1.html b/view/template1.html new file mode 100644 index 0000000..b435a28 --- /dev/null +++ b/view/template1.html @@ -0,0 +1,5 @@ + +

+ {{ .title }} +

+