Compare commits

..

6 Commits

Author SHA1 Message Date
8144cabc76 Group the torrents by types in the torrent list
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
By the way let's show more infos about what's being downloaded.
2020-04-13 16:44:16 +02:00
57b70eb585 Fetch the show title while fetching an episode 2020-04-13 16:44:16 +02:00
8e8a1dc3e9 Movie the torrent progress bar in a new component 2020-04-13 16:44:16 +02:00
61bfd9eee6 Move torrent list components in separate files 2020-04-13 16:44:16 +02:00
ab3cf82fa9 Add a pretty name to the listed torrents 2020-04-13 16:44:16 +02:00
6a946d137d Get the video images from the models
Return the video details embedded in the torrents

This requires the eventers to have the app env
2020-04-13 16:44:16 +02:00
21 changed files with 257 additions and 111 deletions

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"sync"
"time" "time"
"git.quimbo.fr/odwrtw/canape/backend/auth" "git.quimbo.fr/odwrtw/canape/backend/auth"
@ -24,18 +25,16 @@ const (
// Eventers is a global map of all the available Eventers // Eventers is a global map of all the available Eventers
var Eventers map[string]*PolochonEventers var Eventers map[string]*PolochonEventers
var eventersSetup bool var once sync.Once
func initEventers(env *web.Env) { func initEventers(env *web.Env) {
if eventersSetup { once.Do(func() {
return env.Log.Infof("Initialising eventers")
}
Eventers = map[string]*PolochonEventers{ Eventers = map[string]*PolochonEventers{
torrentEventName: NewTorrentEventers(env), torrentEventName: NewTorrentEventers(env),
videoEventName: NewVideoEventers(env), videoEventName: NewVideoEventers(env),
} }
eventersSetup = true })
} }
// WsHandler handles the websockets messages // WsHandler handles the websockets messages

View File

@ -15,7 +15,10 @@ type TorrentEventer struct {
*BaseEventer *BaseEventer
done chan struct{} done chan struct{}
pClient *papi.Client pClient *papi.Client
torrents []papi.Torrent // previous keep the previous data
previous []*papi.Torrent
// data holds the computed data
data []*models.TorrentVideo
} }
var torrentEventName = "torrents" var torrentEventName = "torrents"
@ -45,7 +48,6 @@ func NewTorrentEventer(env *web.Env, polo *models.Polochon) (Eventer, error) {
}, },
pClient: client, pClient: client,
done: make(chan struct{}), done: make(chan struct{}),
torrents: []papi.Torrent{},
} }
return tn, nil return tn, nil
@ -60,7 +62,7 @@ func (t *TorrentEventer) Append(chanl *Channel) {
Type: torrentEventName, Type: torrentEventName,
Status: OK, Status: OK,
}, },
Data: t.torrents, Data: t.data,
} }
chanl.sendEvent(event) chanl.sendEvent(event)
@ -96,27 +98,23 @@ func (t *TorrentEventer) Launch() error {
// torrentsUpdate sends to the eventStream if torrents change // torrentsUpdate sends to the eventStream if torrents change
func (t *TorrentEventer) torrentsUpdate() error { func (t *TorrentEventer) torrentsUpdate() error {
// Get torrents
torrents, err := t.pClient.GetTorrents() torrents, err := t.pClient.GetTorrents()
if err != nil { if err != nil {
return err return err
} }
if reflect.DeepEqual(t.torrents, torrents) { if reflect.DeepEqual(t.previous, torrents) {
return nil return nil
} }
t.env.Log.Debugf("torrents have changed!") t.env.Log.Debugf("torrents have changed!")
notification := make([]*models.TorrentVideo, len(torrents)) data := models.NewTorrentVideos(t.env.Backend.Detailer, t.env.Database, t.env.Log, torrents)
for i := range torrents {
notification[i] = models.NewTorrentVideo(&torrents[i])
notification[i].Update(t.env.Backend.Detailer, t.env.Log)
}
t.NotifyAll(notification) t.NotifyAll(data)
t.torrents = torrents t.previous = torrents
t.data = data
return nil return nil
} }

View File

@ -51,9 +51,9 @@ func run() error {
} }
backend := &models.Backend{ backend := &models.Backend{
Database: db, Database: db,
PublicDir: cf.PublicDir,
ImgURLPrefix: cf.ImgURLPrefix,
} }
models.SetPublicDir(cf.PublicDir)
models.SetImgURLPrefix(cf.ImgURLPrefix)
// Generate auth params // Generate auth params
authParams := auth.Params{ authParams := auth.Params{

View File

@ -8,8 +8,6 @@ import (
// Backend represents the data backend // Backend represents the data backend
type Backend struct { type Backend struct {
Database *sqlx.DB Database *sqlx.DB
PublicDir string
ImgURLPrefix string
configured bool configured bool
} }

View File

@ -2,9 +2,6 @@ package models
import ( import (
"errors" "errors"
"fmt"
"os"
"path/filepath"
polochon "github.com/odwrtw/polochon/lib" polochon "github.com/odwrtw/polochon/lib"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -31,15 +28,6 @@ func (b *Backend) GetMovieDetails(pMovie *polochon.Movie, log *logrus.Entry) err
return err return err
} }
// Add the movie images
imgURL := fmt.Sprintf("movies/%s.jpg", pMovie.ImdbID)
imgFile := filepath.Join(b.PublicDir, "img", imgURL)
posterURL := ""
if _, err := os.Stat(imgFile); !os.IsNotExist(err) {
posterURL = b.ImgURLPrefix + imgURL
}
pMovie.Thumb = posterURL
log.Debugf("got movie %s from backend", pMovie.ImdbID) log.Debugf("got movie %s from backend", pMovie.ImdbID)
return nil return nil
@ -59,6 +47,7 @@ func (b *Backend) GetShowDetails(pShow *polochon.Show, log *logrus.Entry) error
log.Warnf("error while getting episodes: %s", err) log.Warnf("error while getting episodes: %s", err)
return err return err
} }
return nil return nil
} }

View File

@ -2,6 +2,7 @@ package models
import ( import (
"database/sql" "database/sql"
"fmt"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@ -86,6 +87,12 @@ func FillEpisodeFromDB(eDB *episodeDB, pEpisode *polochon.ShowEpisode) {
pEpisode.Thumb = eDB.Thumb pEpisode.Thumb = eDB.Thumb
pEpisode.Runtime = eDB.Runtime pEpisode.Runtime = eDB.Runtime
pEpisode.Aired = eDB.Aired pEpisode.Aired = eDB.Aired
pEpisode.Thumb = imageURL(fmt.Sprintf(
"shows/%s/%d-%d.jpg",
eDB.ShowImdbID,
eDB.Season,
eDB.Episode,
))
} }
// GetEpisode gets an episode and fills the polochon episode // GetEpisode gets an episode and fills the polochon episode

30
backend/models/global.go Normal file
View File

@ -0,0 +1,30 @@
package models
import (
"os"
"path/filepath"
)
// Public gobal variables
var (
PublicDir string
ImgURLPrefix string
)
// SetPublicDir sets the public dir
func SetPublicDir(s string) {
PublicDir = s
}
// SetImgURLPrefix sets the img url prefix
func SetImgURLPrefix(s string) {
ImgURLPrefix = s
}
func imageURL(suffix string) string {
imgFile := filepath.Join(PublicDir, "img", suffix)
if _, err := os.Stat(imgFile); !os.IsNotExist(err) {
return ImgURLPrefix + suffix
}
return ""
}

View File

@ -89,6 +89,7 @@ func FillMovieFromDB(mDB *movieDB, pMovie *polochon.Movie) {
pMovie.Genres = mDB.Genres pMovie.Genres = mDB.Genres
pMovie.SortTitle = mDB.SortTitle pMovie.SortTitle = mDB.SortTitle
pMovie.Tagline = mDB.Tagline pMovie.Tagline = mDB.Tagline
pMovie.Thumb = imageURL("movies/" + mDB.ImdbID + ".jpg")
} }
// updateFromMovie will update the movieDB from a Movie // updateFromMovie will update the movieDB from a Movie

View File

@ -42,7 +42,7 @@ type showDB struct {
// UpsertShow a show in the database // UpsertShow a show in the database
func UpsertShow(db *sqlx.DB, s *polochon.Show) error { func UpsertShow(db *sqlx.DB, s *polochon.Show) error {
sDB := NewShowFromPolochon(s) sDB := newShowFromPolochon(s)
// Upsert the show // Upsert the show
r, err := db.NamedQuery(upsertShowQuery, sDB) r, err := db.NamedQuery(upsertShowQuery, sDB)
if err != nil { if err != nil {
@ -60,8 +60,8 @@ func UpsertShow(db *sqlx.DB, s *polochon.Show) error {
return nil return nil
} }
// NewShowFromPolochon returns an showDB from a polochon Show // newShowFromPolochon returns an showDB from a polochon Show
func NewShowFromPolochon(s *polochon.Show) *showDB { func newShowFromPolochon(s *polochon.Show) *showDB {
sDB := showDB{ sDB := showDB{
ImdbID: s.ImdbID, ImdbID: s.ImdbID,
TvdbID: s.TvdbID, TvdbID: s.TvdbID,
@ -103,4 +103,7 @@ func FillShowFromDB(sDB *showDB, pShow *polochon.Show) {
pShow.TvdbID = sDB.TvdbID pShow.TvdbID = sDB.TvdbID
pShow.Year = sDB.Year pShow.Year = sDB.Year
pShow.FirstAired = &sDB.FirstAired pShow.FirstAired = &sDB.FirstAired
pShow.Banner = imageURL("shows/" + sDB.ImdbID + "/banner.jpg")
pShow.Fanart = imageURL("shows/" + sDB.ImdbID + "/fanart.jpg")
pShow.Poster = imageURL("shows/" + sDB.ImdbID + "/poster.jpg")
} }

View File

@ -1,6 +1,7 @@
package models package models
import ( import (
"github.com/jmoiron/sqlx"
"github.com/odwrtw/papi" "github.com/odwrtw/papi"
polochon "github.com/odwrtw/polochon/lib" polochon "github.com/odwrtw/polochon/lib"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -9,6 +10,7 @@ import (
// TorrentVideo reprensents a torrent embeding the video inforamtions // TorrentVideo reprensents a torrent embeding the video inforamtions
type TorrentVideo struct { type TorrentVideo struct {
*papi.Torrent *papi.Torrent
Img string `json:"img"`
Video polochon.Video `json:"video,omitempty"` Video polochon.Video `json:"video,omitempty"`
} }
@ -29,7 +31,7 @@ func NewTorrentVideo(t *papi.Torrent) *TorrentVideo {
} }
// Update updates the Torrent video with the database details // Update updates the Torrent video with the database details
func (t *TorrentVideo) Update(detailer polochon.Detailer, log *logrus.Entry) { func (t *TorrentVideo) Update(detailer polochon.Detailer, db *sqlx.DB, log *logrus.Entry) {
if t.Video == nil { if t.Video == nil {
return return
} }
@ -39,4 +41,28 @@ func (t *TorrentVideo) Update(detailer polochon.Detailer, log *logrus.Entry) {
if err != nil { if err != nil {
log.WithField("function", "TorrentVideo.Update").Errorf(err.Error()) log.WithField("function", "TorrentVideo.Update").Errorf(err.Error())
} }
switch v := t.Video.(type) {
case *polochon.ShowEpisode:
if v.Show != nil {
if err := GetShow(db, v.Show); err != nil {
return
}
t.Img = v.Show.Poster
v.Show = nil
}
case *polochon.Movie:
t.Img = v.Thumb
}
}
// NewTorrentVideos returns a new slice of TorrentVideo from papi torrents
func NewTorrentVideos(detailer polochon.Detailer, db *sqlx.DB, log *logrus.Entry, torrents []*papi.Torrent) []*TorrentVideo {
tv := make([]*TorrentVideo, len(torrents))
for i := range torrents {
tv[i] = NewTorrentVideo(torrents[i])
tv[i].Update(detailer, db, log)
}
return tv
} }

View File

@ -82,16 +82,12 @@ func (e *Episode) MarshalJSON() ([]byte, error) {
AudioCodec: audioCodec, AudioCodec: audioCodec,
VideoCodec: videoCodec, VideoCodec: videoCodec,
Container: container, Container: container,
Thumb: e.getThumbURL(), Thumb: e.Thumb,
} }
return json.Marshal(episodeToMarshal) return json.Marshal(episodeToMarshal)
} }
func (e *Episode) getThumbURL() string {
return e.show.GetImageURL(fmt.Sprintf("%d-%d", e.Season, e.Episode))
}
// NewEpisode returns an Episode // NewEpisode returns an Episode
func NewEpisode(show *Show, season, episode int) *Episode { func NewEpisode(show *Show, season, episode int) *Episode {
return &Episode{ return &Episode{

View File

@ -38,9 +38,9 @@ func (s *Show) MarshalJSON() ([]byte, error) {
PosterURL string `json:"poster_url"` PosterURL string `json:"poster_url"`
}{ }{
alias: (*alias)(s), alias: (*alias)(s),
BannerURL: s.GetImageURL("banner"), BannerURL: s.Banner,
FanartURL: s.GetImageURL("fanart"), FanartURL: s.Fanart,
PosterURL: s.GetImageURL("poster"), PosterURL: s.Poster,
} }
// Create Episode obj from polochon.Episodes and add them to the object to // Create Episode obj from polochon.Episodes and add them to the object to

View File

@ -52,13 +52,7 @@ func ListHandler(env *web.Env, w http.ResponseWriter, r *http.Request) error {
return env.RenderError(w, err) return env.RenderError(w, err)
} }
torrents := make([]*models.TorrentVideo, len(list)) torrents := models.NewTorrentVideos(env.Backend.Detailer, env.Database, env.Log, list)
for i, t := range list {
tv := models.NewTorrentVideo(&t)
tv.Update(env.Backend.Detailer, env.Log)
torrents[i] = tv
}
return env.RenderJSON(w, torrents) return env.RenderJSON(w, torrents)
} }

View File

@ -170,7 +170,7 @@ const TorrentsDropdown = () => {
}; };
const TorrentsDropdownTitle = () => { const TorrentsDropdownTitle = () => {
const count = useSelector((state) => state.torrents.torrents.length); const count = useSelector((state) => state.torrents.count);
if (count === 0) { if (count === 0) {
return <span>Torrents</span>; return <span>Torrents</span>;
} }

View File

@ -0,0 +1,17 @@
import React from "react";
import PropTypes from "prop-types";
export const Poster = ({ url }) => {
if (!url || url === "") {
return null;
}
return (
<div className="col-md-2 d-none d-md-block">
<img className="card-img" src={url} />
</div>
);
};
Poster.propTypes = {
url: PropTypes.string,
};

View File

@ -20,9 +20,7 @@ export const Progress = ({ torrent }) => {
const totalSize = prettySize(torrent.status.total_size); const totalSize = prettySize(torrent.status.total_size);
const downloadRate = prettySize(torrent.status.download_rate) + "/s"; const downloadRate = prettySize(torrent.status.download_rate) + "/s";
return ( return (
<div className="card-body pb-0"> <div>
{started && (
<>
<div className="progress bg-light"> <div className="progress bg-light">
<div <div
className={progressBarClass} className={progressBarClass}
@ -33,12 +31,16 @@ export const Progress = ({ torrent }) => {
aria-valuemax="100" aria-valuemax="100"
></div> ></div>
</div> </div>
{started && (
<p> <p>
{downloadedSize} / {totalSize} - {percentDone} - {downloadRate} {downloadedSize} / {totalSize} - {percentDone} - {downloadRate}
</p> </p>
</>
)} )}
{!started && <p>Download not yet started</p>} {!started && (
<p>
<small>Not yet started</small>
</p>
)}
</div> </div>
); );
}; };

View File

@ -2,7 +2,7 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { prettyEpisodeName } from "../../../utils"; import { prettyEpisodeNameWithoutShow } from "../../../utils";
import { removeTorrent } from "../../../actions/torrents"; import { removeTorrent } from "../../../actions/torrents";
import { Progress } from "./progress"; import { Progress } from "./progress";
@ -10,32 +10,31 @@ import { Progress } from "./progress";
export const Torrent = ({ torrent }) => { export const Torrent = ({ torrent }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const torrentTitle = (torrent) => { const title = (torrent) => {
switch (torrent.type) { if (torrent.type !== "episode" || !torrent.video) {
case "movie": return "";
return torrent.video ? torrent.video.title : torrent.status.name; }
case "episode":
return torrent.video return (
? prettyEpisodeName( prettyEpisodeNameWithoutShow(
torrent.video.show_title,
torrent.video.season, torrent.video.season,
torrent.video.episode torrent.video.episode
) ) + " - "
: torrent.status.name; );
default:
return torrent.status.name;
}
}; };
return ( return (
<div className="card w-100 mb-3"> <div className="border-top">
<h5 className="card-header"> <div className="card-text d-flex flex-row">
<span className="text text-break">{torrentTitle(torrent)}</span> <div className="mt-1 flex-fill">
<span <span className="text text-break">{title(torrent)}</span>
className="fa fa-trash clickable pull-right" <small className="text-muted text-break">{torrent.status.name}</small>
</div>
<div
className="fa fa-trash btn text-right"
onClick={() => dispatch(removeTorrent(torrent.status.id))} onClick={() => dispatch(removeTorrent(torrent.status.id))}
></span> />
</h5> </div>
<Progress torrent={torrent} /> <Progress torrent={torrent} />
</div> </div>
); );

View File

@ -0,0 +1,49 @@
import React from "react";
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { Torrent } from "./torrent";
import { Poster } from "./poster";
export const TorrentGroup = ({ torrentKey }) => {
const torrents = useSelector((state) =>
state.torrents.torrents.get(torrentKey)
);
if (torrents.length === 0) {
return null;
}
const title = (torrent) => {
switch (torrent.type) {
case "movie":
return torrent.video.title;
case "episode":
return torrent.video.show_title;
default:
return "Files";
}
};
return (
<div className="w-100 mb-3 card">
<div className="row no-gutters">
<Poster url={torrents[0].img} />
<div className="col-sm">
<div className="card-body">
<h4 className="card-title">{title(torrents[0])}</h4>
{torrents.map((torrent, i) => (
<Torrent
key={torrent.video ? torrent.video.imdb_id : i}
torrent={torrent}
/>
))}
</div>
</div>
</div>
</div>
);
};
TorrentGroup.propTypes = {
torrentKey: PropTypes.string.isRequired,
};

View File

@ -1,12 +1,14 @@
import React from "react"; import React from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { Torrent } from "./torrent"; import { TorrentGroup } from "./torrentGroup";
export const Torrents = () => { export const Torrents = () => {
const torrents = useSelector((state) => state.torrents.torrents); const torrentsKeys = useSelector((state) =>
Array.from(state.torrents.torrents.keys())
);
if (torrents.length === 0) { if (torrentsKeys.length === 0) {
return ( return (
<div className="jumbotron"> <div className="jumbotron">
<h2>No torrents</h2> <h2>No torrents</h2>
@ -16,8 +18,8 @@ export const Torrents = () => {
return ( return (
<div className="d-flex flex-wrap"> <div className="d-flex flex-wrap">
{torrents.map((torrent, index) => ( {torrentsKeys.map((key) => (
<Torrent key={index} torrent={torrent} /> <TorrentGroup key={key} torrentKey={key} />
))} ))}
</div> </div>
); );

View File

@ -3,10 +3,42 @@ import { produce } from "immer";
const defaultState = { const defaultState = {
fetching: false, fetching: false,
searching: false, searching: false,
torrents: [], torrents: new Map(),
count: 0,
searchResults: [], searchResults: [],
}; };
// Group the torrents by imdb id
const formatTorrents = (input) => {
let torrents = new Map();
if (!input) {
return torrents;
}
input.forEach((t) => {
let key;
switch (t.type) {
case "movie":
key = t.video ? t.video.imdb_id : "unknown";
break;
case "episode":
key = t.video ? t.video.show_imdb_id : "unknown";
break;
default:
key = "unknown";
break;
}
if (!torrents.has(key)) {
torrents.set(key, []);
}
torrents.get(key).push(t);
});
return torrents;
};
export default (state = defaultState, action) => export default (state = defaultState, action) =>
produce(state, (draft) => { produce(state, (draft) => {
switch (action.type) { switch (action.type) {
@ -16,7 +48,8 @@ export default (state = defaultState, action) =>
case "TORRENTS_FETCH_FULFILLED": case "TORRENTS_FETCH_FULFILLED":
draft.fetching = false; draft.fetching = false;
draft.torrents = action.payload.response.data; draft.torrents = formatTorrents(action.payload.response.data);
draft.count = action.payload.response.data.length;
break; break;
case "TORRENTS_SEARCH_PENDING": case "TORRENTS_SEARCH_PENDING":

View File

@ -18,6 +18,9 @@ export const prettyDurationFromMinutes = (runtime) => {
const pad = (d) => (d < 10 ? "0" + d.toString() : d.toString()); const pad = (d) => (d < 10 ? "0" + d.toString() : d.toString());
export const prettyEpisodeNameWithoutShow = (season, episode) =>
`S${pad(season)}E${pad(episode)}`;
export const prettyEpisodeName = (showName, season, episode) => export const prettyEpisodeName = (showName, season, episode) =>
`${showName} S${pad(season)}E${pad(episode)}`; `${showName} S${pad(season)}E${pad(episode)}`;