From c1270e549a2d7266695ae33f806356b392668788 Mon Sep 17 00:00:00 2001 From: akuma06 Date: Thu, 15 Jun 2017 15:43:18 +0200 Subject: [PATCH] EZTV and Torznab support EZTV is fully supported afaik and can be accessed to /feed/eztv Torznab is supported on display, miss t=caps route and can be accessed to /feed/torznab --- router/router.go | 2 + router/rss_handler.go | 242 ++++++++++++++++++++++++++++++++---------- util/feeds/rss.go | 199 ++++++++++++++++++++++++++++++++++ 3 files changed, 387 insertions(+), 56 deletions(-) create mode 100644 util/feeds/rss.go diff --git a/router/router.go b/router/router.go index dc0ac2da..4594523a 100755 --- a/router/router.go +++ b/router/router.go @@ -57,6 +57,8 @@ func init() { Router.HandleFunc("/activities", ActivityListHandler).Name("activity_list") Router.HandleFunc("/feed", RSSHandler).Name("feed") Router.HandleFunc("/feed/{page}", RSSHandler).Name("feed_page") + Router.HandleFunc("/feed/torznab", RSSTorznabHandler).Name("feed_torznab") + Router.HandleFunc("/feed/eztv", RSSEztvHandler).Name("feed_eztv") // !!! This line need to have the same download location as the one define in config.TorrentStorageLink !!! Router.Handle("/download/{hash}", wrapHandler(downloadTorrentHandler)).Name("torrent_download") diff --git a/router/rss_handler.go b/router/rss_handler.go index 33bd3a52..7cd9ff49 100644 --- a/router/rss_handler.go +++ b/router/rss_handler.go @@ -1,13 +1,16 @@ package router import ( + "errors" "html" "net/http" "strconv" "time" "github.com/NyaaPantsu/nyaa/config" + "github.com/NyaaPantsu/nyaa/model" userService "github.com/NyaaPantsu/nyaa/service/user" + "github.com/NyaaPantsu/nyaa/util/feeds" "github.com/NyaaPantsu/nyaa/util/search" "github.com/gorilla/feeds" "github.com/gorilla/mux" @@ -16,80 +19,33 @@ import ( // RSSHandler : Controller for displaying rss feed, accepting common search arguments func RSSHandler(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - vars := mux.Vars(r) - page := vars["page"] - userID := vars["id"] - offset := r.URL.Query().Get("offset") - var err error - pagenum := 1 - if page == "" && offset != "" { - page = offset - } - if page != "" { - pagenum, err = strconv.Atoi(html.EscapeString(page)) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if pagenum <= 0 { - NotFoundHandler(w, r) - return - } - } + // We only get the basic variable for rss based on search param + torrents, createdAsTime, title, err := getTorrentList(r) - if userID != "" { - userIDnum, err := strconv.Atoi(html.EscapeString(userID)) - // Should we have a feed for anonymous uploads? - if err != nil || userIDnum == 0 { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - _, _, err = userService.RetrieveUserForAdmin(userID) - if err != nil { - http.Error(w, "", http.StatusNotFound) - return - } - - // Set the user ID on the request, so that SearchByQuery finds it. - query := r.URL.Query() - query.Set("userID", userID) - r.URL.RawQuery = query.Encode() - } - - _, torrents, err := search.SearchByQueryNoCount(r, pagenum) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusBadRequest) return } - createdAsTime := time.Now() - if len(torrents) > 0 { - createdAsTime = torrents[0].Date - } - title := "Nyaa Pantsu" - if config.IsSukebei() { - title = "Sukebei Pantsu" - } - feed := &feeds.RssFeed{ + feed := &nyaafeeds.RssFeed{ Title: title, Link: config.WebAddress() + "/", PubDate: createdAsTime.String(), } - feed.Items = make([]*feeds.RssItem, len(torrents)) + feed.Items = make([]*nyaafeeds.RssItem, len(torrents)) for i, torrent := range torrents { torrentJSON := torrent.ToJSON() - feed.Items[i] = &feeds.RssItem{ + feed.Items[i] = &nyaafeeds.RssItem{ Title: torrentJSON.Name, Link: config.WebAddress() + "/view/" + strconv.FormatUint(uint64(torrentJSON.ID), 10), Description: string(torrentJSON.Description), Author: config.WebAddress() + "/view/" + strconv.FormatUint(uint64(torrentJSON.ID), 10), PubDate: torrent.Date.String(), - Guid: config.WebAddress() + "/download/" + torrentJSON.Hash, - Enclosure: &feeds.RssEnclosure{ - Url: config.WebAddress() + "/download/" + torrentJSON.Hash, + GUID: config.WebAddress() + "/download/" + torrentJSON.Hash, + Enclosure: &nyaafeeds.RssEnclosure{ + URL: config.WebAddress() + "/download/" + torrentJSON.Hash, Length: strconv.FormatUint(uint64(torrentJSON.Filesize), 10), Type: "application/x-bittorrent", }, @@ -107,3 +63,177 @@ func RSSHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, writeErr.Error(), http.StatusInternalServerError) } } + +// RSSEztvHandler : Controller for displaying rss feed, accepting common search arguments +func RSSEztvHandler(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + // We only get the basic variable for rss based on search param + torrents, createdAsTime, title, err := getTorrentList(r) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + feed := &nyaafeeds.RssFeed{ + Title: title, + Link: config.WebAddress() + "/", + PubDate: createdAsTime.String(), + } + feed.Items = make([]*nyaafeeds.RssItem, len(torrents)) + + for i, torrent := range torrents { + torrentJSON := torrent.ToJSON() + feed.Items[i] = &nyaafeeds.RssItem{ + Title: torrentJSON.Name, + Link: config.WebAddress() + "/download/" + torrentJSON.Hash, + Category: &nyaafeeds.RssCategory{ + Domain: config.WebAddress() + "/search?c=" + torrentJSON.Category + "_" + torrentJSON.SubCategory, + }, + Description: string(torrentJSON.Description), + Comments: config.WebAddress() + "/view/" + strconv.FormatUint(uint64(torrentJSON.ID), 10), + PubDate: torrent.Date.String(), + GUID: config.WebAddress() + "/view/" + strconv.FormatUint(uint64(torrentJSON.ID), 10), + Enclosure: &nyaafeeds.RssEnclosure{ + URL: config.WebAddress() + "/download/" + torrentJSON.Hash, + Length: strconv.FormatUint(uint64(torrentJSON.Filesize), 10), + Type: "application/x-bittorrent", + }, + Torrent: &nyaafeeds.RssTorrent{ + Xmlns: "http://xmlns.ezrss.it/0.1/", + FileName: torrentJSON.Name + ".torrent", + ContentLength: strconv.FormatUint(uint64(torrentJSON.Filesize), 10), + InfoHash: torrentJSON.Hash, + MagnetURI: string(torrentJSON.Magnet), + }, + } + } + // allow cross domain AJAX requests + w.Header().Set("Access-Control-Allow-Origin", "*") + rss, rssErr := feeds.ToXML(feed) + if rssErr != nil { + http.Error(w, rssErr.Error(), http.StatusInternalServerError) + } + + _, writeErr := w.Write([]byte(rss)) + if writeErr != nil { + http.Error(w, writeErr.Error(), http.StatusInternalServerError) + } +} + +// RSSEztvHandler : Controller for displaying rss feed, accepting common search arguments +func RSSTorznabHandler(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + // We only get the basic variable for rss based on search param + torrents, createdAsTime, title, err := getTorrentList(r) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + feed := &nyaafeeds.RssFeed{ + Title: title, + Link: config.WebAddress() + "/", + PubDate: createdAsTime.String(), + } + feed.Items = make([]*nyaafeeds.RssItem, len(torrents)) + + for i, torrent := range torrents { + torrentJSON := torrent.ToJSON() + feed.Items[i] = &nyaafeeds.RssItem{ + Title: torrentJSON.Name, + Link: config.WebAddress() + "/download/" + torrentJSON.Hash, + Category: &nyaafeeds.RssCategory{ + Domain: config.WebAddress() + "/search?c=" + torrentJSON.Category + "_" + torrentJSON.SubCategory, + }, + Description: string(torrentJSON.Description), + Comments: config.WebAddress() + "/view/" + strconv.FormatUint(uint64(torrentJSON.ID), 10), + PubDate: torrent.Date.String(), + GUID: config.WebAddress() + "/view/" + strconv.FormatUint(uint64(torrentJSON.ID), 10), + Enclosure: &nyaafeeds.RssEnclosure{ + URL: config.WebAddress() + "/download/" + torrentJSON.Hash, + Length: strconv.FormatUint(uint64(torrentJSON.Filesize), 10), + Type: "application/x-bittorrent", + }, + Torznab: &nyaafeeds.RssTorznab{ + Xmlns: "http://torznab.com/schemas/2015/feed", + Size: strconv.FormatUint(uint64(torrentJSON.Filesize), 10), + Files: strconv.Itoa(len(torrentJSON.FileList)), + Grabs: strconv.Itoa(torrentJSON.Downloads), + Seeders: strconv.Itoa(int(torrentJSON.Seeders)), + Leechers: strconv.Itoa(int(torrentJSON.Leechers)), + Infohash: torrentJSON.Hash, + MagnetURL: string(torrentJSON.Magnet), + }, + } + } + // allow cross domain AJAX requests + w.Header().Set("Access-Control-Allow-Origin", "*") + rss, rssErr := feeds.ToXML(feed) + if rssErr != nil { + http.Error(w, rssErr.Error(), http.StatusInternalServerError) + } + + _, writeErr := w.Write([]byte(rss)) + if writeErr != nil { + http.Error(w, writeErr.Error(), http.StatusInternalServerError) + } +} + +func getTorrentList(r *http.Request) (torrents []model.Torrent, createdAsTime time.Time, title string, err error) { + vars := mux.Vars(r) + page := vars["page"] + userID := vars["id"] + + offset := r.URL.Query().Get("offset") + pagenum := 1 + if page == "" && offset != "" { + page = offset + } + if page != "" { + pagenum, err = strconv.Atoi(html.EscapeString(page)) + if err != nil { + return + } + if pagenum <= 0 { + err = errors.New("Page number is invalid") + return + } + } + + if userID != "" { + userIDnum := 0 + userIDnum, err = strconv.Atoi(html.EscapeString(userID)) + // Should we have a feed for anonymous uploads? + if err != nil || userIDnum == 0 { + return + } + + _, _, err = userService.RetrieveUserForAdmin(userID) + if err != nil { + return + } + // Set the user ID on the request, so that SearchByQuery finds it. + query := r.URL.Query() + query.Set("userID", userID) + r.URL.RawQuery = query.Encode() + } + + _, torrents, err = search.SearchByQueryNoCount(r, pagenum) + + createdAsTime = time.Now() + + if len(torrents) > 0 { + createdAsTime = torrents[0].Date + } + + title = "Nyaa Pantsu" + if config.IsSukebei() { + title = "Sukebei Pantsu" + } + + return +} diff --git a/util/feeds/rss.go b/util/feeds/rss.go new file mode 100644 index 00000000..2dd8eec5 --- /dev/null +++ b/util/feeds/rss.go @@ -0,0 +1,199 @@ +package nyaafeeds + +// rss support +// validation done according to spec here: +// http://cyber.law.harvard.edu/rss/rss.html + +import ( + "encoding/xml" + "fmt" + "strconv" + "time" + + "github.com/gorilla/feeds" +) + +// private wrapper around the RssFeed which gives us the .. xml +type rssFeedXML struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel *RssFeed +} + +type RssImage struct { + XMLName xml.Name `xml:"image"` + URL string `xml:"url"` + Title string `xml:"title"` + Link string `xml:"link"` + Width int `xml:"width,omitempty"` + Height int `xml:"height,omitempty"` +} + +type RssTextInput struct { + XMLName xml.Name `xml:"textInput"` + Title string `xml:"title"` + Description string `xml:"description"` + Name string `xml:"name"` + Link string `xml:"link"` +} + +type RssFeed struct { + XMLName xml.Name `xml:"channel"` + Title string `xml:"title"` // required + Link string `xml:"link"` // required + Description string `xml:"description"` // required + Language string `xml:"language,omitempty"` + Copyright string `xml:"copyright,omitempty"` + ManagingEditor string `xml:"managingEditor,omitempty"` // Author used + WebMaster string `xml:"webMaster,omitempty"` + PubDate string `xml:"pubDate,omitempty"` // created or updated + LastBuildDate string `xml:"lastBuildDate,omitempty"` // updated used + Category string `xml:"category,omitempty"` + Generator string `xml:"generator,omitempty"` + Docs string `xml:"docs,omitempty"` + Cloud string `xml:"cloud,omitempty"` + TTL int `xml:"ttl,omitempty"` + Rating string `xml:"rating,omitempty"` + SkipHours string `xml:"skipHours,omitempty"` + SkipDays string `xml:"skipDays,omitempty"` + Image *RssImage + TextInput *RssTextInput + Items []*RssItem +} + +type RssItem struct { + XMLName xml.Name `xml:"item"` + Title string `xml:"title"` // required + Link string `xml:"link"` // required + Description string `xml:"description"` // required + Author string `xml:"author,omitempty"` + Category *RssCategory `xml:"category,omitempty"` + Comments string `xml:"comments,omitempty"` + Enclosure *RssEnclosure + GUID string `xml:"guid,omitempty"` // Id used + PubDate string `xml:"pubDate,omitempty"` // created or updated + Source string `xml:"source,omitempty"` + Torrent *RssTorrent `xml:"torrent,omitempty"` + Torznab *RssTorznab `xml:"torznab,omitempty"` +} + +type RssTorrent struct { + XMLName xml.Name `xml:"torrent"` + Xmlns string `xml:"xmlns,attr"` + FileName string `xml:"fileName,omitempty"` + ContentLength string `xml:"contentLength,omitempty"` + InfoHash string `xml:"infoHash,omitempty"` + MagnetURI string `xml:"magnetUri,omitempty"` +} + +type RssTorznab struct { + XMLName xml.Name `xml:"torznab"` + Xmlns string `xml:"xmlns,attr"` + Type string `xml:"type,omitempty"` + Size string `xml:"size,omitempty"` + Files string `xml:"files,omitempty"` + Grabs string `xml:"grabs,omitempty"` + Tvdbid string `xml:"tvdbid,omitempty"` + Rageid string `xml:"rageid,omitempty"` + Tvmazeid string `xml:"tvmazeid,omitempty"` + Imdb string `xml:"imdb,omitempty"` + BannerURL string `xml:"bannerurl,omitempty"` + Infohash string `xml:"infohash,omitempty"` + MagnetURL string `xml:"magneturl,omitempty"` + Seeders string `xml:"seeders,omitempty"` + Leechers string `xml:"leechers,omitempty"` + Peers string `xml:"peers,omitempty"` + SeedType string `xml:"seedtype,omitempty"` + MinimumRatio string `xml:"minimumratio,omitempty"` + MinimumSeedTime string `xml:"minimumseedtime,omitempty"` + DownloadVolumeFactor string `xml:"downloadvolumefactor,omitempty"` + UploadVolumeFactor string `xml:"uploadvolumefactor,omitempty"` +} + +// RssCategory is a category for rss item +type RssCategory struct { + XMLName xml.Name `xml:"category"` + Domain string `xml:"domain"` +} + +type RssEnclosure struct { + //RSS 2.0 + XMLName xml.Name `xml:"enclosure"` + URL string `xml:"url,attr"` + Length string `xml:"length,attr"` + Type string `xml:"type,attr"` +} + +type Rss struct { + *feeds.Feed +} + +// create a new RssItem with a generic Item struct's data +func newRssItem(i *feeds.Item) *RssItem { + item := &RssItem{ + Title: i.Title, + Link: i.Link.Href, + Description: i.Description, + GUID: i.Id, + PubDate: anyTimeFormat(time.RFC1123Z, i.Created, i.Updated), + } + + intLength, err := strconv.ParseInt(i.Link.Length, 10, 64) + + if err == nil && (intLength > 0 || i.Link.Type != "") { + item.Enclosure = &RssEnclosure{URL: i.Link.Href, Type: i.Link.Type, Length: i.Link.Length} + } + if i.Author != nil { + item.Author = i.Author.Name + } + return item +} + +// returns the first non-zero time formatted as a string or "" +func anyTimeFormat(format string, times ...time.Time) string { + for _, t := range times { + if !t.IsZero() { + return t.Format(format) + } + } + return "" +} + +// RssFeed : create a new RssFeed with a generic Feed struct's data +func (r *Rss) RssFeed() *RssFeed { + pub := anyTimeFormat(time.RFC1123Z, r.Created, r.Updated) + build := anyTimeFormat(time.RFC1123Z, r.Updated) + author := "" + if r.Author != nil { + author = r.Author.Email + if len(r.Author.Name) > 0 { + author = fmt.Sprintf("%s (%s)", r.Author.Email, r.Author.Name) + } + } + + channel := &RssFeed{ + Title: r.Title, + Link: r.Link.Href, + Description: r.Description, + ManagingEditor: author, + PubDate: pub, + LastBuildDate: build, + Copyright: r.Copyright, + } + for _, i := range r.Items { + channel.Items = append(channel.Items, newRssItem(i)) + } + return channel +} + +// FeedXml : return an XML-Ready object for an Rss object +func (r *Rss) FeedXml() interface{} { + // only generate version 2.0 feeds for now + return r.RssFeed().FeedXml() + +} + +// FeedXml : return an XML-ready object for an RssFeed object +func (r *RssFeed) FeedXml() interface{} { + return &rssFeedXML{Version: "2.0", Channel: r} +}