1
0
mirror of https://github.com/Luzifer/mediatimeline.git synced 2024-09-16 14:38:27 +00:00

Initial implementation

This commit is contained in:
Knut Ahlers 2019-04-23 15:54:42 +02:00
commit bf68f32c0e
Signed by: luzifer
GPG Key ID: DC2729FDD34BE99E
6 changed files with 733 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env
mediatimeline
runwrap.sh
tweets.db

260
frontend/index.html Normal file
View File

@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
<title>MediaTimeline</title>
<!-- Bootstrap -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@4.3.1/dist/darkly/bootstrap.min.css"
integrity="sha256-6W1mxPaAt4a6pkJVW5x5Xmq/LvxuQpR9dlzgy77SeZs=" crossorigin="anonymous">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css"
integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous">
<style>
.card-img-top {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
padding-bottom: 100%;
}
.faved {
color: yellow;
}
.user-image {
border-radius: 1.25rem;
height: 36px;
}
.tweet {
max-height: 50px;
white-space: pre-line;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-5">
<a class="navbar-brand" href="#">MediaTimeline Viewer</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</nav>
<div class="container">
<div class="row">
<div class="col-md-3 col-12" v-for="tweet in tweets" :key="tweet.id" v-if="tweet.images">
<div class="card mb-3">
<div :style="`background-image: url('${tweet.images[0].image}')`" class="card-img-top"></div>
<div class="card-body">
<div class="media">
<img :src="tweet.user.image" class="mr-2 user-image">
<div class="media-body">
<h6 class="mt-0 mb-0">{{ tweet.user.screen_name }}</h6>
<small>{{ moment(tweet.posted).format('lll') }}</small>
</div>
</div>
</div>
<div class="card-footer text-right">
<a class="btn btn-sm btn-secondary" :href="`https://twitter.com/${tweet.user.screen_name}/status/${tweet.id}`">
<i class="fas fa-link"></i>
</a>
<button class="btn btn-sm btn-secondary" @click="refetch(tweet)">
<i class="fas fa-sync"></i>
</button>
<button
:class="{ btn: true, 'btn-sm': true, 'btn-secondary': true, 'faved': tweet.favorited }"
@click="favourite(tweet)"
>
<i class="fas fa-star"></i>
</button>
<button class="btn btn-sm btn-secondary" @click="callModal(tweet)">
<i class="fas fa-search-plus"></i>
<span class="badge badge-light badge-pill" v-if="tweet.images.length > 1">{{ tweet.images.length }}</span>
</button>
</div>
</div>
</div>
</div>
<div class="modal" tabindex="-1" role="dialog" v-if="modalTweet">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ modalTweet.user.screen_name }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div id="carouselExampleControls" class="carousel slide">
<div class="carousel-inner">
<div :class="{ 'carousel-item': true, 'active': idx == 0 }" v-for="(image, idx) in modalTweet.images">
<img :src="image.image" class="d-block w-100">
</div>
</div>
<a class="carousel-control-prev" href="#carouselExampleControls" role="button" data-slide="prev" v-if="modalTweet.images.length > 1">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next" href="#carouselExampleControls" role="button" data-slide="next" v-if="modalTweet.images.length > 1">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div> <!-- /.container -->
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="https://cdn.jsdelivr.net/npm/jquery@3.4.0/dist/jquery.min.js"
integrity="sha256-BJeo0qm959uMBGb65z40ejJYGSgR7REI4+CW1fNKwOg=" crossorigin="anonymous"></script>
<!-- Include all compiled plugins (below), or include individual files as needed -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/js/bootstrap.min.js"
integrity="sha256-CjSoeELFOcH0/uxWu6mC/Vlrc1AARqbm/jiiImDGV3s=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"
integrity="sha256-chlNFSVx3TdcQ2Xlw7SvnbLAavAQLO0Y/LBiWX04viY=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.18.0/dist/axios.min.js"
integrity="sha256-mpnrJ5DpEZZkwkE1ZgkEQQJW/46CSEh/STrZKOB/qoM=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.24.0/min/moment.min.js"
integrity="sha256-4iQZ6BVL4qNKlQ27TExEhBN1HFPvAvAMbFavKKosSWQ=" crossorigin="anonymous"></script>
<script>
function sortOrder(i, j) {
switch (true) {
case i < j:
return -1
case j < i:
return 1
default:
return 0
}
}
let app = new Vue({
computed: {
},
data: {
tweets: [],
modalTweet: null,
},
el: ".container",
methods: {
callModal(tweet) {
this.modalTweet = tweet
},
favourite(tweet) {
axios
.post('/api/favourite', { id: tweet.id })
.then((res) => {
if (res.data.length === 0) {
return
}
this.upsertTweets(res.data)
})
.catch((err) => console.log(err))
},
refetch(tweet) {
axios
.post('/api/refresh', { id: tweet.id })
.then((res) => {
if (res.data.length === 0) {
return
}
this.upsertTweets(res.data)
})
.catch((err) => console.log(err))
},
refresh() {
let apiURL = '/api/page' // By default query page 1
if (this.tweets.length > 0) {
apiURL = `/api/since?id=${this.tweets[0].id}`
}
axios
.get(apiURL)
.then((resp) => {
if (resp.data.length === 0) {
return
}
this.upsertTweets(resp.data)
})
.catch((err) => {
console.log(err)
})
},
upsertTweets(data) {
let tweets = this.tweets
for (idx in data) {
let tweet = data[idx]
let inserted = false
for (let i = 0; i < tweets.length; i++) {
if (tweets[i].id == tweet.id) {
tweets[i] = tweet
inserted = true
break
}
}
if (!inserted) {
tweets = [...tweets, tweet]
}
}
tweets.sort((j, i) => sortOrder(i.id, j.id))
this.tweets = tweets
},
},
mounted() {
this.refresh()
window.setInterval(this.refresh, 30000)
},
watch: {
modalTweet() {
window.setTimeout(() => {
$('.modal').on('hide.bs.modal', () => {
// When modal is closed clean it up
$('.carousel').carousel('dispose')
this.modalTweet = null
})
$('.modal').modal('show')
$('.carousel').carousel({
pause: true,
})
}, 100)
},
},
})
</script>
</body>
</html>

131
http.go Normal file
View File

@ -0,0 +1,131 @@
package main
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"github.com/ChimeraCoder/anaconda"
log "github.com/sirupsen/logrus"
)
func init() {
http.HandleFunc("/api/page", handlePage)
http.HandleFunc("/api/since", handleNewest)
http.HandleFunc("/api/favourite", handleFavourite)
http.HandleFunc("/api/refresh", handleTweetRefresh)
}
func handlePage(w http.ResponseWriter, r *http.Request) {
var page int = 1
if p, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && p > 0 {
page = p
}
tweets, err := tweetStore.GetTweetPage(page)
if err != nil {
log.WithError(err).Error("Unable to fetch tweets for page request")
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
tweetResponse(w, tweets)
}
func handleNewest(w http.ResponseWriter, r *http.Request) {
var since uint64 = 0
if s, err := strconv.ParseUint(r.URL.Query().Get("id"), 10, 64); err == nil && s > 0 {
since = s
}
if since == 0 {
http.Error(w, "Must specify last id", http.StatusBadRequest)
return
}
tweets, err := tweetStore.GetTweetsSince(since)
if err != nil {
log.WithError(err).Error("Unable to fetch tweets for newest request")
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
tweetResponse(w, tweets)
}
func tweetResponse(w http.ResponseWriter, tweets []tweet) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(tweets)
}
func handleFavourite(w http.ResponseWriter, r *http.Request) {
req := struct {
ID int64 `json:"id,string"`
}{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID == 0 {
http.Error(w, "Need to specify id", http.StatusBadRequest)
return
}
tweet, err := twitter.Favorite(req.ID)
if err != nil {
log.WithError(err).Error("Unable to favourite tweet")
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
tweets, err := convertTweetList([]anaconda.Tweet{tweet}, true)
if err != nil {
log.WithError(err).Error("Unable to convert tweet for storing")
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
if err = tweetStore.StoreTweets(tweets); err != nil {
log.WithError(err).Error("Unable to update tweet")
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
tweetResponse(w, tweets)
}
func handleTweetRefresh(w http.ResponseWriter, r *http.Request) {
req := struct {
ID int64 `json:"id,string"`
}{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID == 0 {
http.Error(w, "Need to specify id", http.StatusBadRequest)
return
}
tweet, err := twitter.GetTweet(req.ID, url.Values{})
if err != nil {
log.WithError(err).Error("Unable to fetch tweet")
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
tweets, err := convertTweetList([]anaconda.Tweet{tweet}, true)
if err != nil {
log.WithError(err).Error("Unable to convert tweet for storing")
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
if err = tweetStore.StoreTweets(tweets); err != nil {
log.WithError(err).Error("Unable to update tweet")
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
tweetResponse(w, tweets)
}

101
main.go Normal file
View File

@ -0,0 +1,101 @@
package main
import (
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"time"
"github.com/ChimeraCoder/anaconda"
log "github.com/sirupsen/logrus"
"github.com/Luzifer/rconfig"
)
var (
cfg = struct {
AppToken string `flag:"app-token" description:"Token for the application" validate:"nonzero"`
AppSecret string `flag:"app-secret" description:"Secret for the provided token" validate:"nonzero"`
Database string `flag:"database" default:"tweets.db" description:"Database storage location"`
Frontend string `flag:"frontend" default:"frontend" description:"Directory containing frontend files"`
Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"`
ListOwner string `flag:"list-owner" description:"Owner of the specified list" validate:"nonzero"`
ListSlug string `flag:"list-slug" description:"Slug of the list" validate:"nonzero"`
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
UserToken string `flag:"user-token" description:"Token for the user" validate:"nonzero"`
UserSecret string `flag:"user-secret" description:"Secret for the provided token" validate:"nonzero"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{}
tweetStore *store
twitter *anaconda.TwitterApi
version = "dev"
)
func init() {
rconfig.AutoEnv(true)
if err := rconfig.ParseAndValidate(&cfg); err != nil {
log.Fatalf("Unable to parse commandline options: %s", err)
}
if cfg.VersionAndExit {
fmt.Printf("mediatimeline %s\n", version)
os.Exit(0)
}
if l, err := log.ParseLevel(cfg.LogLevel); err != nil {
log.WithError(err).Fatal("Unable to parse log level")
} else {
log.SetLevel(l)
}
}
func main() {
var err error
if tweetStore, err = newStore(cfg.Database); err != nil {
log.WithError(err).Fatal("Unable to create store")
}
twitter = anaconda.NewTwitterApiWithCredentials(
cfg.UserToken, cfg.UserSecret,
cfg.AppToken, cfg.AppSecret,
)
go loadAndStoreTweets()
http.Handle("/", http.FileServer(http.Dir(cfg.Frontend)))
http.ListenAndServe(cfg.Listen, nil)
}
func loadAndStoreTweets() {
for t := time.NewTicker(time.Minute); true; <-t.C {
params := url.Values{
"count": []string{"100"},
}
lastTweet := tweetStore.GetLastTweetID()
if lastTweet > 0 {
params.Set("since_id", strconv.FormatUint(lastTweet, 10))
}
anacondaTweets, err := twitter.GetListTweetsBySlug(cfg.ListSlug, cfg.ListOwner, false, params)
if err != nil {
log.WithError(err).Error("Unable to fetch tweets")
continue
}
tweets, err := convertTweetList(anacondaTweets, true)
if err != nil {
log.WithError(err).Error("Unable to parse tweets")
continue
}
if err := tweetStore.StoreTweets(tweets); err != nil {
log.WithError(err).Error("Unable to store tweets")
continue
}
}
}

149
storage.go Normal file
View File

@ -0,0 +1,149 @@
package main
import (
"encoding/gob"
"os"
"sort"
"sync"
"github.com/pkg/errors"
)
const tweetPageSize = 50
type store struct {
s []tweet
location string
lock sync.RWMutex
}
func init() {
gob.Register(tweet{})
}
func newStore(location string) (*store, error) {
s := &store{
s: []tweet{},
location: location,
}
return s, s.load()
}
func (s *store) StoreTweets(tweets []tweet) error {
s.lock.Lock()
defer s.lock.Unlock()
tmp := s.s
for _, t := range tweets {
var stored bool
for i := 0; i < len(tmp); i++ {
if tmp[i].ID == t.ID {
tmp[i] = t
stored = true
break
}
}
if !stored {
tmp = append(tmp, t)
}
}
sort.Slice(tmp, func(j, i int) bool { return tmp[i].ID < tmp[j].ID })
s.s = tmp
return s.save()
}
func (s *store) GetLastTweetID() uint64 {
s.lock.RLock()
defer s.lock.RUnlock()
if len(s.s) == 0 {
return 0
}
return s.s[0].ID
}
func (s *store) GetTweetPage(page int) ([]tweet, error) {
s.lock.RLock()
defer s.lock.RUnlock()
var (
start = (page - 1) * tweetPageSize
num = tweetPageSize
)
if start > len(s.s) {
return []tweet{}, nil
}
if start+num >= len(s.s) {
num = len(s.s) - start
}
return s.s[start:num], nil
}
func (s *store) GetTweetsSince(since uint64) ([]tweet, error) {
s.lock.RLock()
defer s.lock.RUnlock()
var i int
for i = 0; i < len(s.s); i++ {
if s.s[i].ID <= since {
break
}
}
return s.s[:i], nil
}
func (s *store) load() error {
s.lock.Lock()
defer s.lock.Unlock()
if _, err := os.Stat(s.location); err != nil {
if os.IsNotExist(err) {
// Leave a fresh store
return nil
}
return errors.Wrap(err, "Unable to stat storage file")
}
f, err := os.Open(s.location)
if err != nil {
return errors.Wrap(err, "Unable to open storage file")
}
defer f.Close()
tmp := []tweet{}
if err := gob.NewDecoder(f).Decode(&tmp); err != nil {
return errors.Wrap(err, "Unable to decode storage file")
}
s.s = tmp
return nil
}
func (s *store) save() error {
// No need to lock here, has write-lock from s.StoreTweets
f, err := os.Create(s.location)
if err != nil {
return errors.Wrap(err, "Unable to open store for writing")
}
defer f.Close()
return errors.Wrap(gob.NewEncoder(f).Encode(s.s), "Unable to encode store")
}

88
tweet.go Normal file
View File

@ -0,0 +1,88 @@
package main
import (
"strconv"
"time"
"github.com/ChimeraCoder/anaconda"
"github.com/pkg/errors"
)
func convertTweetList(in []anaconda.Tweet, filterForMedia bool) ([]tweet, error) {
out := []tweet{}
for _, t := range in {
tw, err := tweetFromAnaconda(t)
if err != nil {
return nil, errors.Wrap(err, "Unable to parse tweet")
}
if len(tw.Images) == 0 && filterForMedia {
continue
}
out = append(out, tw)
}
return out, nil
}
type tweet struct {
Favorited bool `json:"favorited"`
ID uint64 `json:"id,string"`
Images []media `json:"images"`
Posted time.Time `json:"posted"`
User user `json:"user"`
Text string `json:"text"`
}
func tweetFromAnaconda(in anaconda.Tweet) (tweet, error) {
medias := []media{}
for _, m := range in.ExtendedEntities.Media {
if m.Type != "photo" {
continue
}
medias = append(medias, mediaFromAnaconda(m))
}
created, _ := in.CreatedAtTime()
id, err := strconv.ParseUint(in.IdStr, 10, 64)
if err != nil {
return tweet{}, errors.Wrap(err, "Unable to parse tweet ID")
}
return tweet{
Favorited: in.Favorited,
ID: id,
Images: medias,
Posted: created,
User: userFromAnaconda(in.User),
Text: in.Text,
}, nil
}
type user struct {
ID int64 `json:"id"`
ScreenName string `json:"screen_name"`
Image string `json:"image"`
}
func userFromAnaconda(in anaconda.User) user {
return user{
ID: in.Id,
ScreenName: in.ScreenName,
Image: in.ProfileImageUrlHttps,
}
}
type media struct {
ID int64 `json:"id"`
Image string `json:"image"`
}
func mediaFromAnaconda(in anaconda.EntityMedia) media {
return media{
ID: in.Id,
Image: in.Media_url_https,
}
}