diff --git a/backend/movies/handlers.go b/backend/movies/handlers.go index f7b8526..45e909c 100644 --- a/backend/movies/handlers.go +++ b/backend/movies/handlers.go @@ -49,6 +49,51 @@ func PolochonMoviesHandler(env *web.Env, w http.ResponseWriter, r *http.Request) return env.RenderJSON(w, movies) } +// GetMovieHandler will return a single movie +func GetMovieHandler(env *web.Env, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + id := vars["id"] + + user := auth.GetCurrentUser(r, env.Log) + + client, err := user.NewPapiClient(env.Database) + if err != nil { + return env.RenderError(w, err) + } + + movies, err := client.GetMovies() + if err != nil { + return env.RenderError(w, err) + } + + moviesWishlist, err := models.GetMovieWishlist(env.Database, user.ID) + if err != nil { + return env.RenderError(w, err) + } + + pMovie, _ := movies.Has(id) + movie := New( + id, + client, + pMovie, + moviesWishlist.IsMovieInWishlist(id), + ) + + detailers := []polochon.Detailer{env.Backend.Detailer} + err = movie.GetDetails(env, detailers) + if err != nil { + return env.RenderError(w, err) + } + + torrenters := []polochon.Torrenter{env.Backend.Torrenter} + err = movie.GetTorrents(env, torrenters) + if err != nil { + env.Log.Errorf("error while getting movie torrents : %s", err) + } + + return env.RenderJSON(w, movie) +} + // RefreshMovieHandler refreshes details for a movie func RefreshMovieHandler(env *web.Env, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) diff --git a/backend/routes.go b/backend/routes.go index ddc819d..1a948b6 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -41,6 +41,7 @@ func setupRoutes(env *web.Env) { env.Handle("/movies/explore/options", extmedias.MovieExplorerOptions).WithRole(models.UserRole).Methods("GET") env.Handle("/movies/search/{search}", movies.SearchMovie).WithRole(models.UserRole).Methods("GET") env.Handle("/movies/{id:tt[0-9]+}", movies.PolochonDeleteHandler).WithRole(models.UserRole).Methods("DELETE") + env.Handle("/movies/{id:tt[0-9]+}", movies.GetMovieHandler).WithRole(models.UserRole).Methods("GET") env.Handle("/movies/{id:tt[0-9]+}/refresh", movies.RefreshMovieHandler).WithRole(models.UserRole).Methods("POST") env.Handle("/movies/{id:tt[0-9]+}/subtitles/{lang}", movies.DownloadVVTSubtitle).WithRole(models.UserRole).Methods("GET") env.Handle("/movies/{id:tt[0-9]+}/subtitles/{lang}", movies.RefreshMovieSubtitlesHandler).WithRole(models.UserRole).Methods("POST") diff --git a/frontend/js/actions/movies.js b/frontend/js/actions/movies.js index 77340b7..a232756 100644 --- a/frontend/js/actions/movies.js +++ b/frontend/js/actions/movies.js @@ -48,6 +48,17 @@ export function getMovieDetails(imdbId) { ); } +export function fetchMovieDetails(imdbId) { + return request( + "MOVIE_FETCH_DETAILS", + configureAxios().get(`/movies/${imdbId}`), + null, + { + imdbId, + } + ); +} + export function deleteMovie(imdbId, lastFetchUrl) { return request("MOVIE_DELETE", configureAxios().delete(`/movies/${imdbId}`), [ fetchMovies(lastFetchUrl), diff --git a/frontend/js/app.js b/frontend/js/app.js index 7d0e0e1..c5e2a17 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -33,6 +33,7 @@ import { AdminPanel } from "./components/admins/panel"; import { Notifications } from "./components/notifications/notifications"; import { Alert } from "./components/alerts/alert"; import MovieList from "./components/movies/list"; +import { MovieDetails } from "./components/movies/details"; import { AppNavBar } from "./components/navbar"; import { WsHandler } from "./components/websocket"; import { ShowDetails } from "./components/shows/details"; @@ -66,6 +67,7 @@ const App = () => ( /> + { - const url = useSelector((state) => state.show.show.fanart_url); +export const Fanart = ({ url }) => { + if (url == "") { + return null; + } return (
{
); }; +Fanart.propTypes = { + url: PropTypes.string.isRequired, +}; +Fanart.defaultProps = { + url: "", +}; diff --git a/frontend/js/components/movies/details.js b/frontend/js/components/movies/details.js new file mode 100644 index 0000000..d80c561 --- /dev/null +++ b/frontend/js/components/movies/details.js @@ -0,0 +1,162 @@ +import React, { useEffect } from "react"; +import PropTypes from "prop-types"; +import { useSelector, useDispatch } from "react-redux"; + +import { + fetchMovieDetails, + getMovieDetails, + movieWishlistToggle, +} from "../../actions/movies"; +import { searchMovieSubtitle } from "../../actions/subtitles"; + +import Loader from "../loader/loader"; + +import { Fanart } from "../details/fanart"; +import { Plot } from "../details/plot"; +import { Rating } from "../details/rating"; +import { ReleaseDate } from "../details/releaseDate"; +import { Title } from "../details/title"; +import { PolochonMetadata } from "../details/polochon"; +import { TrackingLabel } from "../details/tracking"; +import { Genres } from "../details/genres"; +import { Runtime } from "../details/runtime"; + +import { DownloadAndStream } from "../buttons/download"; +import { ImdbBadge } from "../buttons/imdb"; +import { TorrentsButton } from "../buttons/torrents"; +import { SubtitlesButton } from "../buttons/subtitles"; +import { ShowMore } from "../buttons/showMore"; + +export const MovieDetails = ({ match }) => { + const dispatch = useDispatch(); + const loading = useSelector((state) => state.movie.loading); + const fanartUrl = useSelector((state) => state.movie.movie.fanart); + + useEffect(() => { + dispatch(fetchMovieDetails(match.params.imdbId)); + }, [dispatch, match]); + + if (loading) { + return ; + } + + return ( + + +
+ + ); +}; + +MovieDetails.propTypes = { + match: PropTypes.object.isRequired, +}; + +export const Header = () => { + const dispatch = useDispatch(); + const { + audioCodec, + container, + fetchingDetails, + fetchingSubtitles, + genres, + imdb_id: imdbId, + plot, + polochon_url: polochonUrl, + poster_url: posterUrl, + quality, + rating, + runtime, + size, + subtitles, + title, + torrents, + videoCodec, + votes, + wishlisted, + year, + release_group: releaseGroup, + } = useSelector((state) => state.movie.movie); + + const inLibrary = polochonUrl !== ""; + + if (!imdbId || imdbId === "") { + return null; + } + + return ( +
+
+
+ +
+ +
+
+

+ + dispatch(movieWishlistToggle(imdbId, wishlisted)) + } + /> + </p> + <p className="card-text"> + <ReleaseDate date={year} /> + </p> + <p className="card-text"> + <Runtime runtime={runtime} /> + </p> + <p className="card-text"> + <Genres genres={genres} /> + </p> + <p className="card-text"> + <Rating rating={rating} votes={votes} /> + </p> + <div className="card-text"> + <ImdbBadge imdbId={imdbId} /> + <DownloadAndStream + url={polochonUrl} + name={title} + subtitles={subtitles} + /> + </div> + <p className="card-text"> + <TrackingLabel inLibrary={inLibrary} wishlisted={wishlisted} /> + </p> + <p className="card-text"> + <PolochonMetadata + quality={quality} + releaseGroup={releaseGroup} + container={container} + audioCodec={audioCodec} + videoCodec={videoCodec} + size={size} + /> + </p> + <p className="card-text"> + <Plot plot={plot} /> + </p> + <div className="card-text"> + <ShowMore id={imdbId} inLibrary={inLibrary}> + <TorrentsButton + torrents={torrents} + searching={fetchingDetails} + search={() => dispatch(getMovieDetails(imdbId))} + url={`#/torrents/search/movies/${encodeURI(title)}`} + /> + <SubtitlesButton + inLibrary={inLibrary} + fetchingSubtitles={fetchingSubtitles} + subtitles={subtitles} + search={(lang) => dispatch(searchMovieSubtitle(imdbId, lang))} + /> + </ShowMore> + </div> + </div> + </div> + </div> + </div> + ); +}; diff --git a/frontend/js/components/movies/list.js b/frontend/js/components/movies/list.js index ad4c619..8e5068e 100644 --- a/frontend/js/components/movies/list.js +++ b/frontend/js/components/movies/list.js @@ -37,7 +37,7 @@ const fetchUrl = (match) => { } }; -const MovieList = ({ match }) => { +const MovieList = ({ match, history }) => { const dispatch = useDispatch(); useEffect(() => { @@ -63,6 +63,10 @@ const MovieList = ({ match }) => { [dispatch] ); + const movieDetails = (imdbId) => { + history.push("/movies/details/" + imdbId); + }; + return ( <div className="row"> <ListPosters @@ -73,8 +77,8 @@ const MovieList = ({ match }) => { exploreOptions={exploreOptions} selectedImdbId={selectedImdbId} onClick={selectFunc} - onDoubleClick={() => {}} - onKeyEnter={() => {}} + onDoubleClick={movieDetails} + onKeyEnter={movieDetails} params={match.params} loading={loading} /> @@ -105,6 +109,7 @@ MovieList.propTypes = { updateFilter: PropTypes.func, movieWishlistToggle: PropTypes.func, selectMovie: PropTypes.func, + history: PropTypes.object, match: PropTypes.object, }; diff --git a/frontend/js/components/shows/details.js b/frontend/js/components/shows/details.js index a4d59c6..e8c7379 100644 --- a/frontend/js/components/shows/details.js +++ b/frontend/js/components/shows/details.js @@ -4,7 +4,7 @@ import { useSelector, useDispatch } from "react-redux"; import Loader from "../loader/loader"; -import { Fanart } from "./details/fanart"; +import { Fanart } from "../details/fanart"; import { Header } from "./details/header"; import { SeasonsList } from "./details/seasons"; @@ -13,6 +13,7 @@ import { fetchShowDetails } from "../../actions/shows"; export const ShowDetails = ({ match }) => { const dispatch = useDispatch(); const loading = useSelector((state) => state.show.loading); + const fanartUrl = useSelector((state) => state.show.show.fanart_url); useEffect(() => { dispatch(fetchShowDetails(match.params.imdbId)); @@ -24,7 +25,7 @@ export const ShowDetails = ({ match }) => { return ( <React.Fragment> - <Fanart /> + <Fanart url={fanartUrl} /> <div className="row no-gutters"> <Header /> <SeasonsList /> diff --git a/frontend/js/components/torrents/list/torrentGroup.js b/frontend/js/components/torrents/list/torrentGroup.js index 8cfe62f..d74b385 100644 --- a/frontend/js/components/torrents/list/torrentGroup.js +++ b/frontend/js/components/torrents/list/torrentGroup.js @@ -18,7 +18,14 @@ export const TorrentGroup = ({ torrentKey }) => { const title = (torrent) => { switch (torrent.type) { case "movie": - return <span>{torrent.video.title}</span>; + return ( + <Link + className="link-unstyled" + to={`/movies/details/${torrent.video.imdb_id}`} + > + {torrent.video.title} + </Link> + ); case "episode": return ( <Link diff --git a/frontend/js/reducers/index.js b/frontend/js/reducers/index.js index 997ab0a..8e728b6 100644 --- a/frontend/js/reducers/index.js +++ b/frontend/js/reducers/index.js @@ -5,6 +5,7 @@ import { enableMapSet } from "immer"; enableMapSet(); import movies from "./movies"; +import movie from "./movie"; import shows from "./shows"; import show from "./show"; import user from "./users"; @@ -16,6 +17,7 @@ import notifications from "./notifications"; export default combineReducers({ movies, + movie, shows, show, user, diff --git a/frontend/js/reducers/movie.js b/frontend/js/reducers/movie.js new file mode 100644 index 0000000..a005410 --- /dev/null +++ b/frontend/js/reducers/movie.js @@ -0,0 +1,77 @@ +import { produce } from "immer"; + +import { formatSubtitle, formatMovie } from "./utils"; + +const defaultState = { + loading: false, + movie: {}, +}; + +export default (state = defaultState, action) => + produce(state, (draft) => { + switch (action.type) { + case "MOVIE_FETCH_DETAILS_PENDING": + draft.loading = true; + break; + + case "MOVIE_FETCH_DETAILS_FULFILLED": { + draft.movie = formatMovie(action.payload.response.data); + draft.loading = false; + break; + } + + case "MOVIE_GET_DETAILS_PENDING": { + let imdbId = action.payload.main.imdbId; + if (draft.movie.imdb_id !== imdbId) { + break; + } + + draft.movie.fetchingDetails = true; + break; + } + + case "MOVIE_GET_DETAILS_FULFILLED": { + let imdbId = action.payload.main.imdbId; + if (draft.movie.imdb_id !== imdbId) { + break; + } + + draft.movie = formatMovie(action.payload.response.data); + break; + } + + case "MOVIE_SUBTITLES_UPDATE_PENDING": { + let imdbId = action.payload.main.imdbId; + if (draft.movie.imdb_id !== imdbId) { + break; + } + + let lang = action.payload.main.lang; + draft.movie.fetchingSubtitles.push(lang); + if (draft.movie.subtitles.get(lang)) { + draft.movie.subtitles.get(lang).searching = true; + } + break; + } + + case "MOVIE_SUBTITLES_UPDATE_FULFILLED": { + let imdbId = action.payload.main.imdbId; + if (draft.movie.imdb_id !== imdbId) { + break; + } + + let lang = action.payload.main.lang; + let data = action.payload.response.data; + draft.movie.fetchingSubtitles = draft.movie.fetchingSubtitles.filter( + (l) => l != lang + ); + if (data) { + draft.movie.subtitles.set(lang, formatSubtitle(data)); + } + break; + } + + default: + return draft; + } + }); diff --git a/frontend/js/reducers/movies.js b/frontend/js/reducers/movies.js index ca0d811..e5442cb 100644 --- a/frontend/js/reducers/movies.js +++ b/frontend/js/reducers/movies.js @@ -1,7 +1,6 @@ import { produce } from "immer"; -import { formatTorrents } from "../utils"; -import { formatSubtitle, formatSubtitles } from "./utils"; +import { formatSubtitle, formatMovie } from "./utils"; const defaultState = { loading: false, @@ -11,14 +10,6 @@ const defaultState = { exploreOptions: {}, }; -const formatMovie = (movie) => { - movie.fetchingDetails = false; - movie.fetchingSubtitles = []; - movie.torrents = formatTorrents(movie); - movie.subtitles = formatSubtitles(movie.subtitles); - return movie; -}; - const formatMovies = (movies = []) => { let allMoviesInPolochon = true; movies.map((movie) => { @@ -66,10 +57,17 @@ export default (state = defaultState, action) => break; case "MOVIE_GET_DETAILS_PENDING": + if (!draft.movies.get(action.payload.main.imdbId)) { + break; + } draft.movies.get(action.payload.main.imdbId).fetchingDetails = true; break; case "MOVIE_GET_DETAILS_FULFILLED": + if (!draft.movies.get(action.payload.main.imdbId)) { + break; + } + draft.movies.set( action.payload.response.data.imdb_id, formatMovie(action.payload.response.data) @@ -92,6 +90,10 @@ export default (state = defaultState, action) => case "MOVIE_SUBTITLES_UPDATE_PENDING": { let imdbId = action.payload.main.imdbId; let lang = action.payload.main.lang; + if (!draft.movies.get(imdbId)) { + break; + } + draft.movies.get(imdbId).fetchingSubtitles.push(lang); if (draft.movies.get(imdbId).subtitles.get(lang)) { draft.movies.get(imdbId).subtitles.get(lang).searching = true; @@ -103,6 +105,10 @@ export default (state = defaultState, action) => let imdbId = action.payload.main.imdbId; let lang = action.payload.main.lang; let data = action.payload.response.data; + if (!draft.movies.get(imdbId)) { + break; + } + draft.movies.get(imdbId).fetchingSubtitles = draft.movies .get(imdbId) .fetchingSubtitles.filter((l) => l != lang); diff --git a/frontend/js/reducers/show.js b/frontend/js/reducers/show.js index 80b2e95..f12a214 100644 --- a/frontend/js/reducers/show.js +++ b/frontend/js/reducers/show.js @@ -1,7 +1,6 @@ import { produce } from "immer"; -import { formatTorrents } from "../utils"; -import { formatSubtitle, formatSubtitles } from "./utils"; +import { formatTorrents, formatSubtitle, formatSubtitles } from "./utils"; const defaultState = { loading: false, diff --git a/frontend/js/reducers/utils.js b/frontend/js/reducers/utils.js index a395628..41fd61c 100644 --- a/frontend/js/reducers/utils.js +++ b/frontend/js/reducers/utils.js @@ -20,3 +20,32 @@ export const formatSubtitle = (subtitle) => { subtitle.searching = false; return subtitle; }; + +export const formatTorrents = (input) => { + if (!input.torrents || input.torrents.length == 0) { + return undefined; + } + + let torrentMap = new Map(); + input.torrents.forEach((torrent) => { + if (!torrent.result || !torrent.result.source) { + return; + } + + if (!torrentMap.has(torrent.result.source)) { + torrentMap.set(torrent.result.source, new Map()); + } + + torrentMap.get(torrent.result.source).set(torrent.quality, torrent); + }); + + return torrentMap; +}; + +export const formatMovie = (movie) => { + movie.fetchingDetails = false; + movie.fetchingSubtitles = []; + movie.torrents = formatTorrents(movie); + movie.subtitles = formatSubtitles(movie.subtitles); + return movie; +}; diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 9e06768..4a0e132 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -35,26 +35,5 @@ export const prettySize = (fileSizeInBytes) => { return Math.max(fileSizeInBytes, 0.1).toFixed(1) + byteUnits[i]; }; -export const formatTorrents = (input) => { - if (!input.torrents || input.torrents.length == 0) { - return undefined; - } - - let torrentMap = new Map(); - input.torrents.forEach((torrent) => { - if (!torrent.result || !torrent.result.source) { - return; - } - - if (!torrentMap.has(torrent.result.source)) { - torrentMap.set(torrent.result.source, new Map()); - } - - torrentMap.get(torrent.result.source).set(torrent.quality, torrent); - }); - - return torrentMap; -}; - export const upperCaseFirst = (string) => string.charAt(0).toUpperCase() + string.slice(1);