Compare commits

...

77 Commits

Author SHA1 Message Date
b21d2a29cc Merge branch 'addTagPopover' into 'master'
build custom popup for addtag in player

See merge request lukas/openmediacenter!16
2020-10-09 14:00:51 +00:00
76f879a0f2 add drag and drop support for addtagpopup
esc closes the popup
theme style is used
2020-10-09 14:00:51 +00:00
6076512dd0 fix bug of not cleaning up gravity correctly 2020-10-06 01:26:26 +02:00
d799cdd610 remove symlinks before update
set timeout for videoextraction
only insert db entries if not already available
2020-10-04 01:00:52 +02:00
3021126e86 Merge branch 'dpkg-packaging' into 'master'
Dpkg packaging

See merge request lukas/openmediacenter!14
2020-10-03 20:28:19 +00:00
2bfe3c28b2 add postinst doc
fix php 7.3 compatibility issues of typed properties
2020-10-03 20:28:19 +00:00
f1c49ea049 Merge branch 'removeVideo' into 'master'
add a delete button to delete a video

See merge request lukas/openmediacenter!17
2020-10-03 07:06:27 +00:00
13a980f161 add a delete button to delete a video 2020-10-03 07:06:27 +00:00
c2991bcd50 fix bug of non existing insert query 2020-09-27 20:22:14 +02:00
653146213e Merge branch 'tagprediction' into 'master'
quickadd Tags

See merge request lukas/openmediacenter!15
2020-09-26 18:43:30 +00:00
8f88aa3c02 only load non assigned tags
fix custom onClick events
2020-09-26 18:43:30 +00:00
b36327b332 Merge branch 'posteradding' into 'master'
fixing wrong thumbnail pic on homepage and cleanup grabbing and downloading of tmdb poster

See merge request lukas/openmediacenter!13
2020-09-23 18:43:51 +00:00
444ef3f074 cleanup Videoparser and add support for release years in filenames 2020-09-23 18:43:50 +00:00
b3555efa57 Merge branch 'previewtilesize' into 'master'
fix videopreview tile sizing

See merge request lukas/openmediacenter!12
2020-08-30 22:01:54 +00:00
e5ef1f94a4 non selectable text
tags side by side
load 16 videopreviews instead of 12 for huge monitor sizes
2020-08-30 22:01:54 +00:00
f0902d29b7 Merge branch 'documentation' into 'master'
Documentation and minor reformattings

Closes #27 and #35

See merge request lukas/openmediacenter!11
2020-08-12 17:50:26 +00:00
1de36afb69 new folder structure for php scripts
renamed api nodes
php braces on same line
2020-08-12 17:50:25 +00:00
13336cbf1c Merge branch 'csstheming' into 'master'
Csstheming

See merge request lukas/openmediacenter!8
2020-08-05 19:38:28 +00:00
e14d485a07 Merge branch 'master' into csstheming
# Conflicts:
#	src/elements/PageTitle/PageTitle.module.css
#	src/elements/Preview/Preview.module.css
#	src/elements/SideBar/SideBar.module.css
#	src/index.css
#	src/pages/Player/Player.js
#	src/pages/Player/Player.module.css
#	src/pages/SettingsPage/SettingsPage.module.css
2020-08-05 22:55:03 +02:00
186c24277c upload built js and php to server instead of source 2020-08-05 22:10:14 +02:00
f87c02c276 correct naming of Generalinfos and added tests 2020-08-05 22:00:55 +02:00
72a652f5f3 Merge branch 'gitlabcineeds' into 'master'
Gitlabcineeds

See merge request lukas/openmediacenter!10
2020-08-05 17:55:52 +00:00
f80554bfdd correct sort order of css properties
mocking of fetch api only once in setupTests
2020-08-05 17:55:51 +00:00
5970e4d19e Merge branch 'master' into 'csstheming'
# Conflicts:
#   src/App.js
2020-08-04 14:55:02 +00:00
987ae7fb8e correct theming of settings page
fix saving of themestyle to db
2020-08-04 18:53:11 +02:00
747f3005c8 easier getter function to get themestyle
better dark theme for SideBar.js
2020-08-03 23:31:43 +00:00
8bea726e98 theming of previews and sidebar 2020-08-03 18:38:22 +00:00
226f718348 delete index.css 2020-08-02 18:05:07 +00:00
ad1c4b221d fix failing tests (classname wrong) 2020-08-02 17:55:06 +00:00
748f0410de Merge branch 'phppostclass' into 'master'
new class based syntax for handling api requests in php

See merge request lukas/openmediacenter!9
2020-07-30 21:14:38 +00:00
08df6d64dd delete unnecessary ordering in sql statements 2020-07-31 01:05:58 +02:00
a2385e8e4c classify videoload and Tag requests 2020-07-31 01:03:51 +02:00
fd9a54209d new class based syntax for handling api requests in php 2020-07-29 23:42:36 +02:00
827fd6a1b2 reformat and store darkmode setting correct in db 2020-07-29 23:00:37 +02:00
8c4b1a836a new database entry for theme
new settings switcher
2020-07-28 18:17:17 +02:00
0ec4954ec5 correct theme style at settings page 2020-07-27 21:14:56 +02:00
aa741c5a90 only two style files 2020-07-26 18:17:29 +02:00
d3c3ee3044 tab title depends on mediacenter name 2020-07-26 14:27:56 +02:00
a3b63618b4 add seperate modules for dark and light theme 2020-07-24 22:47:21 +02:00
15ede7821e improved filextension check when reindexing and disable foreign key constraint check when deleting movies 2020-07-23 22:20:43 +02:00
1eddddcbac fixing lukas/openmediacenter#25
shuffle button always on same position now
2020-07-22 19:00:55 +02:00
e4b55a1c76 fixing lukas/openmediacenter#33
-- tmdb error always showing
2020-07-22 17:35:06 +02:00
643c4a872d sort only by add date and name
show db size of current db dynamic
2020-07-21 23:16:29 +02:00
ca499fed99 Merge branch 'SettingsPage' into 'master'
Settings page

See merge request lukas/openmediacenter!6
2020-07-17 21:14:56 +00:00
1d9cf31f13 add option to enable disable the tmdb support 2020-07-18 01:10:04 +02:00
8b89db6d5c fixing failing test 2020-07-16 19:13:54 +02:00
b9b9ac0bc2 new class Ssettings to get videopath from db
add test - failing
remove useless videopath and tvpath from app.js
2020-07-15 20:08:22 +02:00
5662a6e6e5 add initial fetch of generalsettings on appstart to get password support and mediacentername 2020-07-13 22:56:43 +02:00
537d869338 added several tests to generalsettings view 2020-07-13 00:44:16 +02:00
24dac2135c add test to test savesettings 2020-07-12 13:12:13 +02:00
7954af888d save settings correctly to db and parse response from insertion 2020-07-10 19:13:40 +02:00
133851fe0d added php code to get settings from database to react state
add onchange events to change state on field change
2020-07-10 01:18:23 +02:00
75ae0d7d8b use css modules
add a mediacenter-name field
use state for reindex-btn greyout
2020-07-08 19:33:23 +02:00
8292d13a70 Merge branch 'css-components' into 'master'
seperate css modules

See merge request lukas/openmediacenter!7
2020-07-07 20:20:03 +00:00
720c218a11 moved all css files to module files to seperate into namespaces and prevent name overlaps 2020-07-08 00:14:08 +02:00
24a29369b4 enable/disable start reindex btn 2020-07-07 19:21:14 +02:00
9ce867c6c8 added some todos and css to video reindex 2020-07-04 00:45:18 +02:00
3b1d85824f new fields in general settings
and test for password switcher
2020-07-03 00:20:11 +02:00
1739ffd32c disable docker in docker mode because of unpreviledged docker environment 2020-06-29 21:54:04 +02:00
0ca25ec4d1 update readme 2020-06-29 21:45:03 +02:00
08c2567551 add a css file for general SettingsPage.js
add a basic form for videopath
2020-06-29 21:34:43 +02:00
cbc191b7ec Update .gitlab-ci.yml 2020-06-29 19:20:14 +00:00
791f2327e1 added some tests and rounder buttons of settings items 2020-06-29 19:55:40 +02:00
fdfb36bcd2 new settingspage sidebar with general and moviesettings 2020-06-25 22:43:26 +02:00
afae31618c Merge branch 'tagsclickable' into 'master'
Tagsclickable

See merge request lukas/openmediacenter!5
2020-06-24 20:29:04 +00:00
a6f6b2d96f correct behaviour on category page on tag click 2020-06-24 22:47:46 +02:00
753ea99693 correct load of categorypage on tag click
improved failing tests
2020-06-24 21:47:22 +02:00
e640b36ce4 add test for homepage 2020-06-23 22:46:50 +02:00
4ac21506f3 edited tests for no fail and added new Tag test 2020-06-23 23:13:14 +02:00
89153b5da9 improved tag clicking events 2020-06-21 23:08:46 +02:00
ec4e54e991 improved gitlab ci job 2020-06-20 10:02:03 +02:00
18ce670836 Merge branch 'SeperateTitleComponent' into 'master'
Seperate title component

See merge request lukas/openmediacenter!4
2020-06-19 14:28:43 +00:00
8150f884ab moved App and index css to project root 2020-06-19 18:23:29 +02:00
82f8fb7350 added tests for PageTitle component
repaired failing homepage test
2020-06-19 18:21:42 +02:00
d034b2bc52 new PageTitle component to have each page the same title 2020-06-19 00:16:18 +02:00
63284da11e added test for loading animation in Previews 2020-06-18 22:08:26 +02:00
37e5a1a51e added a loading animation to the video previews 2020-06-18 21:53:48 +02:00
72 changed files with 2805 additions and 1107 deletions

26
.codeclimate.yml Normal file
View File

@ -0,0 +1,26 @@
version: "2"
checks:
argument-count:
config:
threshold: 5
complex-logic:
config:
threshold: 4
file-lines:
config:
threshold: 350
method-complexity:
config:
threshold: 5
method-count:
config:
threshold: 20
method-lines:
config:
threshold: 60
nested-control-flow:
config:
threshold: 4
return-statements:
config:
threshold: 4

View File

@ -1,34 +1,88 @@
image: node:latest image: node:latest
stages: stages:
- test - prepare
- coverage
- build - build
- test
- packaging
- deploy
cache: cache:
paths: paths:
- node_modules/ - node_modules/
include:
- template: Code-Quality.gitlab-ci.yml
variables:
SAST_DISABLE_DIND: "true"
prepare:
stage: prepare
script:
- npm install --progress=false
build:
stage: build
script:
- npm run build
artifacts:
expire_in: 7 days
paths:
- build/
needs: ["prepare"]
test: test:
stage: test stage: test
script: script:
- npm install
- CI=true npm run test - CI=true npm run test
artifacts: artifacts:
reports: reports:
junit: junit:
- ./junit.xml - ./junit.xml
needs: ["prepare"]
coverage: coverage:
stage: coverage stage: test
script: script:
- CI=true npm run coverage - CI=true npm run coverage
artifacts: artifacts:
reports: reports:
cobertura: cobertura:
- ./coverage/cobertura-coverage.xml - ./coverage/cobertura-coverage.xml
needs: ["prepare"]
build: package_debian:
stage: build stage: packaging
image: debian
script: script:
- npm run build - cd deb
- mkdir -p "./OpenMediaCenter/var/www/openmediacenter/videos/"
- mkdir -p "./OpenMediaCenter/tmp/"
- cp -r ../build/* ./OpenMediaCenter/var/www/openmediacenter/
- cp -r ../api ./OpenMediaCenter/var/www/openmediacenter/
- cp ../database.sql ./OpenMediaCenter/tmp/openmediacenter.sql
- chmod -R 0775 *
- dpkg-deb --build OpenMediaCenter
- mv OpenMediaCenter.deb OpenMediaCenter-0.1_amd64.deb
artifacts:
paths:
- deb/OpenMediaCenter-0.1_amd64.deb
needs: ["build"]
deploy_test1:
stage: deploy
image: luki42/alpineopenssh:latest
needs:
- test
- build
only:
- master
script:
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY")
- mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- scp -r build/* root@192.168.0.42:/var/www/html/
- scp -r api/ root@192.168.0.42:/var/www/html/

View File

@ -7,14 +7,19 @@ Feel free to contribute or open an issue here: https://gitlab.heili.eu/lukas/ope
## What is this? ## What is this?
Open Media Center is an open source solution for a mediacenter in your home network. Open Media Center is an open source solution for a mediacenter in your home network.
It's based on Reactjs and uses PHP for backend. Transform your webserver into a mediaserver.
It's based on Reactjs and PHP is used for backend.
It is optimized for general videos as well as for movies. It is optimized for general videos as well as for movies.
For grabbing movie data TMDB is used. For grabbing movie data TMDB is used.
For organizing videos tags are used. With the help of tags you can organize your video gravity.
Here you can see an example main page: Here you can see an example main page in light mode:
![Image of OpenMediaCenter](https://i.ibb.co/2PC3fmk/Screenshot-20200604-163448.png) ![Image of OpenMediaCenter](https://i.ibb.co/pnDjgNT/Screenshot-20200812-172945.png)
and in dark mode:
![](https://i.ibb.co/xzhdsbJ/Screenshot-20200812-172926.png)
## Installation ## Installation
First of all clone the repository. First of all clone the repository.
@ -31,9 +36,9 @@ You need also to setup a Database with the structure described in [SQL Style Ref
The login data to this database needs to be specified in the `api/Database.php` file. The login data to this database needs to be specified in the `api/Database.php` file.
## Usage ## Usage
To index Videos run on your server: `php extractvideopreviews.php`. Now you can access your MediaCenter via your servers global ip (:
Now you can access your MediaCenter via the servers global ip (: At the settings tab you can set the correct videopath on server and click reindex afterwards.
## Contact ## Contact
Any contribution is appreciated. Any contribution is appreciated.

View File

@ -1,31 +0,0 @@
<?php
require 'Database.php';
$conn = Database::getInstance()->getConnection();
if (isset($_POST['action'])) {
$action = $_POST['action'];
switch ($action) {
case "getAllTags":
$query = "SELECT tag_name,tag_id from tags";
$result = $conn->query($query);
$rows = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r);
}
echo json_encode($rows);
break;
case "createTag":
$query = "INSERT INTO tags (tag_name) VALUES ('" . $_POST['tagname'] . "')";
if ($conn->query($query) === TRUE) {
echo('{"result":"success"}');
} else {
echo('{"result":"' . $conn->error . '"}');
}
break;
}
}

View File

@ -1,265 +1,18 @@
<?php <?php
require 'Database.php'; require_once './src/Database.php';
require 'TMDBMovie.php'; require_once './src/TMDBMovie.php';
require_once './src/SSettings.php';
require_once './src/VideoParser.php';
writeLog("starting extraction!\n"); // allow UTF8 characters
setlocale(LC_ALL, 'en_US.UTF-8');
set_time_limit(3600);
$ffmpeg = 'ffmpeg'; //or: /usr/bin/ffmpeg , or /usr/local/bin/ffmpeg - depends on your installation (type which ffmpeg into a console to find the install path) $vp = new VideoParser();
$tmdb = new TMDBMovie(); $vp->writeLog("starting extraction!!\n");
// initial load of all available movie genres
$tmdbgenres = $tmdb->getAllGenres();
$conn = Database::getInstance()->getConnection(); $sett = new SSettings();
$scandir = "../videos/prn/"; // load video path from settings
$arr = scandir($scandir); $scandir = "../" . $sett->getVideoPath();
$vp->extractVideos($scandir);
$all = 0;
$added = 0;
$deleted = 0;
$failed = 0;
foreach ($arr as $elem) {
if ($elem != "." && $elem != "..") {
if (strpos($elem, '.mp4') !== false) {
$moviename = substr($elem, 0, -4);
$query = "SELECT * FROM videos WHERE movie_name = '" . mysqli_real_escape_string($conn, $moviename) . "'";
$result = $conn->query($query);
// insert if not available in db
if (!mysqli_fetch_assoc($result)) {
// try to fetch data from TMDB
$poster = -1;
$genres = -1;
if (!is_null($dta = $tmdb->searchMovie($moviename))) {
$pic = file_get_contents($tmdb->picturebase . $dta->poster_path);
$poster = shell_exec("ffmpeg -hide_banner -loglevel panic -ss 00:04:00 -i \"../videos/prn/$elem\" -vframes 1 -q:v 2 -f singlejpeg pipe:1 2>/dev/null");
// error handling for download error
if (!$pic) {
$pic = $poster;
$poster = -1;
echo "Failed to load Picture from TMDB! \n";
}
$genres = $dta->genre_ids;
} else {
echo "nothing found with TMDB!\n";
writeLog("nothing found with TMDB!\n");
$pic = shell_exec("ffmpeg -hide_banner -loglevel panic -ss 00:04:00 -i \"../videos/prn/$elem\" -vframes 1 -q:v 2 -f singlejpeg pipe:1 2>/dev/null");
}
//convert video to base64
$image_base64 = base64_encode($pic);
// add base64 fileinfo
$image = 'data:image/jpeg;base64,' . $image_base64;
// extract other video attributes
$video_attributes = _get_video_attributes($elem);
$duration = 0;
$size = 0;
$width = 0;
if ($video_attributes) {
$duration = $video_attributes->media->track[0]->Duration; // in seconds
$size = $video_attributes->media->track[0]->FileSize; // in Bytes
$width = $video_attributes->media->track[1]->Width; // width
}
if ($poster != -1) {
$poster_base64 = 'data:image/jpeg;base64,' . base64_encode($poster);
$query = "INSERT INTO videos(movie_name,movie_url,poster,thumbnail,quality,length)
VALUES ('" . mysqli_real_escape_string($conn, $moviename) . "',
'" . mysqli_real_escape_string($conn, 'videos/prn/' . $elem) . "',
'$poster_base64',
'$image',
'$width',
'$duration')";
} else {
$query = "INSERT INTO videos(movie_name,movie_url,thumbnail,quality,length)
VALUES ('" . mysqli_real_escape_string($conn, $moviename) . "',
'" . mysqli_real_escape_string($conn, 'videos/prn/' . $elem) . "',
'$image',
'$width',
'$duration')";
}
if ($conn->query($query) === TRUE) {
echo('successfully added ' . $elem . " to video gravity\n");
writeLog('successfully added ' . $elem . " to video gravity\n");
// add this entry to the default tags
$last_id = $conn->insert_id;
// full hd
if ($width >= 1900) {
$query = "INSERT INTO video_tags(video_id,tag_id) VALUES ($last_id,2)";
if ($conn->query($query) !== TRUE) {
echo "failed to add default tag here.\n";
writeLog("failed to add default tag here.\n");
}
}
// HD
if ($width >= 1250 && $width < 1900) {
$query = "INSERT INTO video_tags(video_id,tag_id) VALUES ($last_id,4)";
if ($conn->query($query) !== TRUE) {
echo "failed to add default tag here.\n";
writeLog("failed to add default tag here.\n");
}
}
// SD
if ($width < 1250 && $width > 0) {
$query = "INSERT INTO video_tags(video_id,tag_id) VALUES ($last_id,3)";
if ($conn->query($query) !== TRUE) {
echo "failed to add default tag here.\n";
writeLog("failed to add default tag here.\n");
}
}
// handle tmdb genres here!
if ($genres != -1) {
// transform genre ids in valid names
foreach ($genres as $genreid) {
// check if genre is already a tag in db if not insert it
$tagname = array_column($tmdbgenres, 'name', 'id')[$genreid];
$tagid = tagExists($tagname);
$query = "INSERT INTO video_tags(video_id,tag_id) VALUES ($last_id,$tagid)";
if ($conn->query($query) !== TRUE) {
echo "failed to add $genreid tag here.\n";
writeLog("failed to add $genreid tag here.\n");
}
}
}
$added++;
$all++;
} else {
echo('errored item: ' . $elem . "\n");
writeLog('errored item: ' . $elem . "\n");
echo('{"data":"' . $conn->error . '"}\n');
writeLog('{"data":"' . $conn->error . '"}\n');
$failed++;
}
} else {
$all++;
}
} else {
echo($elem . " does not contain a .mp4 extension! - skipping \n");
writeLog($elem . " does not contain a .mp4 extension! - skipping \n");
}
}
}
// auto cleanup db entries
$query = "SELECT COUNT(*) as count FROM videos";
$result = $conn->query($query);
$r = mysqli_fetch_assoc($result);
if ($all < $r['count']) {
echo "should be in gravity: " . $all . "\n";
writeLog("should be in gravity: " . $all . "\n");
echo "really in gravity: " . $r['count'] . "\n";
writeLog("really in gravity: " . $r['count'] . "\n");
echo "cleaning up gravity\n";
writeLog("cleaning up gravity\n");
$query = "SELECT movie_id,movie_url FROM videos";
$result = $conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
if (!file_exists("../" . $r['movie_url'])) {
$query = "DELETE FROM videos WHERE movie_id='" . $r['movie_id'] . "'";
if ($conn->query($query) === TRUE) {
echo('successfully deleted ' . $r['movie_url'] . " from video gravity\n");
writeLog('successfully deleted ' . $r['movie_url'] . " from video gravity\n");
$deleted++;
} else {
echo "failed to delete " . $r['movie_url'] . " from gravity: " . $conn->error . "\n";
writeLog("failed to delete " . $r['movie_url'] . " from gravity: " . $conn->error . "\n");
}
}
}
}
// calculate size of databse here
$size = -1;
$query = "SELECT table_schema AS \"Database\",
ROUND(SUM(data_length + index_length) / 1024 / 1024, 3) AS \"Size\"
FROM information_schema.TABLES
WHERE TABLE_SCHEMA='" . Database::getInstance()->getDatabaseName() . "'
GROUP BY table_schema;";
$result = $conn->query($query);
if ($result->num_rows == 1) {
$row = $result->fetch_assoc();
$size = $row["Size"];
}
echo "Total gravity: " . $all . "\n";
writeLog("Total gravity: " . $all . "\n");
echo "Size of Databse is: " . $size . "MB\n";
writeLog("Size of Databse is: " . $size . "MB\n");
echo "added in this run: " . $added . "\n";
writeLog("added in this run: " . $added . "\n");
echo "deleted in this run: " . $deleted . "\n";
writeLog("deleted in this run: " . $deleted . "\n");
echo "errored in this run: " . $failed . "\n";
writeLog("errored in this run: " . $failed . "\n");
writeLog("-42"); // terminating characters to stop webui requesting infos
/**
* get all videoinfos of a video file
*
* @param $video string name including extension
* @return object all infos as object
*/
function _get_video_attributes($video)
{
$command = "mediainfo \"../videos/prn/$video\" --Output=JSON";
$output = shell_exec($command);
return json_decode($output);
}
/**
* write a line to the output log file
*
* @param string $message message to write
*/
function writeLog(string $message)
{
file_put_contents("/tmp/output.log", $message, FILE_APPEND);
flush();
}
/**
* ckecks if tag exists -- if not creates it
* @param string $tagname the name of the tag
* @return integer the id of the inserted tag
*/
function tagExists(string $tagname)
{
global $conn;
$query = "SELECT * FROM tags WHERE tag_name='$tagname'";
$result = $conn->query($query);
if ($result->num_rows == 0) {
// tag does not exist --> create it
$query = "INSERT INTO tags (tag_name) VALUES ('$tagname')";
if ($conn->query($query) !== TRUE) {
echo "failed to create $tagname tag in database\n";
writeLog("failed to create $tagname tag in database\n");
}
return $conn->insert_id;
} else {
return $result->fetch_assoc()['tag_id'];
}
}

5
api/settings.php Normal file
View File

@ -0,0 +1,5 @@
<?php
require_once './src/handlers/Settings.php';
$sett = new Settings();
$sett->handleAction();

View File

@ -5,19 +5,17 @@
* *
* Class with all neccessary stuff for the Database connections. * Class with all neccessary stuff for the Database connections.
*/ */
class Database class Database {
{ private static $instance = null;
private static ?Database $instance = null; private $conn;
private mysqli $conn;
private string $servername = "192.168.0.30"; private $servername = "127.0.0.1";
private string $username = "root"; private $username = "mediacenteruser";
private string $password = "1qayxsw2"; private $password = "mediapassword";
private string $dbname = "mediacenter"; private $dbname = "mediacenter";
// The db connection is established in the private constructor. // The db connection is established in the private constructor.
private function __construct() private function __construct() {
{
// Create connection // Create connection
$this->conn = new mysqli($this->servername, $this->username, $this->password, $this->dbname); $this->conn = new mysqli($this->servername, $this->username, $this->password, $this->dbname);
@ -32,8 +30,7 @@ class Database
* *
* @return Database dbobject * @return Database dbobject
*/ */
public static function getInstance() public static function getInstance() {
{
if (!self::$instance) { if (!self::$instance) {
self::$instance = new Database(); self::$instance = new Database();
} }
@ -46,8 +43,7 @@ class Database
* *
* @return mysqli mysqli instance * @return mysqli mysqli instance
*/ */
public function getConnection() public function getConnection() {
{
return $this->conn; return $this->conn;
} }

45
api/src/SSettings.php Normal file
View File

@ -0,0 +1,45 @@
<?php
/**
* Class SSettings
* class handling all Settings used by php scripts
*/
class SSettings {
private $database;
/**
* SSettings constructor.
*/
public function __construct() {
$this->database = Database::getInstance();
}
/**
* get the videopath saved in db
* @return string videopath
*/
public function getVideoPath() {
$query = "SELECT video_path from settings";
$result = $this->database->getConnection()->query($query);
$r = mysqli_fetch_assoc($result);
return $r['video_path'];
}
/**
* check if TMDB is enableds
* @return bool isenabled?
*/
public function isTMDBGrabbingEnabled(): bool {
$query = "SELECT TMDB_grabbing from settings WHERE 1";
$result = $this->database->getConnection()->query($query);
if (!$result) {
return true; // if undefined in db --> default true
} else {
$r = mysqli_fetch_assoc($result);
return $r['TMDB_grabbing'] == '1';
}
}
}

View File

@ -1,10 +1,13 @@
<?php <?php
class TMDBMovie /**
{ * Class TMDBMovie
* class to handle all interactions with the tmdb api
*/
class TMDBMovie {
public $picturebase = "https://image.tmdb.org/t/p/w500";
private $apikey = "9fd90530b11447f5646f8e6fb4733fb4"; private $apikey = "9fd90530b11447f5646f8e6fb4733fb4";
private $baseurl = "https://api.themoviedb.org/3/"; private $baseurl = "https://api.themoviedb.org/3/";
public $picturebase = "https://image.tmdb.org/t/p/w500";
/** /**
* search for a specific movie * search for a specific movie
@ -12,13 +15,25 @@ class TMDBMovie
* @param string $moviename moviename * @param string $moviename moviename
* @return object movie object or null if not found * @return object movie object or null if not found
*/ */
public function searchMovie(string $moviename) public function searchMovie(string $moviename, string $year = null) {
{
$reply = json_decode(file_get_contents($this->baseurl . "search/movie?api_key=" . $this->apikey . "&query=" . urlencode($moviename))); $reply = json_decode(file_get_contents($this->baseurl . "search/movie?api_key=" . $this->apikey . "&query=" . urlencode($moviename)));
if ($reply->total_results == 0) { if ($reply->total_results == 0) {
// no results found // no results found
// todo maybe parse first pictures somehow
return null; return null;
} elseif ($year != null) {
// if year is defined check year
$regex = '/[0-9]{4}?/'; // matches year of string
for ($i = 0; $i < count($reply->results); $i++) {
$releasedate = $reply->results[$i]->release_date;
preg_match($regex, $releasedate, $matches);
if (count($matches) > 0) {
$curryear = $matches[0];
if ($curryear == $year)
return $reply->results[$i];
}
}
} else { } else {
return $reply->results[0]; return $reply->results[0];
} }
@ -29,8 +44,7 @@ class TMDBMovie
* *
* @return array of all available genres * @return array of all available genres
*/ */
public function getAllGenres() public function getAllGenres() {
{
$reply = json_decode(file_get_contents($this->baseurl . "genre/movie/list?api_key=" . $this->apikey)); $reply = json_decode(file_get_contents($this->baseurl . "genre/movie/list?api_key=" . $this->apikey));
return $reply->genres; return $reply->genres;
} }

332
api/src/VideoParser.php Normal file
View File

@ -0,0 +1,332 @@
<?php
require_once './src/Database.php';
require_once './src/TMDBMovie.php';
require_once './src/SSettings.php';
/**
* Class VideoParser
* handling the parsing of all videos of a folder and adding
* all videos with tags and thumbnails to the database
*/
class VideoParser {
/// ffmpeg installation binary
private string $ffmpeg = 'ffmpeg';
private TMDBMovie $tmdb;
/// initial load of all available movie genres
private array $tmdbgenres;
private string $videopath;
/// db connection instance
private mysqli $conn;
/// settings object instance
private SSettings $settings;
private bool $TMDBenabled;
/// videos added in this run
private int $added = 0;
/// all videos in this run
private int $all = 0;
/// failed videos in this run
private int $failed = 0;
/// deleted videos in this run
private int $deleted = 0;
/**
* VideoParser constructor.
*/
public function __construct() {
$this->tmdb = new TMDBMovie();
$this->tmdbgenres = $this->tmdb->getAllGenres();
$this->conn = Database::getInstance()->getConnection();
$this->settings = new SSettings();
$this->TMDBenabled = $this->settings->isTMDBGrabbingEnabled();
$this->videopath = $this->settings->getVideoPath();
}
/**
* searches a folder for mp4 videos and adds them to video gravity
* @param $foldername string the folder where to search (relative to the webserver root)
*/
public function extractVideos(string $foldername) {
echo("TMDB grabbing is " . ($this->TMDBenabled ? "" : "not") . " enabled \n");
$arr = scandir($foldername);
foreach ($arr as $elem) {
if ($elem == '.' || $elem == '..') continue;
$ext = pathinfo($elem, PATHINFO_EXTENSION);
if ($ext == "mp4") {
$this->processVideo($elem);
} else {
echo($elem . " does not contain a valid .mp4 extension! - skipping \n");
$this->writeLog($elem . " does not contain a valid .mp4 extension! - skipping \n");
}
}
// cleanup gravity
$this->cleanUpGravity();
// calculate size of databse here
$size = -1;
$query = "SELECT table_schema AS \"Database\",
ROUND(SUM(data_length + index_length) / 1024 / 1024, 3) AS \"Size\"
FROM information_schema.TABLES
WHERE TABLE_SCHEMA='" . Database::getInstance()->getDatabaseName() . "'
GROUP BY table_schema;";
$result = $this->conn->query($query);
if ($result->num_rows == 1) {
$row = $result->fetch_assoc();
$size = $row["Size"];
}
echo "Total gravity: " . $this->all . "\n";
$this->writeLog("Total gravity: " . $this->all . "\n");
echo "Size of Databse is: " . $size . "MB\n";
$this->writeLog("Size of Databse is: " . $size . "MB\n");
echo "added in this run: " . $this->added . "\n";
$this->writeLog("added in this run: " . $this->added . "\n");
echo "deleted in this run: " . $this->deleted . "\n";
$this->writeLog("deleted in this run: " . $this->deleted . "\n");
echo "errored in this run: " . $this->failed . "\n";
$this->writeLog("errored in this run: " . $this->failed . "\n");
$this->writeLog("-42"); // terminating characters to stop webui requesting infos
}
/**
* processes one mp4 video, extracts tags and adds it to the database
* @param $filename string filename of the video to process
*/
private function processVideo(string $filename) {
$moviename = substr($filename, 0, -4);
$regex = '/\([0-9]{4}?\)/'; //match year pattern
preg_match($regex, $moviename, $matches);
preg_replace($regex, '', $moviename);
$year = null;
if (count($matches) > 0) {
$year = substr($matches[count($matches) - 1], 1, 4);
$moviename = substr($moviename, 0, -6);
}
$query = "SELECT * FROM videos WHERE movie_name = '" . mysqli_real_escape_string($this->conn, $moviename) . "'";
$result = $this->conn->query($query);
// insert if not available in db
if (!mysqli_fetch_assoc($result)) {
$genres = -1;
$insert_query = "";
// extract other video attributes
$video_attributes = $this->_get_video_attributes($filename);
$duration = 0;
$size = 0;
$width = 0;
if ($video_attributes) {
$duration = $video_attributes->media->track[0]->Duration; // in seconds
$size = $video_attributes->media->track[0]->FileSize; // in Bytes
$width = $video_attributes->media->track[1]->Width; // width
}
// extract poster from video
$backpic = shell_exec("$this->ffmpeg -hide_banner -loglevel panic -ss 00:04:00 -i \"../$this->videopath$filename\" -vframes 1 -q:v 2 -f singlejpeg pipe:1 2>/dev/null");
// convert video to base64
$backpic64 = 'data:image/jpeg;base64,' . base64_encode($backpic);
// set default insert query without tmdb poster
$insert_query = "INSERT INTO videos(movie_name,movie_url,thumbnail,quality,length)
VALUES ('" . mysqli_real_escape_string($this->conn, $moviename) . "',
'" . mysqli_real_escape_string($this->conn, $this->videopath . $filename) . "',
'$backpic64',
'$width',
'$duration')";
// check if tmdb grabbing is enabled
if ($this->TMDBenabled) {
// search in tmdb api
if (!is_null($dta = $this->tmdb->searchMovie($moviename, $year))) {
$poster = file_get_contents($this->tmdb->picturebase . $dta->poster_path);
// error handling for download error
if ($poster) {
$poster_base64 = 'data:image/jpeg;base64,' . base64_encode($poster);
// override insert query if pic loaded correctly
$insert_query = "INSERT INTO videos(movie_name,movie_url,poster,thumbnail,quality,length)
VALUES ('" . mysqli_real_escape_string($this->conn, $moviename) . "',
'" . mysqli_real_escape_string($this->conn, $this->videopath . $filename) . "',
'$backpic64',
'$poster_base64',
'$width',
'$duration')";
}
// store genre ids for parsing later
$genres = $dta->genre_ids;
} else {
// nothing found with tmdb
echo "my moviename: " . $moviename;
$this->writeLog("nothing found with TMDB! -- $moviename\n");
}
}
if ($this->conn->query($insert_query) === TRUE) {
echo('successfully added ' . $filename . " to video gravity\n");
$this->writeLog('successfully added ' . $filename . " to video gravity\n");
// add this entry to the default tags
$last_id = $this->conn->insert_id;
$this->insertSizeTag($width, $last_id);
// handle tmdb genres here!
if ($genres != -1) {
// transform genre ids in valid names
foreach ($genres as $genreid) {
// check if genre is already a tag in db if not insert it
$tagname = array_column($this->tmdbgenres, 'name', 'id')[$genreid];
$tagid = $this->tagExists($tagname);
$query = "INSERT INTO video_tags(video_id,tag_id) VALUES ($last_id,$tagid)";
if ($this->conn->query($query) !== TRUE) {
echo "failed to add $genreid tag here.\n";
$this->writeLog("failed to add $genreid tag here.\n");
}
}
}
$this->added++;
$this->all++;
} else {
echo('errored item: ' . $filename . "\n");
$this->writeLog('errored item: ' . $filename . "\n");
echo('{"data":"' . $this->conn->error . '"}\n');
$this->writeLog('{"data":"' . $this->conn->error . '"}\n');
$this->failed++;
}
} else {
$this->all++;
}
}
/**
* insert the corresponding videosize tag to a specific videoid
* @param $width int video width
* @param $videoid int id of video
*/
private function insertSizeTag(int $width, int $videoid) {
// full hd
if ($width >= 1900) {
$query = "INSERT INTO video_tags(video_id,tag_id) VALUES ($videoid,2)";
if ($this->conn->query($query) !== TRUE) {
echo "failed to add default tag here.\n";
$this->writeLog("failed to add default tag here.\n");
}
}
// HD
if ($width >= 1250 && $width < 1900) {
$query = "INSERT INTO video_tags(video_id,tag_id) VALUES ($videoid,4)";
if ($this->conn->query($query) !== TRUE) {
echo "failed to add default tag here.\n";
$this->writeLog("failed to add default tag here.\n");
}
}
// SD
if ($width < 1250 && $width > 0) {
$query = "INSERT INTO video_tags(video_id,tag_id) VALUES ($videoid,3)";
if ($this->conn->query($query) !== TRUE) {
echo "failed to add default tag here.\n";
$this->writeLog("failed to add default tag here.\n");
}
}
}
/**
* get all videoinfos of a video file
*
* @param $video string name including extension
* @return object all infos as object
*/
private function _get_video_attributes(string $video) {
$command = "mediainfo \"../$this->videopath$video\" --Output=JSON";
$output = shell_exec($command);
return json_decode($output);
}
/**
* write a line to the output log file
*
* @param string $message message to write
*/
public function writeLog(string $message) {
file_put_contents("/tmp/output.log", $message, FILE_APPEND);
flush();
}
/**
* ckecks if tag exists -- if not creates it
* @param string $tagname the name of the tag
* @return integer the id of the inserted tag
*/
private function tagExists(string $tagname) {
$query = "SELECT * FROM tags WHERE tag_name='$tagname'";
$result = $this->conn->query($query);
if ($result->num_rows == 0) {
// tag does not exist --> create it
$query = "INSERT INTO tags (tag_name) VALUES ('$tagname')";
if ($this->conn->query($query) !== TRUE) {
echo "failed to create $tagname tag in database\n";
$this->writeLog("failed to create $tagname tag in database\n");
}
return $this->conn->insert_id;
} else {
return $result->fetch_assoc()['tag_id'];
}
}
/**
* cleans up the video gravity and removes non existent videos
*/
public function cleanUpGravity() {
// auto cleanup db entries
$query = "SELECT COUNT(*) as count FROM videos";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
if ($this->all < $r['count']) {
echo "\n\nshould be in gravity: " . $this->all . "\n";
$this->writeLog("should be in gravity: " . $this->all . "\n");
echo "really in gravity: " . $r['count'] . "\n";
$this->writeLog("really in gravity: " . $r['count'] . "\n");
echo "cleaning up gravity\n";
$this->writeLog("cleaning up gravity\n");
$query = "SELECT movie_id,movie_url FROM videos";
$result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
$movie_id = $r['movie_id'];
$url = $r['movie_url'];
// todo ORDER BY movie_url and erase duplicates also
if (!file_exists("../$url")) {
$query = "DELETE FROM videos WHERE movie_id=$movie_id";
if ($this->conn->query($query) === TRUE) {
echo("successfully deleted $url from video gravity\n");
$this->writeLog("successfully deleted $url from video gravity\n");
$this->deleted++;
} else {
echo "failed to delete $url from gravity: $this->conn->error \n";
$this->writeLog("failed to delete $url from gravity: $this->conn->error \n");
}
}
}
}
}
}

View File

@ -0,0 +1,50 @@
<?php
require_once 'src/Database.php';
abstract class RequestBase {
protected $conn;
private $actions = array();
/**
* adds a new action handler to the current api file
*
* @param $action string name of the action variable
* @param $callback Closure callback function to be called
*/
function addActionHandler($action, $callback) {
$this->actions[$action] = $callback;
}
/**
* runs the correct handler
* should be called once within the api request
*/
function handleAction() {
$this->conn = Database::getInstance()->getConnection();
if (isset($_POST['action'])) {
$this->initHandlers();
$action = $_POST['action'];
// call the right handler
$this->actions[$action]();
} else {
echo('{data:"error"}');
}
}
/**
* Send response message and exit script
* @param $message string the response message
*/
function commitMessage($message){
echo $message;
exit(0);
}
/**
* add the action handlers in this abstract method
*/
abstract function initHandlers();
}

View File

@ -0,0 +1,82 @@
<?php
require_once 'RequestBase.php';
/**
* Class Settings
* Backend for the Settings page
*/
class Settings extends RequestBase {
function initHandlers() {
$this->getFromDB();
$this->saveToDB();
}
/**
* handle settings stuff to load from db
*/
private function getFromDB(){
/**
* load currently set settings form db for init of settings page
*/
$this->addActionHandler("loadGeneralSettings", function () {
$query = "SELECT * from settings";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
// booleans need to be set manually
$r['passwordEnabled'] = $r['password'] != "-1";
$r['TMDB_grabbing'] = ($r['TMDB_grabbing'] != '0');
echo json_encode($r);
});
/**
* load initial data for home page load to check if pwd is set
*/
$this->addActionHandler("loadInitialData", function () {
$query = "SELECT * from settings";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
$r['passwordEnabled'] = $r['password'] != "-1";
unset($r['password']);
$r['DarkMode'] = (bool)($r['DarkMode'] != '0');
$this->commitMessage(json_encode($r));
});
}
/**
* handle setting stuff to save to db
*/
private function saveToDB(){
/**
* save changed settings to db
*/
$this->addActionHandler("saveGeneralSettings", function () {
$mediacentername = $_POST['mediacentername'];
$password = $_POST['password'];
$videopath = $_POST['videopath'];
$tvshowpath = $_POST['tvshowpath'];
$tmdbsupport = $_POST['tmdbsupport'];
$darkmodeenabled = $_POST['darkmodeenabled'];
$query = "UPDATE settings SET
video_path='$videopath',
episode_path='$tvshowpath',
password='$password',
mediacenter_name='$mediacentername',
TMDB_grabbing=$tmdbsupport,
DarkMode=$darkmodeenabled
WHERE 1";
if ($this->conn->query($query) === true) {
$this->commitMessage('{"success": true}');
} else {
$this->commitMessage('{"success": true}');
}
});
}
}

66
api/src/handlers/Tags.php Normal file
View File

@ -0,0 +1,66 @@
<?php
require_once 'RequestBase.php';
/**
* Class Tags
* backend to handle Tag database interactions
*/
class Tags extends RequestBase {
function initHandlers() {
$this->addToDB();
$this->getFromDB();
}
private function getFromDB(){
/**
* returns all available tags from database
*/
$this->addActionHandler("getAllTags", function () {
$query = "SELECT tag_name,tag_id from tags";
$result = $this->conn->query($query);
$rows = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r);
}
$this->commitMessage(json_encode($rows));
});
}
private function addToDB(){
/**
* creates a new tag
* query requirements:
* * tagname -- name of the new tag
*/
$this->addActionHandler("createTag", function () {
$query = "INSERT INTO tags (tag_name) VALUES ('" . $_POST['tagname'] . "')";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
/**
* adds a new tag to an existing video
*
* query requirements:
* * movieid -- the id of the video to add the tag to
* * id -- the tag id which tag to add
*/
$this->addActionHandler("addTag", function () {
$movieid = $_POST['movieid'];
$tagid = $_POST['id'];
$query = "INSERT INTO video_tags(tag_id, video_id) VALUES ('$tagid','$movieid')";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
}
}

View File

@ -1,14 +1,32 @@
<?php <?php
require 'Database.php'; require_once 'src/SSettings.php';
require_once 'RequestBase.php';
$conn = Database::getInstance()->getConnection(); /**
* Class Video
* backend for all interactions with videoloads and receiving of video infos
*/
class Video extends RequestBase {
private $videopath;
//$_POST['action'] = "getRandomMovies";$_POST['number'] =6; public function __construct() {
if (isset($_POST['action'])) { $settings = new SSettings();
$action = $_POST['action']; // load video path from settings
switch ($action) { $this->videopath = $settings->getVideoPath();
case "getMovies": }
$query = "SELECT movie_id,movie_name FROM videos ORDER BY likes DESC, create_date DESC, movie_name ASC";
function initHandlers() {
$this->getVideos();
$this->loadVideos();
$this->addToVideo();
}
/**
* function handles load of all videos and search for videos
*/
private function getVideos() {
$this->addActionHandler("getMovies", function () {
$query = "SELECT movie_id,movie_name FROM videos ORDER BY create_date DESC, movie_name";
if (isset($_POST['tag'])) { if (isset($_POST['tag'])) {
$tag = $_POST['tag']; $tag = $_POST['tag'];
if ($_POST['tag'] != "all") { if ($_POST['tag'] != "all") {
@ -16,21 +34,22 @@ if (isset($_POST['action'])) {
INNER JOIN video_tags vt on videos.movie_id = vt.video_id INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name = '$tag' WHERE t.tag_name = '$tag'
ORDER BY likes DESC, create_date ASC, movie_name ASC"; ORDER BY likes DESC, create_date, movie_name";
} }
} }
$result = $conn->query($query); $result = $this->conn->query($query);
$rows = array(); $rows = array();
while ($r = mysqli_fetch_assoc($result)) { while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r); array_push($rows, $r);
} }
echo(json_encode($rows)); $this->commitMessage(json_encode($rows));
break; });
case "getRandomMovies":
$this->addActionHandler("getRandomMovies", function () {
$return = new stdClass(); $return = new stdClass();
$query = "SELECT movie_id,movie_name FROM videos ORDER BY RAND() LIMIT " . $_POST['number']; $query = "SELECT movie_id,movie_name FROM videos ORDER BY RAND() LIMIT " . $_POST['number'];
$result = $conn->query($query); $result = $this->conn->query($query);
$return->rows = array(); $return->rows = array();
// get tags of random videos // get tags of random videos
@ -47,32 +66,41 @@ if (isset($_POST['action'])) {
INNER JOIN tags t on video_tags.tag_id = t.tag_id INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE $idstring WHERE $idstring
GROUP BY t.tag_name"; GROUP BY t.tag_name";
$result = $conn->query($query); $result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) { while ($r = mysqli_fetch_assoc($result)) {
array_push($return->tags, $r); array_push($return->tags, $r);
} }
echo(json_encode($return)); $this->commitMessage(json_encode($return));
break; });
case "getSearchKeyWord":
$this->addActionHandler("getSearchKeyWord", function () {
$search = $_POST['keyword']; $search = $_POST['keyword'];
$query = "SELECT movie_id,movie_name FROM videos $query = "SELECT movie_id,movie_name FROM videos
WHERE movie_name LIKE '%$search%' WHERE movie_name LIKE '%$search%'
ORDER BY likes DESC, create_date DESC, movie_name ASC"; ORDER BY likes DESC, create_date DESC, movie_name";
$result = $conn->query($query); $result = $this->conn->query($query);
$rows = array(); $rows = array();
while ($r = mysqli_fetch_assoc($result)) { while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r); array_push($rows, $r);
} }
echo(json_encode($rows)); $this->commitMessage(json_encode($rows));
});
}
break; /**
case "loadVideo": * function to handle stuff for loading specific videos and startdata
$query = "SELECT movie_name,movie_id,movie_url,thumbnail,poster,likes,quality,length FROM videos WHERE movie_id='" . $_POST['movieid'] . "'"; */
private function loadVideos() {
$this->addActionHandler("loadVideo", function () {
$video_id = $_POST['movieid'];
$result = $conn->query($query); $query = " SELECT movie_name,movie_id,movie_url,thumbnail,poster,likes,quality,length
FROM videos WHERE movie_id=$video_id";
$result = $this->conn->query($query);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
$arr = array(); $arr = array();
@ -84,8 +112,10 @@ if (isset($_POST['action'])) {
$arr["movie_id"] = $row["movie_id"]; $arr["movie_id"] = $row["movie_id"];
$arr["movie_name"] = $row["movie_name"]; $arr["movie_name"] = $row["movie_name"];
$arr["movie_url"] = str_replace("?","%3F",$row["movie_url"]); // todo drop video url from db -- maybe one with and one without extension
$arr["likes"] = $row["likes"]; // extension hardcoded here!!!
$arr["movie_url"] = str_replace("?", "%3F", $this->videopath . $row["movie_name"] . ".mp4");
$arr["likes"] = (int) $row["likes"];
$arr["quality"] = $row["quality"]; $arr["quality"] = $row["quality"];
$arr["length"] = $row["length"]; $arr["length"] = $row["length"];
@ -93,71 +123,42 @@ if (isset($_POST['action'])) {
$arr['tags'] = array(); $arr['tags'] = array();
$query = "SELECT t.tag_name FROM video_tags $query = "SELECT t.tag_name FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE video_tags.video_id=" . $_POST['movieid'] . " WHERE video_tags.video_id=$video_id
GROUP BY t.tag_name"; GROUP BY t.tag_name";
$result = $conn->query($query); $result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) { while ($r = mysqli_fetch_assoc($result)) {
array_push($arr['tags'], $r); array_push($arr['tags'], $r);
} }
echo(json_encode($arr)); // get the random predict tags
$arr['suggesttag'] = array();
break; // select 5 random tags which are not selected for current video
case "getDbSize": $query = "SELECT * FROM tags
$query = "SELECT table_schema AS \"Database\", WHERE tag_id NOT IN (
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS \"Size\" SELECT video_tags.tag_id FROM video_tags
FROM information_schema.TABLES WHERE video_id=$video_id)
WHERE TABLE_SCHEMA='hub' ORDER BY rand()
GROUP BY table_schema;"; LIMIT 5";
$result = $conn->query($query); $result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
if ($result->num_rows == 1) { array_push($arr['suggesttag'], $r);
$row = $result->fetch_assoc();
echo '{"data":"' . $row["Size"] . 'MB"}';
} }
break; $this->commitMessage(json_encode($arr));
case "readThumbnail": });
$this->addActionHandler("readThumbnail", function () {
$query = "SELECT thumbnail FROM videos WHERE movie_id='" . $_POST['movieid'] . "'"; $query = "SELECT thumbnail FROM videos WHERE movie_id='" . $_POST['movieid'] . "'";
$result = $conn->query($query); $result = $this->conn->query($query);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
echo($row["thumbnail"]); $this->commitMessage($row["thumbnail"]);
});
break; $this->addActionHandler("getStartData", function () {
case "getTags":
// todo add this to loadVideo maybe
$movieid = $_POST['movieid'];
$query = "SELECT tag_name FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE video_id='$movieid'";
$result = $conn->query($query);
$rows = array();
$rows['tags'] = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows['tags'], $r['tag_name']);
}
echo(json_encode($rows));
break;
case "addLike":
$movieid = $_POST['movieid'];
$query = "update videos set likes = likes + 1 where movie_id = '$movieid'";
if ($conn->query($query) === TRUE) {
echo('{"result":"success"}');
} else {
echo('{"result":"' . $conn->error . '"}');
}
break;
case "getStartData":
$query = "SELECT COUNT(*) as nr FROM videos"; $query = "SELECT COUNT(*) as nr FROM videos";
$result = $conn->query($query); $result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result); $r = mysqli_fetch_assoc($result);
$arr = array(); $arr = array();
@ -166,7 +167,7 @@ if (isset($_POST['action'])) {
$query = "SELECT COUNT(*) as nr FROM videos $query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id"; INNER JOIN tags t on vt.tag_id = t.tag_id";
$result = $conn->query($query); $result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result); $r = mysqli_fetch_assoc($result);
$arr['tagged'] = $r['nr']; $arr['tagged'] = $r['nr'];
@ -174,7 +175,7 @@ if (isset($_POST['action'])) {
INNER JOIN video_tags vt on videos.movie_id = vt.video_id INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='hd'"; WHERE t.tag_name='hd'";
$result = $conn->query($query); $result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result); $r = mysqli_fetch_assoc($result);
$arr['hd'] = $r['nr']; $arr['hd'] = $r['nr'];
@ -182,7 +183,7 @@ if (isset($_POST['action'])) {
INNER JOIN video_tags vt on videos.movie_id = vt.video_id INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='fullhd'"; WHERE t.tag_name='fullhd'";
$result = $conn->query($query); $result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result); $r = mysqli_fetch_assoc($result);
$arr['fullhd'] = $r['nr']; $arr['fullhd'] = $r['nr'];
@ -190,43 +191,46 @@ if (isset($_POST['action'])) {
INNER JOIN video_tags vt on videos.movie_id = vt.video_id INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='lowquality'"; WHERE t.tag_name='lowquality'";
$result = $conn->query($query); $result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result); $r = mysqli_fetch_assoc($result);
$arr['sd'] = $r['nr']; $arr['sd'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM tags"; $query = "SELECT COUNT(*) as nr FROM tags";
$result = $conn->query($query); $result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result); $r = mysqli_fetch_assoc($result);
$arr['tags'] = $r['nr']; $arr['tags'] = $r['nr'];
echo(json_encode($arr)); $this->commitMessage(json_encode($arr));
break; });
case "getAllTags":
$query = "SELECT tag_name,tag_id from tags";
$result = $conn->query($query);
$rows = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r);
} }
echo(json_encode($rows));
break; /**
case "addTag": * function to handle api handlers for stuff to add to video or database
*/
private function addToVideo() {
$this->addActionHandler("addLike", function () {
$movieid = $_POST['movieid']; $movieid = $_POST['movieid'];
$tagid = $_POST['id'];
$query = "INSERT INTO video_tags(tag_id, video_id) VALUES ('$tagid','$movieid')"; $query = "update videos set likes = likes + 1 where movie_id = '$movieid'";
if ($conn->query($query) === TRUE) { if ($this->conn->query($query) === TRUE) {
echo('{"result":"success"}'); $this->commitMessage('{"result":"success"}');
} else { } else {
echo('{"result":"' . $conn->error . '"}'); $this->commitMessage('{"result":"' . $this->conn->error . '"}');
} }
break; });
}
} else {
echo('{data:"error"}');
}
return;
$this->addActionHandler("deleteVideo", function () {
$movieid = $_POST['movieid'];
// delete video entry and corresponding tag infos
$query = "DELETE FROM videos WHERE movie_id=$movieid";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
}
}

5
api/tags.php Normal file
View File

@ -0,0 +1,5 @@
<?php
include_once './src/handlers/Tags.php';
$tags = new Tags();
$tags->handleAction();

5
api/video.php Normal file
View File

@ -0,0 +1,5 @@
<?php
include_once './src/handlers/Video.php';
$video = new Video();
$video->handleAction();

View File

@ -27,11 +27,25 @@ create table if not exists video_tags
foreign key (tag_id) references tags (tag_id), foreign key (tag_id) references tags (tag_id),
constraint video_tags_videos_movie_id_fk constraint video_tags_videos_movie_id_fk
foreign key (video_id) references videos (movie_id) foreign key (video_id) references videos (movie_id)
on delete cascade
); );
INSERT INTO tags (tag_id, tag_name) create table if not exists settings
(
video_path varchar(255) null,
episode_path varchar(255) null,
password varchar(32) default '-1' null,
mediacenter_name varchar(32) default 'OpenMediaCenter' null,
TMDB_grabbing tinyint null,
DarkMode tinyint default 0 null
);
INSERT IGNORE INTO tags (tag_id, tag_name)
VALUES (2, 'fullhd'); VALUES (2, 'fullhd');
INSERT INTO tags (tag_id, tag_name) INSERT IGNORE INTO tags (tag_id, tag_name)
VALUES (3, 'lowquality'); VALUES (3, 'lowquality');
INSERT INTO tags (tag_id, tag_name) INSERT IGNORE INTO tags (tag_id, tag_name)
VALUES (4, 'hd'); VALUES (4, 'hd');
INSERT IGNORE INTO settings (video_path, episode_path, password, mediacenter_name)
VALUES ('./videos/', './tvshows/', -1, 'OpenMediaCenter');

View File

@ -0,0 +1,10 @@
Package: OpenMediaCenter
Version: 0.1
Depends: nginx, php-fpm, php-mysqli, mariadb-server
Section: web
Priority: optional
Architecture: all
Essential: no
Installed-Size: 1024
Maintainer: heili.eu
Description: OpenMediaCenter

View File

@ -0,0 +1,23 @@
#!/bin/bash
# enable nginx site
rm /etc/nginx/sites-enabled/OpenMediaCenter.conf
ln -s /etc/nginx/sites-available/OpenMediaCenter.conf /etc/nginx/sites-enabled/OpenMediaCenter.conf
# link general socket to current one
rm /var/run/php-fpm.sock
ln -s /var/run/php/php*-fpm.sock /var/run/php-fpm.sock
# setup database
mysql -uroot -pPASS -e "CREATE DATABASE IF NOT EXISTS mediacenter;"
mysql -uroot -pPASS -e "CREATE USER IF NOT EXISTS 'mediacenteruser'@'localhost' IDENTIFIED BY 'mediapassword';"
mysql -uroot -pPASS -e "GRANT ALL PRIVILEGES ON mediacenter . * TO 'mediacenteruser'@'localhost';"
mysql -u mediacenteruser -pmediapassword mediacenter < /tmp/openmediacenter.sql
# removed unused sql style file
rm /tmp/openmediacenter.sql
# correct user rights
chown -R www-data:www-data /var/www/openmediacenter
# restart services
systemctl restart nginx

View File

@ -0,0 +1,4 @@
#!/bin/bash
#preset db password
debconf-set-selections <<< 'mariadb-server-10.0 mysql-server/root_password password PASS'
debconf-set-selections <<< 'mariadb-server-10.0 mysql-server/root_password_again password PASS'

View File

@ -0,0 +1,21 @@
server {
listen 8080 default_server;
listen [::]:8080 default_server;
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php-fpm.sock;
}
root /var/www/openmediacenter;
index index.html;
access_log /var/log/nginx/openmediacenter.access.log;
error_log /var/log/nginx/openmediacenter.error.log;
location / {
try_files $uri $uri/ =404;
}
}

View File

@ -27,7 +27,7 @@
"text-summary" "text-summary"
] ]
}, },
"proxy": "http://192.168.0.248", "proxy": "http://192.168.0.42",
"homepage": "/", "homepage": "/",
"eslintConfig": { "eslintConfig": {
"extends": "react-app" "extends": "react-app"

View File

@ -1,42 +1,87 @@
import React from 'react'; import React from 'react';
import "./css/App.css"
import HomePage from "./pages/HomePage/HomePage"; import HomePage from "./pages/HomePage/HomePage";
import RandomPage from "./pages/RandomPage/RandomPage"; import RandomPage from "./pages/RandomPage/RandomPage";
import GlobalInfos from "./GlobalInfos";
// include bootstraps css // include bootstraps css
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import style from './App.module.css'
import SettingsPage from "./pages/SettingsPage/SettingsPage"; import SettingsPage from "./pages/SettingsPage/SettingsPage";
import CategoryPage from "./pages/CategoryPage/CategoryPage"; import CategoryPage from "./pages/CategoryPage/CategoryPage";
/**
* The main App handles the main tabs and which content to show
*/
class App extends React.Component { class App extends React.Component {
newElement = null;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = {page: "default"}; this.state = {
page: "default",
generalSettingsLoaded: false,
passwordsupport: null,
mediacentername: "OpenMediaCenter"
};
// bind this to the method for being able to call methods such as this.setstate // bind this to the method for being able to call methods such as this.setstate
this.showVideo = this.showVideo.bind(this); this.changeRootElement = this.changeRootElement.bind(this);
this.hideVideo = this.hideVideo.bind(this); this.returnToLastElement = this.returnToLastElement.bind(this);
} }
videoelement = null; componentDidMount() {
const updateRequest = new FormData();
updateRequest.append('action', 'loadInitialData');
fetch('/api/settings.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
// set theme
GlobalInfos.enableDarkTheme(result.DarkMode);
this.setState({
generalSettingsLoaded: true,
passwordsupport: result.passwordEnabled,
mediacentername: result.mediacenter_name
});
// set tab title to received mediacenter name
document.title = result.mediacenter_name;
}));
}
/**
* create a viewbinding to call APP functions from child elements
* @returns a set of callback functions
*/
constructViewBinding() {
return {
changeRootElement: this.changeRootElement,
returnToLastElement: this.returnToLastElement
};
}
/**
* load the selected component into the main view
* @returns {JSX.Element} body element of selected page
*/
MainBody() { MainBody() {
let page; let page;
if (this.state.page === "default") { if (this.state.page === "default") {
page = <HomePage viewbinding={{showVideo: this.showVideo, hideVideo: this.hideVideo}}/>; page = <HomePage viewbinding={this.constructViewBinding()}/>;
this.mypage = page; this.mypage = page;
} else if (this.state.page === "random") { } else if (this.state.page === "random") {
page = <RandomPage viewbinding={{showVideo: this.showVideo, hideVideo: this.hideVideo}}/>; page = <RandomPage viewbinding={this.constructViewBinding()}/>;
this.mypage = page; this.mypage = page;
} else if (this.state.page === "settings") { } else if (this.state.page === "settings") {
page = <SettingsPage/>; page = <SettingsPage/>;
this.mypage = page; this.mypage = page;
} else if (this.state.page === "categories") { } else if (this.state.page === "categories") {
page = <CategoryPage viewbinding={{showVideo: this.showVideo, hideVideo: this.hideVideo}}/>; page = <CategoryPage viewbinding={this.constructViewBinding()}/>;
this.mypage = page; this.mypage = page;
} else if (this.state.page === "video") { } else if (this.state.page === "video") {
// show videoelement if neccessary // show videoelement if neccessary
page = this.videoelement; page = this.newElement;
console.log(page); console.log(page);
} else if (this.state.page === "lastpage") { } else if (this.state.page === "lastpage") {
@ -49,56 +94,50 @@ class App extends React.Component {
} }
render() { render() {
const themeStyle = GlobalInfos.getThemeStyle();
// add the main theme to the page body
document.body.className = themeStyle.backgroundcolor;
return ( return (
<div className="App"> <div className={style.app}>
<nav className="navbar navbar-expand-sm bg-primary navbar-dark"> <div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}>
<div className="navbar-brand">OpenMediaCenter</div> <div className={style.navbrand}>{this.state.mediacentername}</div>
<ul className="navbar-nav"> <div className={[style.navitem, themeStyle.navitem, this.state.page === "default" ? style.navitemselected : {}].join(' ')}
<li className="nav-item">
<div className="nav-link"
style={this.state.page === "default" ? {color: "rgba(255,255,255,.75"} : {}}
onClick={() => this.setState({page: "default"})}>Home onClick={() => this.setState({page: "default"})}>Home
</div> </div>
</li> <div className={[style.navitem, themeStyle.navitem, this.state.page === "random" ? style.navitemselected : {}].join(' ')}
<li className="nav-item">
<div className="nav-link"
style={this.state.page === "random" ? {color: "rgba(255,255,255,.75"} : {}}
onClick={() => this.setState({page: "random"})}>Random Video onClick={() => this.setState({page: "random"})}>Random Video
</div> </div>
</li> <div className={[style.navitem, themeStyle.navitem, this.state.page === "categories" ? style.navitemselected : {}].join(' ')}
<li className="nav-item">
<div className="nav-link"
style={this.state.page === "categories" ? {color: "rgba(255,255,255,.75"} : {}}
onClick={() => this.setState({page: "categories"})}>Categories onClick={() => this.setState({page: "categories"})}>Categories
</div> </div>
</li> <div className={[style.navitem, themeStyle.navitem, this.state.page === "settings" ? style.navitemselected : {}].join(' ')}
<li className="nav-item">
<div className="nav-link"
style={this.state.page === "settings" ? {color: "rgba(255,255,255,.75"} : {}}
onClick={() => this.setState({page: "settings"})}>Settings onClick={() => this.setState({page: "settings"})}>Settings
</div> </div>
</li> </div>
</ul> {this.state.generalSettingsLoaded ? this.MainBody() : "loading"}
</nav>
{this.MainBody()}
</div> </div>
); );
} }
showVideo(element) { /**
this.videoelement = element; * render a new root element into the main body
*/
changeRootElement(element) {
this.newElement = element;
this.setState({ this.setState({
page: "video" page: "video"
}); });
} }
hideVideo() { /**
* return from page to the previous page before a change
*/
returnToLastElement() {
this.setState({ this.setState({
page: "lastpage" page: "lastpage"
}); });
this.element = null;
} }
} }

58
src/App.module.css Normal file
View File

@ -0,0 +1,58 @@
.app {
user-select: none;
}
.navitem {
cursor: pointer;
float: left;
font-size: large;
font-weight: bold;
margin-left: 20px;
opacity: 0.6;
text-transform: capitalize;
}
.navitem:hover {
opacity: 1;
transition: opacity .5s;
}
.navitem::after {
content: '';
display: block;
height: 2px;
transition: width .3s;
width: 0;
}
.navitem:hover::after {
width: 100%;
}
.navitemselected {
opacity: 0.85;
}
.navcontainer {
border-bottom-width: 2px;
border-style: dotted;
border-width: 0;
padding-bottom: 40px;
padding-top: 20px;
width: 100%;
}
.navbrand {
float: left;
font-size: large;
font-weight: bold;
margin-left: 20px;
margin-right: 20px;
text-transform: capitalize;
}

View File

@ -10,69 +10,99 @@ describe('<App/>', function () {
it('renders title', () => { it('renders title', () => {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
expect(wrapper.find('.navbar-brand').text()).toBe('OpenMediaCenter'); expect(wrapper.find('.navbrand').text()).toBe('OpenMediaCenter');
}); });
it('are navlinks correct', function () { it('are navlinks correct', function () {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
expect(wrapper.find('nav').find('li')).toHaveLength(4); expect(wrapper.find('.navitem')).toHaveLength(4);
}); });
it('simulate video view change ', function () { it('simulate video view change ', function () {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.instance().showVideo(<div id='testit'></div>); wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find("#testit")).toHaveLength(1); expect(wrapper.find("#testit")).toHaveLength(1);
}); });
it('test hide video again', function () { it('test hide video again', function () {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.instance().showVideo(<div id='testit'></div>); wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find("#testit")).toHaveLength(1); expect(wrapper.find("#testit")).toHaveLength(1);
wrapper.instance().hideVideo(); wrapper.instance().returnToLastElement();
expect(wrapper.find("HomePage")).toHaveLength(1); expect(wrapper.find("HomePage")).toHaveLength(1);
}); });
it('test fallback to last loaded page', function () { it('test fallback to last loaded page', function () {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.find(".nav-link").findWhere(t => t.text() === "Random Video" && t.type() === "div").simulate("click"); wrapper.find(".navitem").findWhere(t => t.text() === "Random Video" && t.type() === "div").simulate("click");
wrapper.instance().showVideo(<div id='testit'></div>); wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find("#testit")).toHaveLength(1); expect(wrapper.find("#testit")).toHaveLength(1);
wrapper.instance().hideVideo(); wrapper.instance().returnToLastElement();
expect(wrapper.find("RandomPage")).toHaveLength(1); expect(wrapper.find("RandomPage")).toHaveLength(1);
}); });
it('test home click', function () { it('test home click', function () {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.setState({page: "wrongvalue"}); wrapper.setState({page: "wrongvalue"});
expect(wrapper.find("HomePage")).toHaveLength(0); expect(wrapper.find("HomePage")).toHaveLength(0);
wrapper.find(".nav-link").findWhere(t => t.text() === "Home" && t.type() === "div").simulate("click"); wrapper.find(".navitem").findWhere(t => t.text() === "Home" && t.type() === "div").simulate("click");
expect(wrapper.find("HomePage")).toHaveLength(1); expect(wrapper.find("HomePage")).toHaveLength(1);
}); });
it('test category click', function () { it('test category click', function () {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
expect(wrapper.find("CategoryPage")).toHaveLength(0); expect(wrapper.find("CategoryPage")).toHaveLength(0);
wrapper.find(".nav-link").findWhere(t => t.text() === "Categories" && t.type() === "div").simulate("click"); wrapper.find(".navitem").findWhere(t => t.text() === "Categories" && t.type() === "div").simulate("click");
expect(wrapper.find("CategoryPage")).toHaveLength(1); expect(wrapper.find("CategoryPage")).toHaveLength(1);
}); });
it('test settings click', function () { it('test settings click', function () {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
expect(wrapper.find("SettingsPage")).toHaveLength(0); expect(wrapper.find("SettingsPage")).toHaveLength(0);
wrapper.find(".nav-link").findWhere(t => t.text() === "Settings" && t.type() === "div").simulate("click"); wrapper.find(".navitem").findWhere(t => t.text() === "Settings" && t.type() === "div").simulate("click");
expect(wrapper.find("SettingsPage")).toHaveLength(1); expect(wrapper.find("SettingsPage")).toHaveLength(1);
}); });
it('test initial fetch from api', done => {
global.fetch = global.prepareFetchApi({
generalSettingsLoaded: true,
passwordsupport: true,
mediacentername: "testname"
});
const wrapper = shallow(<App/>);
const func = jest.fn();
wrapper.instance().setState = func;
expect(global.fetch).toBeCalledTimes(1);
process.nextTick(() => {
expect(func).toBeCalledTimes(1);
global.fetch.mockClear();
done();
});
});
}); });

View File

@ -0,0 +1,40 @@
/**
* The coloring elements for dark theme
*/
.backgroundcolor {
background-color: #141520;
}
.textcolor {
color: white;
}
.subtextcolor {
color: #dedad6;
}
.lighttextcolor {
color: #d5d5d5;
}
.navitem::after {
background: white;
}
.hrcolor {
border-color: rgba(255, 255, 255, .1);
}
.secbackground {
background-color: #3c3d48;
}
.thirdbackground {
background-color: #141520;
}
.preview:hover {
box-shadow: rgba(255, 255, 255, 0.7) 0 0 0 5px;
}

View File

@ -0,0 +1,39 @@
/**
* The coloring elements for light theme
*/
.navitem::after {
background: black;
}
.backgroundcolor {
background-color: white;
}
.textcolor {
color: black;
}
.subtextcolor {
color: #212529;
}
.lighttextcolor {
color: #3d3d3d;
}
.hrcolor {
border-color: rgba(0, 0, 0, 0.1);
}
.secbackground {
background-color: #a8c3ff;
}
.thirdbackground {
background-color: #8ca3fc;
}
.preview:hover {
box-shadow: rgba(2, 12, 27, 0.7) 0 0 0 5px;
}

37
src/GlobalInfos.js Normal file
View File

@ -0,0 +1,37 @@
import darktheme from "./AppDarkTheme.module.css";
import lighttheme from "./AppLightTheme.module.css";
/**
* This class is available for all components in project
* it contains general infos about app - like theme
*/
class StaticInfos {
#darktheme = true;
/**
* check if the current theme is the dark theme
* @returns {boolean} is dark theme?
*/
isDarkTheme() {
return this.#darktheme;
};
/**
* setter to enable or disable the dark or light theme
* @param enable enable the dark theme?
*/
enableDarkTheme(enable = true) {
this.#darktheme = enable;
}
/**
* get the currently selected theme stylesheet
* @returns {*} the style object of the current active theme
*/
getThemeStyle() {
return this.isDarkTheme() ? darktheme : lighttheme;
}
}
const GlobalInfos = new StaticInfos();
export default GlobalInfos;

24
src/GlobalInfos.test.js Normal file
View File

@ -0,0 +1,24 @@
import React from "react";
import GlobalInfos from "./GlobalInfos";
describe('<GlobalInfos/>', function () {
it('always same instance ', function () {
GlobalInfos.enableDarkTheme(true);
expect(GlobalInfos.isDarkTheme()).toBe(true);
GlobalInfos.enableDarkTheme(false);
expect(GlobalInfos.isDarkTheme()).toBe(false);
});
it('test default theme', function () {
expect(GlobalInfos.isDarkTheme()).toBe(false);
});
it('test receive of stylesheet', function () {
const style = GlobalInfos.getThemeStyle();
expect(style.navitem).not.toBeNull();
});
});

View File

@ -1,12 +0,0 @@
.nav-item {
cursor: pointer;
}
.nav-link {
color: rgba(255, 255, 255, .5);
font-weight: bold;
}
.nav-link:hover {
color: rgba(255, 255, 255, 1);
}

View File

@ -1,8 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -1,28 +1,40 @@
import React from "react"; import React from "react";
import Modal from 'react-bootstrap/Modal' import ReactDom from 'react-dom';
import Dropdown from "react-bootstrap/Dropdown"; import style from './AddTagPopup.module.css'
import DropdownButton from "react-bootstrap/DropdownButton"; import Tag from "../Tag/Tag";
import {Line} from "../PageTitle/PageTitle";
import GlobalInfos from "../../GlobalInfos";
/**
* component creates overlay to add a new tag to a video
*/
class AddTagPopup extends React.Component { class AddTagPopup extends React.Component {
/// instance of root element
element;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {items: []};
selection: { this.handleClickOutside = this.handleClickOutside.bind(this);
name: "nothing selected", this.keypress = this.keypress.bind(this);
id: -1
},
items: []
};
this.props = props; this.props = props;
} }
componentDidMount() { componentDidMount() {
document.addEventListener('click', this.handleClickOutside);
document.addEventListener('keyup', this.keypress);
// add element drag drop events
if (this.element != null) {
this.dragElement();
}
const updateRequest = new FormData(); const updateRequest = new FormData();
updateRequest.append('action', 'getAllTags'); updateRequest.append('action', 'getAllTags');
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()) .then((response) => response.json())
.then((result) => { .then((result) => {
this.setState({ this.setState({
@ -31,59 +43,115 @@ class AddTagPopup extends React.Component {
}); });
} }
componentWillUnmount() {
// remove the appended listeners
document.removeEventListener('click', this.handleClickOutside);
document.removeEventListener('keyup', this.keypress);
}
render() { render() {
const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<> <div className={[style.popup, themeStyle.thirdbackground].join(' ')} ref={el => this.element = el}>
<Modal <div className={[style.header, themeStyle.textcolor].join(' ')}>Add a Tag to this Video:</div>
show={this.props.show} <Line/>
onHide={this.props.onHide} <div className={style.content}>
size="lg"
aria-labelledby="contained-modal-title-vcenter"
centered>
<Modal.Header closeButton>
<Modal.Title id="contained-modal-title-vcenter">
Add to Tag
</Modal.Title>
</Modal.Header>
<Modal.Body>
<h4>Select a Tag:</h4>
<DropdownButton id="dropdown-basic-button" title={this.state.selection.name}>
{this.state.items ? {this.state.items ?
this.state.items.map((i) => ( this.state.items.map((i) => (
<Dropdown.Item key={i.tag_name} onClick={() => { <Tag onclick={() => {
this.setState({selection: {name: i.tag_name, id: i.tag_id}}) this.addTag(i.tag_id, i.tag_name);
}}>{i.tag_name}</Dropdown.Item> }}>{i.tag_name}</Tag>
)) : )) : null}
<Dropdown.Item>loading tags...</Dropdown.Item>} </div>
</DropdownButton> </div>
</Modal.Body>
<Modal.Footer>
<button className='btn btn-primary' onClick={() => {
this.storeselection();
}}>Add
</button>
</Modal.Footer>
</Modal>
</>
); );
} }
storeselection() { /**
* Alert if clicked on outside of element
*/
handleClickOutside(event) {
const domNode = ReactDom.findDOMNode(this);
if (!domNode || !domNode.contains(event.target)) {
this.props.onHide();
}
}
/**
* key event handling
* @param event keyevent
*/
keypress(event) {
// hide if escape is pressed
if (event.key === "Escape") {
this.props.onHide();
}
}
/**
* add a new tag to this video
* @param tagid tag id to add
* @param tagname tag name to add
*/
addTag(tagid, tagname) {
console.log(this.props)
const updateRequest = new FormData(); const updateRequest = new FormData();
updateRequest.append('action', 'addTag'); updateRequest.append('action', 'addTag');
updateRequest.append('id', this.state.selection.id); updateRequest.append('id', tagid);
updateRequest.append('movieid', this.props.movie_id); updateRequest.append('movieid', this.props.movie_id);
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json() .then((response) => response.json()
.then((result) => { .then((result) => {
if (result.result !== "success") { if (result.result !== "success") {
console.log("error occured while writing to db -- todo error handling"); console.log("error occured while writing to db -- todo error handling");
console.log(result.result); console.log(result.result);
} else {
this.props.submit(tagid, tagname);
} }
this.props.onHide(); this.props.onHide();
})); }));
} }
/**
* make the element drag and droppable
*/
dragElement() {
let xOld = 0, yOld = 0;
const elmnt = this.element;
elmnt.firstChild.onmousedown = dragMouseDown;
function dragMouseDown(e) {
e.preventDefault();
// get the mouse cursor position at startup:
xOld = e.clientX;
yOld = e.clientY;
document.onmouseup = closeDragElement;
// call a function whenever the cursor moves:
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e.preventDefault();
// calculate the new cursor position:
const dx = xOld - e.clientX;
const dy = yOld - e.clientY;
xOld = e.clientX;
yOld = e.clientY;
// set the element's new position:
elmnt.style.top = (elmnt.offsetTop - dy) + "px";
elmnt.style.left = (elmnt.offsetLeft - dx) + "px";
}
function closeDragElement() {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
}
}
} }
export default AddTagPopup; export default AddTagPopup;

View File

@ -0,0 +1,26 @@
.popup {
border: 3px #3574fe solid;
border-radius: 18px;
height: 80%;
left: 20%;
opacity: 0.95;
position: absolute;
top: 10%;
width: 60%;
z-index: 2;
}
.header {
cursor: move;
font-size: x-large;
margin-left: 15px;
margin-top: 10px;
opacity: 1;
}
.content {
margin-left: 20px;
margin-right: 20px;
margin-top: 10px;
opacity: 1;
}

View File

@ -11,47 +11,68 @@ describe('<AddTagPopup/>', function () {
wrapper.unmount(); wrapper.unmount();
}); });
it('test dropdown insertion', function () { it('test tag insertion', function () {
const wrapper = shallow(<AddTagPopup/>); const wrapper = shallow(<AddTagPopup/>);
wrapper.setState({items: ["test1", "test2", "test3"]}); wrapper.setState({
expect(wrapper.find('DropdownItem')).toHaveLength(3); items: [{tag_id: 1, tag_name: 'test'}, {tag_id: 2, tag_name: "ee"}]
}, () => {
expect(wrapper.find('Tag')).toHaveLength(2);
expect(wrapper.find('Tag').first().dive().text()).toBe("test");
});
}); });
it('test storeseletion click event', done => { it('test tag click', function () {
const mockSuccessResponse = {};
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
global.fetch = jest.fn().mockImplementation(() => mockFetchPromise);
const func = jest.fn();
const wrapper = shallow(<AddTagPopup/>); const wrapper = shallow(<AddTagPopup/>);
wrapper.setProps({ wrapper.instance().addTag = jest.fn();
onHide: () => {
func()
}
});
wrapper.setState({ wrapper.setState({
items: ["test1", "test2", "test3"], items: [{tag_id: 1, tag_name: 'test'}]
selection: { }, () => {
name: "test1", wrapper.find('Tag').first().dive().simulate('click');
id: 42 expect(wrapper.instance().addTag).toHaveBeenCalledTimes(1);
} });
}); });
// first call of fetch is getting of available tags it('test addtag', done => {
expect(global.fetch).toHaveBeenCalledTimes(1); const wrapper = shallow(<AddTagPopup/>);
wrapper.find('ModalFooter').find('button').simulate('click');
// now called 2 times global.fetch = prepareFetchApi({result: "success"});
expect(global.fetch).toHaveBeenCalledTimes(2);
wrapper.setProps({
submit: jest.fn((arg1, arg2) => {}),
onHide: jest.fn()
}, () => {
wrapper.instance().addTag(1, "test");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
process.nextTick(() => { process.nextTick(() => {
//callback to close window should have called expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledTimes(1); expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
it('test failing addTag', done => {
const wrapper = shallow(<AddTagPopup/>);
global.fetch = prepareFetchApi({result: "fail"});
wrapper.setProps({
submit: jest.fn((arg1, arg2) => {}),
onHide: jest.fn()
}, () => {
wrapper.instance().addTag(1, "test");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
process.nextTick(() => {
expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(0);
expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
global.fetch.mockClear(); global.fetch.mockClear();
done(); done();

View File

@ -2,6 +2,9 @@ import React from "react";
import Modal from 'react-bootstrap/Modal' import Modal from 'react-bootstrap/Modal'
import {Form} from "react-bootstrap"; import {Form} from "react-bootstrap";
/**
* creates modal overlay to define a new Tag
*/
class NewTagPopup extends React.Component { class NewTagPopup extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -45,12 +48,15 @@ class NewTagPopup extends React.Component {
); );
} }
/**
* store the filled in form to the backend
*/
storeselection() { storeselection() {
const updateRequest = new FormData(); const updateRequest = new FormData();
updateRequest.append('action', 'createTag'); updateRequest.append('action', 'createTag');
updateRequest.append('tagname', this.value); updateRequest.append('tagname', this.value);
fetch('/api/Tags.php', {method: 'POST', body: updateRequest}) fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()) .then((response) => response.json())
.then((result) => { .then((result) => {
if (result.result !== "success") { if (result.result !== "success") {

View File

@ -0,0 +1,39 @@
import React from "react";
import style from "./PageTitle.module.css"
import GlobalInfos from "../../GlobalInfos";
/**
* Component for generating PageTitle with bottom Line
*/
class PageTitle extends React.Component {
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={style.pageheader + ' ' + themeStyle.backgroundcolor}>
<span className={style.pageheadertitle + ' ' + themeStyle.textcolor}>{this.props.title}</span>
<span className={style.pageheadersubtitle + ' ' + themeStyle.textcolor}>{this.props.subtitle}</span>
<>
{this.props.children}
</>
<Line/>
</div>
);
}
}
/**
* class to override default <hr> color and styling
* use this for horizontal lines to use the current active theming
*/
export class Line extends React.Component {
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<>
<hr className={themeStyle.hrcolor}/>
</>
);
}
}
export default PageTitle;

View File

@ -1,8 +1,5 @@
.pageheader { .pageheader {
margin-top: 20px; padding: 20px 12% 20px 22%;
margin-bottom: 20px;
padding-left: 22%;
padding-right: 12%;
} }
.pageheadertitle { .pageheadertitle {
@ -11,8 +8,7 @@
} }
.pageheadersubtitle { .pageheadersubtitle {
margin-left: 20px;
font-size: 23pt; font-size: 23pt;
margin-left: 20px;
opacity: 0.6; opacity: 0.6;
} }

View File

@ -0,0 +1,31 @@
import React from 'react';
import {shallow} from 'enzyme'
import PageTitle from "./PageTitle";
describe('<Preview/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<PageTitle/>);
wrapper.unmount();
});
it('renders childs correctly', function () {
const wrapper = shallow(<PageTitle>heyimachild</PageTitle>);
const children = wrapper.children();
expect(children.at(children.length - 2).text()).toBe("heyimachild");
});
it('renders pagetitle prop', function () {
const wrapper = shallow(<PageTitle title='testtitle'/>);
expect(wrapper.find(".pageheader").text()).toBe("testtitle<Line />");
});
it('renders subtitle prop', function () {
const wrapper = shallow(<PageTitle subtitle='testsubtitle'/>);
expect(wrapper.find(".pageheadersubtitle").text()).toBe("testsubtitle");
});
});

View File

@ -1,12 +1,16 @@
import React from "react"; import React from "react";
import "./Preview.css"; import style from "./Preview.module.css";
import Player from "../../pages/Player/Player"; import Player from "../../pages/Player/Player";
import VideoContainer from "../VideoContainer/VideoContainer"; import {Spinner} from "react-bootstrap";
import GlobalInfos from "../../GlobalInfos";
/**
* Component for single preview tile
* floating side by side
*/
class Preview extends React.Component { class Preview extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.props = props;
this.state = { this.state = {
previewpicture: null, previewpicture: null,
@ -14,10 +18,6 @@ class Preview extends React.Component {
}; };
} }
componentWillUnmount() {
this.setState({});
}
componentDidMount() { componentDidMount() {
this.setState({ this.setState({
previewpicture: null, previewpicture: null,
@ -28,84 +28,71 @@ class Preview extends React.Component {
updateRequest.append('action', 'readThumbnail'); updateRequest.append('action', 'readThumbnail');
updateRequest.append('movieid', this.props.movie_id); updateRequest.append('movieid', this.props.movie_id);
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.text() .then((response) => response.text()
.then((result) => { .then((result) => {
this.setState(prevState => ({ this.setState({
...prevState.previewpicture, previewpicture: result previewpicture: result
})); });
})); }));
} }
render() { render() {
const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<div className='videopreview' onClick={() => this.itemClick()}> <div className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
<div className='previewtitle'>{this.state.name}</div> onClick={() => this.itemClick()}>
<div className='previewpic'> <div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.state.name}</div>
<img className='previewimage' <div className={style.previewpic}>
{this.state.previewpicture !== null ?
<img className={style.previewimage}
src={this.state.previewpicture} src={this.state.previewpicture}
alt='Pic loading.'/> alt='Pic loading.'/> :
<span className={style.loadAnimation}><Spinner animation="border"/></span>}
</div> </div>
<div className='previewbottom'> <div className={style.previewbottom}>
</div> </div>
</div> </div>
); );
} }
/**
* handle the click event of a tile
*/
itemClick() { itemClick() {
console.log("item clicked!" + this.state.name); console.log("item clicked!" + this.state.name);
this.props.viewbinding.showVideo(<Player this.props.viewbinding.changeRootElement(
<Player
viewbinding={this.props.viewbinding} viewbinding={this.props.viewbinding}
movie_id={this.props.movie_id}/>); movie_id={this.props.movie_id}/>);
} }
} }
/**
* Component for a Tag-name tile (used in category page)
*/
export class TagPreview extends React.Component { export class TagPreview extends React.Component {
constructor(props, context) {
super(props, context);
this.props = props;
}
fetchVideoData(tag) {
console.log(tag);
const updateRequest = new FormData();
updateRequest.append('action', 'getMovies');
updateRequest.append('tag', tag);
console.log("fetching data");
// fetch all videos available
fetch('/api/videoload.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
console.log(result);
this.props.categorybinding(
<VideoContainer
data={result}
viewbinding={this.props.viewbinding}/>, tag
);
}))
.catch(() => {
console.log("no connection to backend");
});
}
render() { render() {
const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<div className='videopreview tagpreview' onClick={() => this.itemClick()}> <div
<div className='tagpreviewtitle'> className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
onClick={() => this.itemClick()}>
<div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>
{this.props.name} {this.props.name}
</div> </div>
</div> </div>
); );
} }
/**
* handle the click event of a Tag tile
*/
itemClick() { itemClick() {
this.fetchVideoData(this.props.name); this.props.categorybinding(this.props.name);
} }
} }

View File

@ -1,22 +1,30 @@
.previewtitle { .previewtitle {
height: 20px;
color: #3d3d3d;
text-align: center;
font-weight: bold;
max-width: 266px;
font-size: smaller; font-size: smaller;
font-weight: bold;
height: 20px;
max-width: 266px;
text-align: center;
} }
.previewpic { .previewpic {
height: 80%; height: 80%;
min-height: 150px;
min-width: 266px;
overflow: hidden; overflow: hidden;
text-align: center;
}
.loadAnimation {
display: inline-block;
line-height: 150px;
vertical-align: middle;
} }
.previewimage { .previewimage {
min-height: 150px;
max-height: 400px; max-height: 400px;
min-width: 266px;
max-width: 410px; max-width: 410px;
min-height: 150px;
min-width: 266px;
} }
.previewbottom { .previewbottom {
@ -24,28 +32,25 @@
} }
.videopreview { .videopreview {
border-radius: 20px;
cursor: pointer;
float: left; float: left;
margin-left: 25px; margin-left: 25px;
margin-top: 25px; margin-top: 25px;
/*background-color: #7F7F7F;*/
background-color: #a8c3ff;
cursor: pointer;
opacity: 0.85; opacity: 0.85;
border-radius: 20px;
} }
.videopreview:hover { .videopreview:hover {
opacity: 1; opacity: 1;
box-shadow: rgba(2, 12, 27, 0.7) 0px 0px 0px 5px;
transition: all 300ms; transition: all 300ms;
} }
.tagpreview { .tagpreview {
text-transform: uppercase;
font-weight: bolder;
font-size: x-large; font-size: x-large;
text-align: center; font-weight: bolder;
height: 150px; height: 150px;
text-align: center;
text-transform: uppercase;
width: 266px; width: 266px;
} }

View File

@ -22,7 +22,7 @@ describe('<Preview/>', function () {
const wrapper = shallow(<Preview/>); const wrapper = shallow(<Preview/>);
wrapper.setProps({ wrapper.setProps({
viewbinding: { viewbinding: {
showVideo: () => { changeRootElement: () => {
func() func()
} }
} }
@ -56,6 +56,13 @@ describe('<Preview/>', function () {
}); });
}); });
it('spinner loads correctly', function () {
const wrapper = shallow(<Preview/>);
// expect load animation to be visible
expect(wrapper.find(".loadAnimation")).toHaveLength(1);
});
}); });
describe('<TagPreview/>', function () { describe('<TagPreview/>', function () {
@ -71,14 +78,7 @@ describe('<TagPreview/>', function () {
}); });
it('click event triggered', done => { it('click event triggered', function () {
const mockSuccessResponse = {};
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
global.fetch = jest.fn().mockImplementation(() => mockFetchPromise);
const func = jest.fn(); const func = jest.fn();
const wrapper = shallow(<TagPreview/>); const wrapper = shallow(<TagPreview/>);
@ -89,19 +89,11 @@ describe('<TagPreview/>', function () {
}); });
// first call of fetch is getting of available tags // first call of fetch is getting of available tags
expect(global.fetch).toHaveBeenCalledTimes(0); expect(func).toHaveBeenCalledTimes(0);
wrapper.find('.videopreview').simulate('click'); wrapper.find('.videopreview').simulate('click');
// now called 1 times // now called 1 times
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
//callback to close window should have called
expect(func).toHaveBeenCalledTimes(1); expect(func).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
}); });
}); });

View File

@ -1,12 +1,42 @@
import React from "react"; import React from "react";
import "./SideBar.css" import style from "./SideBar.module.css"
import GlobalInfos from "../../GlobalInfos";
/**
* component for sidebar-info
*/
class SideBar extends React.Component { class SideBar extends React.Component {
render() { render() {
return (<div className='sideinfo'> const themeStyle = GlobalInfos.getThemeStyle();
return (<div className={style.sideinfo + ' ' + themeStyle.secbackground}>
{this.props.children} {this.props.children}
</div>); </div>);
} }
} }
/**
* The title of the sidebar
*/
export class SideBarTitle extends React.Component {
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={style.sidebartitle + ' ' + themeStyle.subtextcolor}>{this.props.children}</div>
);
}
}
/**
* An item of the sidebar
*/
export class SideBarItem extends React.Component {
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div
className={style.sidebarinfo + ' ' + themeStyle.thirdbackground + ' ' + themeStyle.lighttextcolor}>{this.props.children}</div>
);
}
}
export default SideBar; export default SideBar;

View File

@ -1,24 +1,22 @@
.sideinfo { .sideinfo {
width: 20%;
float: left;
padding: 20px;
margin-top: 25px;
margin-left: 15px;
background-color: #b4c7fe;
border-radius: 20px;
border: 2px #3574fe solid; border: 2px #3574fe solid;
border-radius: 20px;
float: left;
margin-left: 15px;
margin-top: 25px;
overflow: hidden; overflow: hidden;
padding: 20px;
width: 20%;
} }
.sidebartitle { .sidebartitle {
font-weight: bold;
font-size: larger; font-size: larger;
font-weight: bold;
} }
.sidebarinfo { .sidebarinfo {
margin-top: 5px;
background-color: #8ca3fc;
border-radius: 5px; border-radius: 5px;
margin-top: 5px;
padding: 2px 10px 2px 15px; padding: 2px 10px 2px 15px;
width: 220px; width: 220px;
} }

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import SideBar from "./SideBar"; import SideBar, {SideBarItem, SideBarTitle} from "./SideBar";
import "@testing-library/jest-dom" import "@testing-library/jest-dom"
import {shallow} from "enzyme"; import {shallow} from "enzyme";
@ -14,4 +14,14 @@ describe('<SideBar/>', function () {
const wrapper = shallow(<SideBar>test</SideBar>); const wrapper = shallow(<SideBar>test</SideBar>);
expect(wrapper.children().text()).toBe("test"); expect(wrapper.children().text()).toBe("test");
}); });
it('sidebar Item renders without crashing', function () {
const wrapper = shallow(<SideBarItem>Test</SideBarItem>);
expect(wrapper.children().text()).toBe("Test");
});
it('renderes sidebartitle correctly', function () {
const wrapper = shallow(<SideBarTitle>Test</SideBarTitle>);
expect(wrapper.children().text()).toBe("Test");
});
}); });

View File

@ -1,21 +1,36 @@
import React from "react"; import React from "react";
import "./Tag.css" import styles from "./Tag.module.css"
import CategoryPage from "../../pages/CategoryPage/CategoryPage";
/**
* A Component representing a single Category tag
*/
class Tag extends React.Component { class Tag extends React.Component {
constructor(props, context) {
super(props, context);
this.props = props;
}
render() { render() {
// todo onclick events correctlyy
return ( return (
<button className='tagbtn' onClick={this.props.onClick} <button className={styles.tagbtn} onClick={() => this.TagClick()}
data-testid="Test-Tag">{this.props.children}</button> data-testid="Test-Tag">{this.props.children}</button>
); );
} }
/**
* click handling for a Tag
*/
TagClick() {
if (this.props.onclick) {
this.props.onclick();
return;
}
const tag = this.props.children.toString().toLowerCase();
// call callback functin to switch to category page with specified tag
this.props.viewbinding.changeRootElement(
<CategoryPage
category={tag}
viewbinding={this.props.viewbinding}/>);
}
} }
export default Tag; export default Tag;

View File

@ -1,12 +1,12 @@
.tagbtn { .tagbtn {
color: white;
margin: 10px;
background-color: #3574fe; background-color: #3574fe;
border: none; border: none;
border-radius: 10px; border-radius: 10px;
padding: 5px 15px 5px 15px; color: white;
margin-left: 10px;
margin-top: 15px;
/*font-weight: bold;*/ /*font-weight: bold;*/
display: block; padding: 5px 15px 5px 15px;
} }
.tagbtn:focus { .tagbtn:focus {

View File

@ -14,4 +14,34 @@ describe('<Tag/>', function () {
const wrapper = shallow(<Tag>test</Tag>); const wrapper = shallow(<Tag>test</Tag>);
expect(wrapper.children().text()).toBe("test"); expect(wrapper.children().text()).toBe("test");
}); });
it('click event triggered and setvideo callback called', function () {
global.fetch = global.prepareFetchApi({});
const func = jest.fn();
const elem = {
changeRootElement: () => func()
};
const wrapper = shallow(<Tag
viewbinding={elem}>test</Tag>);
expect(func).toBeCalledTimes(0);
wrapper.simulate("click");
expect(func).toBeCalledTimes(1);
});
it('test custom onclick function', function () {
const func = jest.fn();
const wrapper = shallow(<Tag
onclick={() => {func()}}>test</Tag>);
expect(func).toBeCalledTimes(0);
wrapper.simulate("click");
expect(func).toBeCalledTimes(1);
});
}); });

View File

@ -1,7 +1,15 @@
import React from "react"; import React from "react";
import Preview from "../Preview/Preview"; import Preview from "../Preview/Preview";
import style from "./VideoContainer.module.css"
/**
* A videocontainer storing lots of Preview elements
* includes scroll handling and loading of preview infos
*/
class VideoContainer extends React.Component { class VideoContainer extends React.Component {
// stores current index of loaded elements
loadindex = 0;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -13,18 +21,15 @@ class VideoContainer extends React.Component {
}; };
} }
// stores current index of loaded elements
loadindex = 0;
componentDidMount() { componentDidMount() {
document.addEventListener('scroll', this.trackScrolling); document.addEventListener('scroll', this.trackScrolling);
this.loadPreviewBlock(12); this.loadPreviewBlock(16);
} }
render() { render() {
return ( return (
<div className='maincontent'> <div className={style.maincontent}>
{this.state.loadeditems.map(elem => ( {this.state.loadeditems.map(elem => (
<Preview <Preview
key={elem.movie_id} key={elem.movie_id}
@ -35,15 +40,21 @@ class VideoContainer extends React.Component {
{/*todo css for no items to show*/} {/*todo css for no items to show*/}
{this.state.loadeditems.length === 0 ? {this.state.loadeditems.length === 0 ?
"no items to show!" : null} "no items to show!" : null}
{this.props.children}
</div> </div>
); );
} }
componentWillUnmount() { componentWillUnmount() {
this.setState({}); this.setState({});
// unbind scroll listener when unmounting component
document.removeEventListener('scroll', this.trackScrolling); document.removeEventListener('scroll', this.trackScrolling);
} }
/**
* load previews to the container
* @param nr number of previews to load
*/
loadPreviewBlock(nr) { loadPreviewBlock(nr) {
console.log("loadpreviewblock called ...") console.log("loadpreviewblock called ...")
let ret = []; let ret = [];
@ -65,9 +76,11 @@ class VideoContainer extends React.Component {
this.loadindex += nr; this.loadindex += nr;
} }
/**
* scroll event handler -> load new previews if on bottom
*/
trackScrolling = () => { trackScrolling = () => {
// comparison if current scroll position is on bottom // comparison if current scroll position is on bottom --> 200 is bottom offset to trigger load
// 200 stands for bottom offset to trigger load
if (window.innerHeight + document.documentElement.scrollTop + 200 >= document.documentElement.offsetHeight) { if (window.innerHeight + document.documentElement.scrollTop + 200 >= document.documentElement.offsetHeight) {
this.loadPreviewBlock(8); this.loadPreviewBlock(8);
} }

View File

@ -0,0 +1,4 @@
.maincontent {
float: left;
width: 70%;
}

View File

@ -8,11 +8,11 @@ describe('<VideoContainer/>', function () {
wrapper.unmount(); wrapper.unmount();
}); });
it('inserts tiles correctly', () => { it('inserts tiles correctly if enough available', () => {
const wrapper = shallow(<VideoContainer data={[ const wrapper = shallow(<VideoContainer data={[
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
]}/>); ]}/>);
expect(wrapper.find('Preview')).toHaveLength(12); expect(wrapper.find('Preview')).toHaveLength(16);
}); });
it('inserts tiles correctly if not enough available', () => { it('inserts tiles correctly if not enough available', () => {

View File

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import './css/index.css';
import App from './App'; import App from './App';
ReactDOM.render( ReactDOM.render(

View File

@ -1,16 +1,21 @@
import React from "react"; import React from "react";
import SideBar from "../../elements/SideBar/SideBar"; import SideBar, {SideBarTitle} from "../../elements/SideBar/SideBar";
import Tag from "../../elements/Tag/Tag"; import Tag from "../../elements/Tag/Tag";
import videocontainerstyle from "../../elements/VideoContainer/VideoContainer.module.css"
import {TagPreview} from "../../elements/Preview/Preview"; import {TagPreview} from "../../elements/Preview/Preview";
import NewTagPopup from "../../elements/NewTagPopup/NewTagPopup"; import NewTagPopup from "../../elements/NewTagPopup/NewTagPopup";
import PageTitle, {Line} from "../../elements/PageTitle/PageTitle";
import VideoContainer from "../../elements/VideoContainer/VideoContainer";
/**
* Component for Category Page
* Contains a Tag Overview and loads specific Tag videos in VideoContainer
*/
class CategoryPage extends React.Component { class CategoryPage extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.props = props;
this.state = { this.state = {
loadedtags: [], loadedtags: [],
selected: null selected: null
@ -18,33 +23,72 @@ class CategoryPage extends React.Component {
} }
componentDidMount() { componentDidMount() {
// check if predefined category is set
if (this.props.category) {
this.fetchVideoData(this.props.category);
} else {
this.loadTags(); this.loadTags();
} }
}
/**
* render the Title and SideBar component for the Category page
* @returns {JSX.Element} corresponding jsx element for Title and Sidebar
*/
renderSideBarATitle() {
return (
<>
<PageTitle
title='Categories'
subtitle={!this.state.selected ? this.state.loadedtags.length + " different Tags" : this.state.selected}/>
<SideBar>
<SideBarTitle>Default Tags:</SideBarTitle>
<Tag viewbinding={{
changeRootElement: (e) => {
this.loadTag(e.props.category)
}
}}>All</Tag>
<Tag viewbinding={{
changeRootElement: (e) => {
this.loadTag(e.props.category)
}
}}>FullHd</Tag>
<Tag viewbinding={{
changeRootElement: (e) => {
this.loadTag(e.props.category)
}
}}>LowQuality</Tag>
<Tag viewbinding={{
changeRootElement: (e) => {
this.loadTag(e.props.category)
}
}}>HD</Tag>
<Line/>
<button data-testid='btnaddtag' className='btn btn-success' onClick={() => {
this.setState({popupvisible: true})
}}>Add a new Tag!
</button>
</SideBar></>
);
}
render() { render() {
return ( return (
<> <>
<div className='pageheader'> {this.renderSideBarATitle()}
<span className='pageheadertitle'>Categories</span>
<span
className='pageheadersubtitle'>{!this.state.selected ? this.state.loadedtags.length + " different Tags" : this.state.selected}</span>
<hr/>
</div>
<SideBar>
<div className='sidebartitle'>Default Tags:</div>
<Tag>All</Tag>
<Tag>FullHd</Tag>
<Tag>LowQuality</Tag>
<Tag>HD</Tag>
<hr/>
<button data-testid='btnaddtag' className='btn btn-success' onClick={() => {
this.setState({popupvisible: true})
}}>Add a new Tag!
</button>
</SideBar>
{!this.state.selected ? {this.state.selected ?
(<div className='maincontent'> <>
{this.videodata ?
<VideoContainer
data={this.videodata}
viewbinding={this.props.viewbinding}/> : null}
<button data-testid='backbtn' className="btn btn-success"
onClick={this.loadCategoryPageDefault}>Back
</button>
</> :
<div className={videocontainerstyle.maincontent}>
{this.state.loadedtags ? {this.state.loadedtags ?
this.state.loadedtags.map((m) => ( this.state.loadedtags.map((m) => (
<TagPreview <TagPreview
@ -52,16 +96,10 @@ class CategoryPage extends React.Component {
name={m.tag_name} name={m.tag_name}
tag_id={m.tag_id} tag_id={m.tag_id}
viewbinding={this.props.viewbinding} viewbinding={this.props.viewbinding}
categorybinding={this.setPage}/> categorybinding={this.loadTag}/>
)) : )) :
"loading"} "loading"}
</div>) : </div>
<>
{this.selectionelements}
<button data-testid='backbtn' className="btn btn-success"
onClick={this.loadCategoryPageDefault}>Back
</button>
</>
} }
{this.state.popupvisible ? {this.state.popupvisible ?
@ -77,14 +115,45 @@ class CategoryPage extends React.Component {
); );
} }
setPage = (element, tagname) => { /**
this.selectionelements = element; * load a specific tag into a new previewcontainer
* @param tagname
this.setState({selected: tagname}); */
loadTag = (tagname) => {
this.fetchVideoData(tagname);
}; };
/**
* fetch data for a specific tag from backend
* @param tag tagname
*/
fetchVideoData(tag) {
console.log(tag);
const updateRequest = new FormData();
updateRequest.append('action', 'getMovies');
updateRequest.append('tag', tag);
console.log("fetching data");
// fetch all videos available
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
this.videodata = result;
this.setState({selected: null}); // needed to trigger the state reload correctly
this.setState({selected: tag});
}))
.catch(() => {
console.log("no connection to backend");
});
}
/**
* go back to the default category overview
*/
loadCategoryPageDefault = () => { loadCategoryPageDefault = () => {
this.setState({selected: null}); this.setState({selected: null});
this.loadTags();
}; };
/** /**
@ -95,7 +164,7 @@ class CategoryPage extends React.Component {
updateRequest.append('action', 'getAllTags'); updateRequest.append('action', 'getAllTags');
// fetch all videos available // fetch all videos available
fetch('/api/Tags.php', {method: 'POST', body: updateRequest}) fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json() .then((response) => response.json()
.then((result) => { .then((result) => {
this.setState({loadedtags: result}); this.setState({loadedtags: result});

View File

@ -1,15 +1,6 @@
import {shallow, mount} from "enzyme"; import {mount, shallow} from "enzyme";
import React from "react"; import React from "react";
import CategoryPage from "./CategoryPage"; import CategoryPage from "./CategoryPage";
import VideoContainer from "../../elements/VideoContainer/VideoContainer";
function prepareFetchApi(response) {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
describe('<CategoryPage/>', function () { describe('<CategoryPage/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -18,7 +9,7 @@ describe('<CategoryPage/>', function () {
}); });
it('test tag fetch call', done => { it('test tag fetch call', done => {
global.fetch = prepareFetchApi(["first", "second"]); global.fetch = global.prepareFetchApi(["first", "second"]);
const wrapper = shallow(<CategoryPage/>); const wrapper = shallow(<CategoryPage/>);
@ -34,14 +25,14 @@ describe('<CategoryPage/>', function () {
}); });
it('test errored fetch call', done => { it('test errored fetch call', done => {
global.fetch = prepareFetchApi({}); global.fetch = global.prepareFetchApi({});
let message; let message;
global.console.log = jest.fn((m) => { global.console.log = jest.fn((m) => {
message = m; message = m;
}); });
const wrapper = shallow(<CategoryPage/>); shallow(<CategoryPage/>);
expect(global.fetch).toHaveBeenCalledTimes(1); expect(global.fetch).toHaveBeenCalledTimes(1);
@ -68,7 +59,7 @@ describe('<CategoryPage/>', function () {
}); });
it('test setpage callback', done => { it('test setpage callback', done => {
global.fetch = prepareFetchApi([{}, {}]); global.fetch = global.prepareFetchApi([{}, {}]);
const wrapper = mount(<CategoryPage/>); const wrapper = mount(<CategoryPage/>);
@ -86,8 +77,6 @@ describe('<CategoryPage/>', function () {
process.nextTick(() => { process.nextTick(() => {
// expect callback to have loaded correct tag // expect callback to have loaded correct tag
expect(wrapper.state().selected).toBe("testname"); expect(wrapper.state().selected).toBe("testname");
// expect to receive a videocontainer with simulated data
expect(wrapper.instance().selectionelements).toMatchObject(<VideoContainer data={[{}, {}]}/>);
global.fetch.mockClear(); global.fetch.mockClear();
done(); done();
@ -104,4 +93,30 @@ describe('<CategoryPage/>', function () {
wrapper.find('[data-testid="backbtn"]').simulate("click"); wrapper.find('[data-testid="backbtn"]').simulate("click");
expect(wrapper.state().selected).toBeNull(); expect(wrapper.state().selected).toBeNull();
}); });
it('load categorypage with predefined tag', function () {
const func = jest.fn();
CategoryPage.prototype.fetchVideoData = func;
shallow(<CategoryPage category="fullhd"/>);
expect(func).toBeCalledTimes(1);
});
it('test sidebar tag clicks', function () {
const func = jest.fn();
const wrapper = mount(<CategoryPage category="fullhd"/>);
wrapper.instance().loadTag = func;
console.log(wrapper.debug());
expect(func).toBeCalledTimes(0);
wrapper.find("SideBar").find("Tag").forEach(e => {
e.simulate("click");
})
expect(func).toBeCalledTimes(4);
});
}); });

View File

@ -1,12 +1,18 @@
import React from "react"; import React from "react";
import SideBar from "../../elements/SideBar/SideBar"; import SideBar, {SideBarItem, SideBarTitle} from "../../elements/SideBar/SideBar";
import Tag from "../../elements/Tag/Tag"; import Tag from "../../elements/Tag/Tag";
import VideoContainer from "../../elements/VideoContainer/VideoContainer"; import VideoContainer from "../../elements/VideoContainer/VideoContainer";
import "./HomePage.css" import style from "./HomePage.module.css"
import "../../css/DefaultPage.css" import PageTitle, {Line} from "../../elements/PageTitle/PageTitle";
/**
* The home page component showing on the initial pageload
*/
class HomePage extends React.Component { class HomePage extends React.Component {
/** keyword variable needed temporary store search keyword */
keyword = "";
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -24,9 +30,6 @@ class HomePage extends React.Component {
}; };
} }
/** keyword variable needed temporary store search keyword */
keyword = "";
componentDidMount() { componentDidMount() {
// initial get of all videos // initial get of all videos
this.fetchVideoData("all"); this.fetchVideoData("all");
@ -47,7 +50,7 @@ class HomePage extends React.Component {
console.log("fetching data"); console.log("fetching data");
// fetch all videos available // fetch all videos available
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json() .then((response) => response.json()
.then((result) => { .then((result) => {
this.setState({ this.setState({
@ -71,7 +74,7 @@ class HomePage extends React.Component {
updateRequest.append('action', 'getStartData'); updateRequest.append('action', 'getStartData');
// fetch all videos available // fetch all videos available
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json() .then((response) => response.json()
.then((result) => { .then((result) => {
this.setState({ this.setState({
@ -102,7 +105,7 @@ class HomePage extends React.Component {
updateRequest.append('keyword', keyword); updateRequest.append('keyword', keyword);
// fetch all videos available // fetch all videos available
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json() .then((response) => response.json()
.then((result) => { .then((result) => {
this.setState({ this.setState({
@ -120,11 +123,11 @@ class HomePage extends React.Component {
render() { render() {
return ( return (
<div> <>
<div className='pageheader'> <PageTitle
<span className='pageheadertitle'>Home Page</span> title='Home Page'
<span className='pageheadersubtitle'>{this.state.tag} Videos - {this.state.selectionnr}</span> subtitle={this.state.tag + " Videos - " + this.state.selectionnr}>
<form className="form-inline searchform" onSubmit={(e) => { <form className={"form-inline " + style.searchform} onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
this.searchVideos(this.keyword); this.searchVideos(this.keyword);
}}> }}>
@ -135,49 +138,32 @@ class HomePage extends React.Component {
}}/> }}/>
<button data-testid='searchbtnsubmit' className="btn btn-success" type="submit">Search</button> <button data-testid='searchbtnsubmit' className="btn btn-success" type="submit">Search</button>
</form> </form>
<hr/> </PageTitle>
</div>
<SideBar> <SideBar>
<div className='sidebartitle'>Infos:</div> <SideBarTitle>Infos:</SideBarTitle>
<hr/> <Line/>
<div className='sidebarinfo'><b>{this.state.sideinfo.videonr}</b> Videos Total!</div> <SideBarItem><b>{this.state.sideinfo.videonr}</b> Videos Total!</SideBarItem>
<div className='sidebarinfo'><b>{this.state.sideinfo.fullhdvideonr}</b> FULL-HD Videos!</div> <SideBarItem><b>{this.state.sideinfo.fullhdvideonr}</b> FULL-HD Videos!</SideBarItem>
<div className='sidebarinfo'><b>{this.state.sideinfo.hdvideonr}</b> HD Videos!</div> <SideBarItem><b>{this.state.sideinfo.hdvideonr}</b> HD Videos!</SideBarItem>
<div className='sidebarinfo'><b>{this.state.sideinfo.sdvideonr}</b> SD Videos!</div> <SideBarItem><b>{this.state.sideinfo.sdvideonr}</b> SD Videos!</SideBarItem>
<div className='sidebarinfo'><b>{this.state.sideinfo.tagnr}</b> different Tags!</div> <SideBarItem><b>{this.state.sideinfo.tagnr}</b> different Tags!</SideBarItem>
<hr/> <Line/>
<div className='sidebartitle'>Default Tags:</div> <SideBarTitle>Default Tags:</SideBarTitle>
<Tag onClick={() => { <Tag viewbinding={this.props.viewbinding}>All</Tag>
this.setState({tag: "All"}); <Tag viewbinding={this.props.viewbinding}>FullHd</Tag>
this.fetchVideoData("all"); <Tag viewbinding={this.props.viewbinding}>LowQuality</Tag>
}}>All <Tag viewbinding={this.props.viewbinding}>HD</Tag>
</Tag>
<Tag onClick={() => {
this.setState({tag: "Full HD"});
this.fetchVideoData("fullhd");
}}>FullHd
</Tag>
<Tag onClick={() => {
this.setState({tag: "Low Quality"});
this.fetchVideoData("lowquality");
}}>LowQuality
</Tag>
<Tag onClick={() => {
this.setState({tag: "HD"});
this.fetchVideoData("hd");
}}>HD
</Tag>
</SideBar> </SideBar>
{this.state.data.length !== 0 ? {this.state.data.length !== 0 ?
<VideoContainer <VideoContainer
data={this.state.data} data={this.state.data}
viewbinding={this.props.viewbinding}/> : viewbinding={this.props.viewbinding}/> :
<div>No Data found!</div>} <div>No Data found!</div>}
<div className='rightinfo'> <div className={style.rightinfo}>
</div> </div>
</div> </>
); );
} }
} }

View File

@ -1,14 +1,9 @@
.maincontent {
float: left;
width: 70%;
}
.rightinfo { .rightinfo {
float: left; float: left;
width: 10%; width: 10%;
} }
.searchform { .searchform {
margin-top: 25px;
float: right; float: right;
margin-top: 25px;
} }

View File

@ -3,37 +3,12 @@ import React from "react";
import HomePage from "./HomePage"; import HomePage from "./HomePage";
import VideoContainer from "../../elements/VideoContainer/VideoContainer"; import VideoContainer from "../../elements/VideoContainer/VideoContainer";
function prepareFetchApi(response) {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
function prepareFailingFetchApi() {
const mockFetchPromise = Promise.reject("myreason");
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
describe('<HomePage/>', function () { describe('<HomePage/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
const wrapper = shallow(<HomePage/>); const wrapper = shallow(<HomePage/>);
wrapper.unmount(); wrapper.unmount();
}); });
it('ckeck default tag click events', function () {
const wrapper = shallow(<HomePage/>);
global.fetch = prepareFetchApi({});
expect(global.fetch).toBeCalledTimes(0);
// click every tag button
wrapper.find("Tag").map((i) => {
i.simulate("click");
});
expect(global.fetch).toBeCalledTimes(4);
});
it('test data insertion', function () { it('test data insertion', function () {
const wrapper = shallow(<HomePage/>); const wrapper = shallow(<HomePage/>);
@ -52,18 +27,18 @@ describe('<HomePage/>', function () {
it('test title and nr insertions', function () { it('test title and nr insertions', function () {
const wrapper = shallow(<HomePage/>); const wrapper = shallow(<HomePage/>);
expect(wrapper.find(".pageheadersubtitle").text()).toBe("All Videos - 0"); expect(wrapper.find("PageTitle").props().subtitle).toBe("All Videos - 0");
wrapper.setState({ wrapper.setState({
tag: "testtag", tag: "testtag",
selectionnr: 42 selectionnr: 42
}); });
expect(wrapper.find(".pageheadersubtitle").text()).toBe("testtag Videos - 42"); expect(wrapper.find("PageTitle").props().subtitle).toBe("testtag Videos - 42");
}); });
it('test search field', done => { it('test search field', done => {
global.fetch = prepareFetchApi([{}, {}]); global.fetch = global.prepareFetchApi([{}, {}]);
const wrapper = shallow(<HomePage/>); const wrapper = shallow(<HomePage/>);
@ -80,7 +55,7 @@ describe('<HomePage/>', function () {
}); });
it('test form submit', done => { it('test form submit', done => {
global.fetch = prepareFetchApi([{}, {}]); global.fetch = global.prepareFetchApi([{}, {}]);
const wrapper = shallow(<HomePage/>); const wrapper = shallow(<HomePage/>);
@ -100,14 +75,14 @@ describe('<HomePage/>', function () {
it('test no backend connection behaviour', done => { it('test no backend connection behaviour', done => {
// this test assumes a console.log within every connection fail // this test assumes a console.log within every connection fail
global.fetch = prepareFailingFetchApi(); global.fetch = global.prepareFailingFetchApi();
let count = 0; let count = 0;
global.console.log = jest.fn((m) => { global.console.log = jest.fn(() => {
count++; count++;
}); });
const wrapper = shallow(<HomePage/>); shallow(<HomePage/>);
process.nextTick(() => { process.nextTick(() => {
// state to be set correctly with response // state to be set correctly with response

View File

@ -1,29 +1,18 @@
import React from "react"; import React from "react";
import "./Player.css" import style from "./Player.module.css"
import {PlyrComponent} from 'plyr-react'; import {PlyrComponent} from 'plyr-react';
import SideBar from "../../elements/SideBar/SideBar"; import SideBar, {SideBarItem, SideBarTitle} from "../../elements/SideBar/SideBar";
import Tag from "../../elements/Tag/Tag"; import Tag from "../../elements/Tag/Tag";
import AddTagPopup from "../../elements/AddTagPopup/AddTagPopup"; import AddTagPopup from "../../elements/AddTagPopup/AddTagPopup";
import PageTitle, {Line} from "../../elements/PageTitle/PageTitle";
/**
* Player page loads when a video is selected to play and handles the video view
* and actions such as tag adding and liking
*/
class Player extends React.Component { class Player extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
sources: null,
movie_id: null,
movie_name: null,
likes: null,
quality: null,
length: null,
tags: [],
popupvisible: false
};
this.props = props;
}
options = { options = {
controls: [ controls: [
'play-large', // The large play button in the center 'play-large', // The large play button in the center
@ -41,70 +30,165 @@ class Player extends React.Component {
] ]
}; };
constructor(props, context) {
super(props, context);
this.state = {
sources: null,
movie_id: null,
movie_name: null,
likes: null,
quality: null,
length: null,
tags: [],
suggesttag: [],
popupvisible: false
};
this.quickAddTag = this.quickAddTag.bind(this);
}
componentDidMount() { componentDidMount() {
this.fetchMovieData(); this.fetchMovieData();
} }
/**
* quick add callback to add tag to db and change gui correctly
* @param tag_id id of tag to add
* @param tag_name name of tag to add
*/
quickAddTag(tag_id, tag_name) {
const updateRequest = new FormData();
updateRequest.append('action', 'addTag');
updateRequest.append('id', tag_id);
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.result !== "success") {
console.error("error occured while writing to db -- todo error handling");
console.error(result.result);
} else {
// update tags if successful
let array = [...this.state.suggesttag]; // make a separate copy of the array
const index = array.map(function (e) {
return e.tag_id;
}).indexOf(tag_id);
// check if tag is available in quickadds
if (index !== -1) {
array.splice(index, 1);
this.setState({
tags: [...this.state.tags, {tag_name: tag_name}],
suggesttag: array
});
} else {
this.setState({
tags: [...this.state.tags, {tag_name: tag_name}]
});
}
}
}));
}
/**
* handle the popovers generated according to state changes
* @returns {JSX.Element}
*/
handlePopOvers() {
return (
<>
{this.state.popupvisible ?
<AddTagPopup show={this.state.popupvisible}
onHide={() => {
this.setState({popupvisible: false});
}}
submit={this.quickAddTag}
movie_id={this.state.movie_id}/> :
null
}
</>
);
}
/**
* generate sidebar with all items
*/
assembleSideBar() {
return (
<SideBar>
<SideBarTitle>Infos:</SideBarTitle>
<Line/>
<SideBarItem><b>{this.state.likes}</b> Likes!</SideBarItem>
{this.state.quality !== 0 ?
<SideBarItem><b>{this.state.quality}p</b> Quality!</SideBarItem> : null}
{this.state.length !== 0 ?
<SideBarItem><b>{Math.round(this.state.length / 60)}</b> Minutes of
length!</SideBarItem> : null}
<Line/>
<SideBarTitle>Tags:</SideBarTitle>
{this.state.tags.map((m) => (
<Tag
key={m.tag_name}
viewbinding={this.props.viewbinding}>{m.tag_name}</Tag>
))}
<Line/>
<SideBarTitle>Tag Quickadd:</SideBarTitle>
{this.state.suggesttag.map((m) => (
<Tag
key={m.tag_name}
onclick={() => {
this.quickAddTag(m.tag_id, m.tag_name);
}}>
{m.tag_name}
</Tag>
))}
</SideBar>
);
}
render() { render() {
return ( return (
<div id='videocontainer'> <div id='videocontainer'>
<div className='pageheader'> <PageTitle
<span className='pageheadertitle'>Watch</span> title='Watch'
<span className='pageheadersubtitle'>{this.state.movie_name}</span> subtitle={this.state.movie_name}/>
<hr/>
</div>
<SideBar>
<div className='sidebartitle'>Infos:</div>
<hr/>
<div className='sidebarinfo'><b>{this.state.likes}</b> Likes!</div>
{this.state.quality !== 0 ?
<div className='sidebarinfo'><b>{this.state.quality}p</b> Quality!
</div> : null}
{this.state.length !== 0 ?
<div className='sidebarinfo'><b>{Math.round(this.state.length / 60)}</b> Minutes of length!
</div> : null}
<hr/>
<div className='sidebartitle'>Tags:</div>
{this.state.tags.map((m) => (
<Tag key={m.tag_name}>{m.tag_name}</Tag>
))}
</SideBar>
<div className="videowrapper"> {this.assembleSideBar()}
<div className={style.videowrapper}>
{/* video component is added here */} {/* video component is added here */}
{this.state.sources ? <PlyrComponent {this.state.sources ? <PlyrComponent
className='myvideo' className='myvideo'
sources={this.state.sources} sources={this.state.sources}
options={this.options}/> : options={this.options}/> :
<div>not loaded yet</div>} <div>not loaded yet</div>}
<div className='videoactions'> <div className={style.videoactions}>
<button className='btn btn-primary' onClick={() => this.likebtn()}>Like this Video!</button> <button className='btn btn-primary' onClick={() => this.likebtn()}>Like this Video!</button>
<button className='btn btn-info' onClick={() => this.setState({popupvisible: true})}>Give this <button className='btn btn-info' onClick={() => this.setState({popupvisible: true})}>Give this Video a Tag</button>
Video a Tag <button className='btn btn-danger' onClick={() =>{this.deleteVideo()}}>Delete Video</button>
</button> </div>
{this.state.popupvisible ? </div>
<AddTagPopup show={this.state.popupvisible} <button className={style.closebutton} onClick={() => this.closebtn()}>Close</button>
onHide={() => { {
this.setState({popupvisible: false}); // handle the popovers switched on and off according to state changes
this.fetchMovieData(); this.handlePopOvers()
}}
movie_id={this.state.movie_id}/> :
null
} }
</div>
</div>
<button className="closebutton" onClick={() => this.closebtn()}>Close</button>
</div> </div>
); );
} }
/**
* fetch all the required infos of a video from backend
*/
fetchMovieData() { fetchMovieData() {
const updateRequest = new FormData(); const updateRequest = new FormData();
updateRequest.append('action', 'loadVideo'); updateRequest.append('action', 'loadVideo');
updateRequest.append('movieid', this.props.movie_id); updateRequest.append('movieid', this.props.movie_id);
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()) .then((response) => response.json())
.then((result) => { .then((result) => {
this.setState({ this.setState({
@ -124,32 +208,62 @@ class Player extends React.Component {
likes: result.likes, likes: result.likes,
quality: result.quality, quality: result.quality,
length: result.length, length: result.length,
tags: result.tags tags: result.tags,
suggesttag: result.suggesttag
}); });
console.log(this.state);
}); });
} }
/* Click Listener */ /**
* click handler for the like btn
*/
likebtn() { likebtn() {
const updateRequest = new FormData(); const updateRequest = new FormData();
updateRequest.append('action', 'addLike'); updateRequest.append('action', 'addLike');
updateRequest.append('movieid', this.props.movie_id); updateRequest.append('movieid', this.props.movie_id);
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json() .then((response) => response.json()
.then((result) => { .then((result) => {
if (result.result === "success") { if (result.result === "success") {
this.fetchMovieData(); // likes +1 --> avoid reload of all data
this.setState({likes: this.state.likes + 1})
} else { } else {
console.log("an error occured while liking"); console.error("an error occured while liking");
console.log(result); console.error(result);
} }
})); }));
} }
/**
* closebtn click handler
* calls callback to viewbinding to show previous page agains
*/
closebtn() { closebtn() {
this.props.viewbinding.hideVideo(); this.props.viewbinding.returnToLastElement();
}
/**
* delete the current video and return to last page
*/
deleteVideo() {
const updateRequest = new FormData();
updateRequest.append('action', 'deleteVideo');
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.result === "success") {
// return to last element if successful
this.props.viewbinding.returnToLastElement();
} else {
console.error("an error occured while liking");
console.error(result);
}
}));
} }
} }

View File

@ -1,30 +1,20 @@
.closebutton { .closebutton {
color: white; background-color: #FF0000;
border: none; border: none;
border-radius: 10px; border-radius: 10px;
padding: 5px 15px 5px 15px;
background-color: #FF0000;
margin-top: 25px;
margin-left: 25px;
}
.likefield {
margin-top: 15px;
margin-left: 15px;
margin-right: 15px;
height: 30px;
background-color: #9e5353;
border-radius: 10px;
text-align: center;
color: white; color: white;
margin-left: 25px;
margin-top: 25px;
padding: 5px 15px 5px 15px;
} }
.videowrapper { .videowrapper {
margin-left: 20px;
display: block; display: block;
float: left; float: left;
width: 60%; margin-left: 20px;
margin-top: 25px; margin-top: 25px;
margin-top: 20px;
width: 60%;
} }
.videoactions { .videoactions {

View File

@ -2,14 +2,6 @@ import {shallow} from "enzyme";
import React from "react"; import React from "react";
import Player from "./Player"; import Player from "./Player";
function prepareFetchApi(response) {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
describe('<Player/>', function () { describe('<Player/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
const wrapper = shallow(<Player/>); const wrapper = shallow(<Player/>);
@ -31,17 +23,8 @@ describe('<Player/>', function () {
expect(wrapper.find("r")).toHaveLength(1); expect(wrapper.find("r")).toHaveLength(1);
}); });
it('likebtn click', done => { function simulateLikeButtonClick() {
global.fetch = prepareFetchApi({result: 'success'});
const func = jest.fn();
const wrapper = shallow(<Player/>); const wrapper = shallow(<Player/>);
wrapper.setProps({
onHide: () => {
func()
}
});
// initial fetch for getting movie data // initial fetch for getting movie data
expect(global.fetch).toHaveBeenCalledTimes(1); expect(global.fetch).toHaveBeenCalledTimes(1);
@ -49,9 +32,19 @@ describe('<Player/>', function () {
// fetch for liking // fetch for liking
expect(global.fetch).toHaveBeenCalledTimes(2); expect(global.fetch).toHaveBeenCalledTimes(2);
return wrapper;
}
it('likebtn click', done => {
global.fetch = global.prepareFetchApi({result: 'success'});
global.console.error = jest.fn();
simulateLikeButtonClick();
process.nextTick(() => { process.nextTick(() => {
// refetch is called so fetch called 3 times expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledTimes(3); expect(global.console.error).toHaveBeenCalledTimes(0);
global.fetch.mockClear(); global.fetch.mockClear();
done(); done();
@ -59,25 +52,15 @@ describe('<Player/>', function () {
}); });
it('errored likebtn click', done => { it('errored likebtn click', done => {
global.fetch = prepareFetchApi({result: 'nosuccess'}); global.fetch = global.prepareFetchApi({result: 'nosuccess'});
const func = jest.fn(); global.console.error = jest.fn();
const wrapper = shallow(<Player/>); simulateLikeButtonClick();
wrapper.setProps({
onHide: () => {
func()
}
});
// initial fetch for getting movie data
expect(global.fetch).toHaveBeenCalledTimes(1);
wrapper.find('.videoactions').find("button").first().simulate('click');
// fetch for liking
expect(global.fetch).toHaveBeenCalledTimes(2);
process.nextTick(() => { process.nextTick(() => {
// refetch is called so fetch called 3 times // refetch is called so fetch called 3 times
expect(global.fetch).toHaveBeenCalledTimes(2); expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.console.error).toHaveBeenCalledTimes(2);
global.fetch.mockClear(); global.fetch.mockClear();
done(); done();
@ -86,20 +69,38 @@ describe('<Player/>', function () {
it('show tag popup', function () { it('show tag popup', function () {
const wrapper = shallow(<Player/>); const wrapper = shallow(<Player/>);
expect(wrapper.find("AddTagPopup")).toHaveLength(0); expect(wrapper.find("AddTagPopup")).toHaveLength(0);
wrapper.find('.videoactions').find("button").last().simulate('click'); // todo dynamic button find without index
wrapper.find('.videoactions').find("button").at(1).simulate('click');
// addtagpopup should be showing now // addtagpopup should be showing now
expect(wrapper.find("AddTagPopup")).toHaveLength(1); expect(wrapper.find("AddTagPopup")).toHaveLength(1);
}); });
it('test delete button', done => {
const wrapper = shallow(<Player viewbinding={{
returnToLastElement: jest.fn()
}}/>);
global.fetch = prepareFetchApi({result: "success"});
wrapper.find('.videoactions').find("button").at(2).simulate('click');
process.nextTick(() => {
// refetch is called so fetch called 3 times
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.viewbinding.returnToLastElement).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
it('hide click ', function () { it('hide click ', function () {
const wrapper = shallow(<Player/>); const wrapper = shallow(<Player/>);
const func = jest.fn(); const func = jest.fn();
wrapper.setProps({ wrapper.setProps({
viewbinding: { viewbinding: {
hideVideo: () => { returnToLastElement: () => {
func() func()
} }
} }
@ -125,4 +126,59 @@ describe('<Player/>', function () {
expect(wrapper.find("Tag")).toHaveLength(2); expect(wrapper.find("Tag")).toHaveLength(2);
}); });
it('inserts tag quickadd correctly', function () {
generatetag();
});
it('test click of quickadd tag btn', done => {
const wrapper = generatetag();
global.fetch = prepareFetchApi({result: 'success'});
// render tag subcomponent
const tag = wrapper.find("Tag").first().dive();
tag.simulate('click');
process.nextTick(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
it('test failing quickadd', done => {
const wrapper = generatetag();
global.fetch = prepareFetchApi({result: 'nonsuccess'});
global.console.error = jest.fn();
// render tag subcomponent
const tag = wrapper.find("Tag").first().dive();
tag.simulate('click');
process.nextTick(() => {
expect(global.console.error).toHaveBeenCalledTimes(2);
global.fetch.mockClear();
done();
});
});
function generatetag() {
const wrapper = shallow(<Player/>);
expect(wrapper.find("Tag")).toHaveLength(0);
wrapper.setState({
suggesttag: [
{tag_name: 'first', tag_id: 1},
]
});
expect(wrapper.find("Tag")).toHaveLength(1);
return wrapper;
}
}); });

View File

@ -1,9 +1,13 @@
import React from "react"; import React from "react";
import Preview from "../../elements/Preview/Preview"; import style from "./RandomPage.module.css"
import "./RandomPage.css" import SideBar, {SideBarTitle} from "../../elements/SideBar/SideBar";
import SideBar from "../../elements/SideBar/SideBar";
import Tag from "../../elements/Tag/Tag"; import Tag from "../../elements/Tag/Tag";
import PageTitle from "../../elements/PageTitle/PageTitle";
import VideoContainer from "../../elements/VideoContainer/VideoContainer";
/**
* Randompage shuffles random viedeopreviews and provides a shuffle btn
*/
class RandomPage extends React.Component { class RandomPage extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -21,48 +25,57 @@ class RandomPage extends React.Component {
render() { render() {
return ( return (
<div> <div>
<div className='pageheader'> <PageTitle
<span className='pageheadertitle'>Random Videos</span> title='Random Videos'
<span className='pageheadersubtitle'>4pc</span> subtitle='4pc'/>
<hr/>
</div>
<SideBar> <SideBar>
<div className='sidebartitle'>Visible Tags:</div> <SideBarTitle>Visible Tags:</SideBarTitle>
{this.state.tags.map((m) => ( {this.state.tags.map((m) => (
<Tag>{m.tag_name}</Tag> <Tag
key={m.tag_name}
viewbinding={this.props.viewbinding}>{m.tag_name}</Tag>
))} ))}
</SideBar> </SideBar>
<div className='maincontent'> {this.state.videos.length !== 0 ?
{this.state.videos.map(elem => ( <VideoContainer
<Preview data={this.state.videos}
key={elem.movie_id} viewbinding={this.props.viewbinding}>
name={elem.movie_name} <div className={style.Shufflebutton}>
movie_id={elem.movie_id} <button onClick={() => this.shuffleclick()} className={style.btnshuffle}>Shuffle</button>
viewbinding={this.props.viewbinding}/>
))}
<div className='Shufflebutton'>
<button onClick={() => this.shuffleclick()} className='btnshuffle'>Shuffle</button>
</div>
</div> </div>
</VideoContainer>
:
<div>No Data found!</div>}
</div> </div>
); );
} }
/**
* click handler for shuffle btn
*/
shuffleclick() { shuffleclick() {
this.loadShuffledvideos(4); this.loadShuffledvideos(4);
} }
/**
* load random videos from backend
* @param nr number of videos to load
*/
loadShuffledvideos(nr) { loadShuffledvideos(nr) {
const updateRequest = new FormData(); const updateRequest = new FormData();
updateRequest.append('action', 'getRandomMovies'); updateRequest.append('action', 'getRandomMovies');
updateRequest.append('number', nr); updateRequest.append('number', nr);
// fetch all videos available // fetch all videos available
fetch('/api/videoload.php', {method: 'POST', body: updateRequest}) fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json() .then((response) => response.json()
.then((result) => { .then((result) => {
console.log(result); console.log(result);
this.setState({videos: []}); // needed to trigger rerender of main videoview
this.setState({ this.setState({
videos: result.rows, videos: result.rows,
tags: result.tags tags: result.tags

View File

@ -1,19 +1,19 @@
.Shufflebutton { .Shufflebutton {
width: 100%;
align-content: center; align-content: center;
width: 100%;
} }
.btnshuffle { .btnshuffle {
background-color: #39a945; background-color: #39a945;
color: white;
margin-top: 20px;
margin-left: 45%;
border: none; border: none;
border-radius: 10px; border-radius: 10px;
padding: 15px 25px 15px 25px; color: white;
font-weight: bold;
font-size: larger; font-size: larger;
font-weight: bold;
margin-left: 45%;
margin-top: 20px;
padding: 15px 25px 15px 25px;
} }
.btnshuffle:focus { .btnshuffle:focus {

View File

@ -2,14 +2,6 @@ import {shallow} from "enzyme";
import React from "react"; import React from "react";
import RandomPage from "./RandomPage"; import RandomPage from "./RandomPage";
function prepareFetchApi(response) {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
describe('<RandomPage/>', function () { describe('<RandomPage/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
const wrapper = shallow(<RandomPage/>); const wrapper = shallow(<RandomPage/>);
@ -17,18 +9,25 @@ describe('<RandomPage/>', function () {
}); });
it('test shuffleload fetch', function () { it('test shuffleload fetch', function () {
global.fetch = prepareFetchApi({}); global.fetch = global.prepareFetchApi({});
const wrapper = shallow(<RandomPage/>); shallow(<RandomPage/>);
expect(global.fetch).toBeCalledTimes(1); expect(global.fetch).toBeCalledTimes(1);
}); });
it('btnshuffle click test', function () { it('btnshuffle click test', function () {
global.fetch = prepareFetchApi({}); global.fetch = global.prepareFetchApi({});
const wrapper = shallow(<RandomPage/>); const wrapper = shallow(<RandomPage/>);
// simulate at least one existing element
wrapper.setState({
videos: [
{}
]
});
wrapper.find(".btnshuffle").simulate("click"); wrapper.find(".btnshuffle").simulate("click");
expect(global.fetch).toBeCalledTimes(2); expect(global.fetch).toBeCalledTimes(2);

View File

@ -0,0 +1,161 @@
import React from "react";
import {Button, Col, Form} from "react-bootstrap";
import style from "./GeneralSettings.module.css"
import GlobalInfos from "../../GlobalInfos";
/**
* Component for Generalsettings tag on Settingspage
* handles general settings of mediacenter which concerns to all pages
*/
class GeneralSettings extends React.Component {
constructor(props) {
super(props);
this.state = {
passwordsupport: false,
tmdbsupport: null,
videopath: "",
tvshowpath: "",
mediacentername: "",
password: ""
};
}
componentDidMount() {
this.loadSettings();
}
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<>
<div className={style.GeneralForm + ' ' + themeStyle.subtextcolor}>
<Form data-testid='mainformsettings' onSubmit={(e) => {
e.preventDefault();
this.saveSettings();
}}>
<Form.Row>
<Form.Group as={Col} data-testid="videpathform">
<Form.Label>Video Path</Form.Label>
<Form.Control type="text" placeholder="/var/www/html/video" value={this.state.videopath}
onChange={(ee) => this.setState({videopath: ee.target.value})}/>
</Form.Group>
<Form.Group as={Col} data-testid="tvshowpath">
<Form.Label>TV Show Path</Form.Label>
<Form.Control type='text' placeholder="/var/www/html/tvshow"
value={this.state.tvshowpath}
onChange={(e) => this.setState({tvshowpath: e.target.value})}/>
</Form.Group>
</Form.Row>
<Form.Check
type="switch"
id="custom-switch"
data-testid='passwordswitch'
label="Enable Password support"
checked={this.state.passwordsupport}
onChange={() => {
this.setState({passwordsupport: !this.state.passwordsupport})
}}
/>
{this.state.passwordsupport ?
<Form.Group data-testid="passwordfield">
<Form.Label>Password</Form.Label>
<Form.Control type="password" placeholder="**********" value={this.state.password}
onChange={(e) => this.setState({password: e.target.value})}/>
</Form.Group> : null
}
<Form.Check
type="switch"
id="custom-switch-2"
data-testid='tmdb-switch'
label="Enable TMDB video grabbing support"
checked={this.state.tmdbsupport}
onChange={() => {
this.setState({tmdbsupport: !this.state.tmdbsupport})
}}
/>
<Form.Check
type="switch"
id="custom-switch-3"
data-testid='darktheme-switch'
label="Enable Dark-Theme"
checked={GlobalInfos.isDarkTheme()}
onChange={() => {
GlobalInfos.enableDarkTheme(!GlobalInfos.isDarkTheme());
this.forceUpdate();
// todo initiate rerender
}}
/>
<Form.Group className={style.mediacenternameform} data-testid="nameform">
<Form.Label>The name of the Mediacenter</Form.Label>
<Form.Control type="text" placeholder="Mediacentername" value={this.state.mediacentername}
onChange={(e) => this.setState({mediacentername: e.target.value})}/>
</Form.Group>
<Button variant="primary" type="submit">
Submit
</Button>
</Form>
</div>
</>
);
}
/**
* inital load of already specified settings from backend
*/
loadSettings() {
const updateRequest = new FormData();
updateRequest.append('action', 'loadGeneralSettings');
fetch('/api/settings.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
console.log(result);
this.setState({
videopath: result.video_path,
tvshowpath: result.episode_path,
mediacentername: result.mediacenter_name,
password: result.password,
passwordsupport: result.passwordEnabled,
tmdbsupport: result.TMDB_grabbing
});
}));
}
/**
* save the selected and typed settings to the backend
*/
saveSettings() {
const updateRequest = new FormData();
updateRequest.append('action', 'saveGeneralSettings');
updateRequest.append('password', this.state.passwordsupport ? this.state.password : "-1");
updateRequest.append('videopath', this.state.videopath);
updateRequest.append('tvshowpath', this.state.tvshowpath);
updateRequest.append('mediacentername', this.state.mediacentername);
updateRequest.append("tmdbsupport", this.state.tmdbsupport);
updateRequest.append("darkmodeenabled", GlobalInfos.isDarkTheme());
fetch('/api/settings.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.success) {
console.log("successfully saved settings");
// todo 2020-07-10: popup success
} else {
console.log("failed to save settings");
// todo 2020-07-10: popup error
}
}));
}
}
export default GeneralSettings;

View File

@ -0,0 +1,8 @@
.GeneralForm {
width: 60%;
}
.mediacenternameform {
margin-top: 25px;
width: 40%;
}

View File

@ -0,0 +1,112 @@
import {shallow} from "enzyme";
import React from "react";
import GeneralSettings from "./GeneralSettings";
import GlobalInfos from "../../GlobalInfos";
describe('<GeneralSettings/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<GeneralSettings/>);
wrapper.unmount();
});
it('test password hide/show switchbutton', function () {
const wrapper = shallow(<GeneralSettings/>);
expect(wrapper.find("[data-testid='passwordfield']")).toHaveLength(0);
wrapper.find("FormCheck").findWhere(it => it.props().label === "Enable Password support").simulate("change");
expect(wrapper.find("[data-testid='passwordfield']")).toHaveLength(1);
});
it('test theme switchbutton', function () {
const wrapper = shallow(<GeneralSettings/>);
GlobalInfos.enableDarkTheme(false);
expect(GlobalInfos.isDarkTheme()).toBe(false);
wrapper.find("[data-testid='darktheme-switch']").simulate("change");
expect(GlobalInfos.isDarkTheme()).toBe(true);
});
it('test savesettings', done => {
const wrapper = shallow(<GeneralSettings/>);
global.fetch = global.prepareFetchApi({success: true});
expect(global.fetch).toBeCalledTimes(0);
const fakeEvent = {preventDefault: () => console.log('preventDefault')};
wrapper.find("[data-testid='mainformsettings']").simulate("submit", fakeEvent);
expect(global.fetch).toBeCalledTimes(1);
process.nextTick(() => {
// todo 2020-07-13: test popup of error success here
global.fetch.mockClear();
done();
});
});
it('test failing savesettings', done => {
const wrapper = shallow(<GeneralSettings/>);
global.fetch = global.prepareFetchApi({success: false});
expect(global.fetch).toBeCalledTimes(0);
const fakeEvent = {preventDefault: () => console.log('preventDefault')};
wrapper.find("[data-testid='mainformsettings']").simulate("submit", fakeEvent);
expect(global.fetch).toBeCalledTimes(1);
process.nextTick(() => {
// todo 2020-07-13: test error popup here!
global.fetch.mockClear();
done();
});
});
it('test videopath change event', function () {
const wrapper = shallow(<GeneralSettings/>);
expect(wrapper.state().videopath).not.toBe("test");
const event = {target: {name: "pollName", value: "test"}};
wrapper.find("[data-testid='videpathform']").find("FormControl").simulate("change", event);
expect(wrapper.state().videopath).toBe("test");
});
it('test tvshowpath change event', function () {
const wrapper = shallow(<GeneralSettings/>);
const event = {target: {name: "pollName", value: "test"}};
expect(wrapper.state().tvshowpath).not.toBe("test");
wrapper.find("[data-testid='tvshowpath']").find("FormControl").simulate("change", event);
expect(wrapper.state().tvshowpath).toBe("test");
});
it('test mediacentername-form change event', function () {
const wrapper = shallow(<GeneralSettings/>);
const event = {target: {name: "pollName", value: "test"}};
expect(wrapper.state().mediacentername).not.toBe("test");
wrapper.find("[data-testid='nameform']").find("FormControl").simulate("change", event);
expect(wrapper.state().mediacentername).toBe("test");
});
it('test password-form change event', function () {
const wrapper = shallow(<GeneralSettings/>);
wrapper.setState({passwordsupport: true});
const event = {target: {name: "pollName", value: "test"}};
expect(wrapper.state().password).not.toBe("test");
wrapper.find("[data-testid='passwordfield']").find("FormControl").simulate("change", event);
expect(wrapper.state().password).toBe("test");
});
it('test tmdbsupport change event', function () {
const wrapper = shallow(<GeneralSettings/>);
wrapper.setState({tmdbsupport: true});
expect(wrapper.state().tmdbsupport).toBe(true);
wrapper.find("[data-testid='tmdb-switch']").simulate("change");
expect(wrapper.state().tmdbsupport).toBe(false);
});
});

View File

@ -0,0 +1,99 @@
import React from "react";
import style from "./MovieSettings.module.css"
/**
* Component for MovieSettings on Settingspage
* handles settings concerning to movies in general
*/
class MovieSettings extends React.Component {
constructor(props) {
super(props);
this.state = {
text: [],
startbtnDisabled: false
};
}
componentDidMount() {
if (this.myinterval) {
clearInterval(this.myinterval);
}
this.myinterval = setInterval(this.updateStatus, 1000);
}
componentWillUnmount() {
clearInterval(this.myinterval);
}
render() {
return (
<>
<button disabled={this.state.startbtnDisabled} className='reindexbtn btn btn-success' onClick={() => {
this.startReindex()
}}>Reindex Movies
</button>
<div className={style.indextextarea}>{this.state.text.map(m => (
<div className='textarea-element'>{m}</div>
))}</div>
</>
);
}
/**
* starts the reindex process of the videos in the specified folder
*/
startReindex() {
// clear output text before start
this.setState({text: []});
this.setState({startbtnDisabled: true});
console.log("starting");
const updateRequest = new FormData();
// fetch all videos available
fetch('/api/extractvideopreviews.php', {method: 'POST', body: updateRequest})
.then((response) => response.text()
.then((result) => {
// todo 2020-07-4: some kind of return finished handler
console.log("returned");
}))
.catch(() => {
console.log("no connection to backend");
});
if (this.myinterval) {
clearInterval(this.myinterval);
}
this.myinterval = setInterval(this.updateStatus, 1000);
}
/**
* This interval function reloads the current status of reindexing from backend
*/
updateStatus = () => {
const updateRequest = new FormData();
fetch('/api/extractionData.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.contentAvailable === true) {
console.log(result);
// todo 2020-07-4: scroll to bottom of div here
this.setState({
// insert a string for each line
text: [...result.message.split("\n"),
...this.state.text]
});
} else {
// clear refresh interval if no content available
clearInterval(this.myinterval);
this.setState({startbtnDisabled: false});
}
}))
.catch(() => {
console.log("no connection to backend");
});
};
}
export default MovieSettings;

View File

@ -0,0 +1,13 @@
.indextextarea {
background-color: #c2c2c2;
border-radius: 5px;
margin-top: 15px;
max-height: 300px;
min-height: 100px;
overflow-x: auto;
overflow-y: scroll;
padding: 10px;
width: 50%;
}

View File

@ -0,0 +1,54 @@
import {shallow} from "enzyme";
import React from "react";
import MovieSettings from "./MovieSettings";
describe('<MovieSettings/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<MovieSettings/>);
wrapper.unmount();
});
it('received text renders into dom', function () {
const wrapper = shallow(<MovieSettings/>);
wrapper.setState({
text: [
"firstline",
"secline"
]
});
expect(wrapper.find(".indextextarea").find(".textarea-element")).toHaveLength(2);
});
it('test simulate reindex', function () {
global.fetch = global.prepareFetchApi({});
const wrapper = shallow(<MovieSettings/>);
wrapper.find(".reindexbtn").simulate("click");
// initial send of reindex request to server
expect(global.fetch).toBeCalledTimes(1);
});
it('content available received and in state', done => {
global.fetch = global.prepareFetchApi({
contentAvailable: true,
message: "firstline\nsecondline"
});
const wrapper = shallow(<MovieSettings/>);
wrapper.instance().updateStatus();
process.nextTick(() => {
expect(wrapper.state()).toMatchObject({
text: [
"firstline",
"secondline"
]
});
global.fetch.mockClear();
done();
});
});
});

View File

@ -1,84 +1,61 @@
import React from "react"; import React from "react";
import "../../css/DefaultPage.css" import MovieSettings from "./MovieSettings";
import GeneralSettings from "./GeneralSettings";
import style from "./SettingsPage.module.css"
import GlobalInfos from "../../GlobalInfos";
/**
* The Settingspage handles all kinds of settings for the mediacenter
* and is basically a wrapper for child-tabs
*/
class SettingsPage extends React.Component { class SettingsPage extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
text: [] currentpage: "general"
}; };
} }
updateStatus = () => { /**
const updateRequest = new FormData(); * load the selected tab
fetch('/api/extractionData.php', {method: 'POST', body: updateRequest}) * @returns {JSX.Element|string} the jsx element of the selected tab
.then((response) => response.json() */
.then((result) => { getContent() {
if (result.contentAvailable === true) { switch (this.state.currentpage) {
console.log(result); case "general":
this.setState({ return <GeneralSettings/>;
text: [...result.message.split("\n"), case "movies":
...this.state.text] return <MovieSettings/>;
}); case "tv":
} else { return <span/>; // todo this page
clearInterval(this.myinterval); default:
return "unknown button clicked";
} }
}))
.catch(() => {
console.log("no connection to backend");
});
};
componentDidMount() {
if (this.myinterval) {
clearInterval(this.myinterval);
}
this.myinterval = setInterval(this.updateStatus, 1000);
}
componentWillUnmount() {
clearInterval(this.myinterval);
} }
render() { render() {
const themestyle = GlobalInfos.getThemeStyle();
return ( return (
<div> <div>
<div className='pageheader'> <div className={style.SettingsSidebar + ' ' + themestyle.secbackground}>
<span className='pageheadertitle'>Settings Page</span> <div className={style.SettingsSidebarTitle + ' ' + themestyle.lighttextcolor}>Settings</div>
<span className='pageheadersubtitle'>todo</span> <div onClick={() => this.setState({currentpage: "general"})}
<hr/> className={style.SettingSidebarElement}>General
</div>
<div onClick={() => this.setState({currentpage: "movies"})}
className={style.SettingSidebarElement}>Movies
</div>
<div onClick={() => this.setState({currentpage: "tv"})}
className={style.SettingSidebarElement}>TV Shows
</div>
</div>
<div className={style.SettingsContent}>
{this.getContent()}
</div> </div>
<button className='reindexbtn btn btn-success' onClick={() => {
this.startReindex()
}}>Reindex Movies
</button>
<div className='indextextarea'>{this.state.text.map(m => (
<div className='textarea-element'>{m}</div>
))}</div>
</div> </div>
); );
} }
startReindex() {
console.log("starting");
const updateRequest = new FormData();
// fetch all videos available
fetch('/api/extractvideopreviews.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
console.log("returned");
}))
.catch(() => {
console.log("no connection to backend");
});
if (this.myinterval) {
clearInterval(this.myinterval);
}
this.myinterval = setInterval(this.updateStatus, 1000);
console.log("sent");
}
} }
export default SettingsPage; export default SettingsPage;

View File

@ -0,0 +1,41 @@
.SettingsSidebar {
border-bottom-right-radius: 10px;
border-top-right-radius: 10px;
float: left;
min-height: calc(100vh - 62px);
min-width: 110px;
padding-top: 20px;
width: 10%;
}
.SettingsSidebarTitle {
font-size: larger;
font-weight: bold;
margin-bottom: 25px;
text-align: center;
text-transform: uppercase;
}
.SettingsContent {
float: left;
padding-left: 30px;
padding-top: 30px;
width: 80%;
}
.SettingSidebarElement {
background-color: #919fd9;
border-radius: 7px;
font-weight: bold;
margin: 10px 5px 5px;
padding: 5px;
text-align: center;
}
.SettingSidebarElement:hover {
background-color: #7d8dd4;
box-shadow: #7d8dd4 0 0 0 5px;
cursor: pointer;
font-weight: bolder;
transition: all 300ms;
}

View File

@ -2,40 +2,39 @@ import {shallow} from "enzyme";
import React from "react"; import React from "react";
import SettingsPage from "./SettingsPage"; import SettingsPage from "./SettingsPage";
function prepareFetchApi(response) {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
describe('<RandomPage/>', function () { describe('<RandomPage/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
const wrapper = shallow(<SettingsPage/>); const wrapper = shallow(<SettingsPage/>);
wrapper.unmount(); wrapper.unmount();
}); });
it('received text renders into dom', function () { it('simulate topic clicka', function () {
const wrapper = shallow(<SettingsPage/>); const wrapper = shallow(<SettingsPage/>);
wrapper.setState({ simulateSideBarClick("General", wrapper);
text: [ expect(wrapper.state().currentpage).toBe("general");
"firstline", expect(wrapper.find(".SettingsContent").find("GeneralSettings")).toHaveLength(1);
"secline"
] simulateSideBarClick("Movies", wrapper);
expect(wrapper.state().currentpage).toBe("movies");
expect(wrapper.find(".SettingsContent").find("MovieSettings")).toHaveLength(1);
simulateSideBarClick("TV Shows", wrapper);
expect(wrapper.state().currentpage).toBe("tv");
expect(wrapper.find(".SettingsContent").find("span")).toHaveLength(1);
}); });
expect(wrapper.find(".indextextarea").find(".textarea-element")).toHaveLength(2); function simulateSideBarClick(name, wrapper) {
}); wrapper.find(".SettingSidebarElement").findWhere(it =>
it.text() === name &&
it.type() === "div").simulate("click");
}
it('test simulate reindex', function () { it('simulate unknown topic', function () {
global.fetch = prepareFetchApi({});
const wrapper = shallow(<SettingsPage/>); const wrapper = shallow(<SettingsPage/>);
wrapper.setState({currentpage: "unknown"});
wrapper.find(".reindexbtn").simulate("click"); expect(wrapper.find(".SettingsContent").text()).toBe("unknown button clicked");
// initial send of reindex request to server
expect(global.fetch).toBeCalledTimes(1);
}); });
}); });

View File

@ -8,3 +8,25 @@ import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16'; import Adapter from 'enzyme-adapter-react-16';
configure({adapter: new Adapter()}); configure({adapter: new Adapter()});
/**
* prepares fetch api for a virtual test call
* @param response the response fetch should give you back
* @returns {jest.Mock<any, any>} a jest mock function simulating a fetch
*/
global.prepareFetchApi = (response) => {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
/**
* prepares a failing virtual fetch api call
* @returns {jest.Mock<any, any>} a jest moch function simulating a failing fetch call
*/
global.prepareFailingFetchApi = () => {
const mockFetchPromise = Promise.reject("myreason");
return (jest.fn().mockImplementation(() => mockFetchPromise));
}