131 Commits
v0.1.1 ... PHP

Author SHA1 Message Date
2967aee16d Merge branch 'keypropwarnings' into 'master'
fix some required key warnings

See merge request lukas/openmediacenter!34
2021-02-06 22:18:48 +00:00
3c32356227 improve insertion of reindex messages -- prevent empty string lines and add key prop to every new line 2021-02-06 22:18:48 +00:00
46aeda73d8 Merge branch 'api_call_enum' into 'master'
add API node type instead of always use string to define api node

See merge request lukas/openmediacenter!33
2021-01-29 22:15:17 +00:00
b6ab359a37 add API node type instead of always use string to define api node 2021-01-29 22:15:17 +00:00
e825f94028 fix #54 2021-01-29 21:39:19 +01:00
fbf286c09c Merge branch 'enter_popup_submit' into 'master'
Shortkeys

See merge request lukas/openmediacenter!31
2021-01-28 19:50:26 +00:00
fa21ba4f25 bind enter events as a submit to Popups
add s as key to submit a reshuffle in shuffled videos
2021-01-28 19:50:26 +00:00
f06da8044f Merge branch 'tat_remove' into 'master'
make tags deleteable

See merge request lukas/openmediacenter!30
2021-01-26 19:14:57 +00:00
ac126f6a9d make tags deleteable
seperate sidebar for each different category page
2021-01-26 19:14:57 +00:00
d8aee9e5b7 reformat code 2021-01-24 16:43:38 +01:00
fe1a00d1af Merge branch 'full_typescript' into 'master'
typescriptify settings components

See merge request lukas/openmediacenter!29
2021-01-22 21:05:22 +00:00
6c7cc11038 typescriptify Popupbase
focus textfield on filterclick
2021-01-22 21:05:21 +00:00
66eb72d7fb drop support of electron - bad practise to mix website with electron app 2021-01-19 17:27:39 +01:00
0c3f9204bc keep persistent api - databse namings in tests (fixes tests) 2021-01-06 17:53:57 +01:00
4ca590639d add a filter option to the addactor popup page 2021-01-03 21:58:55 +01:00
3e9cb7410f use / as default homepage in package.json 2021-01-01 19:49:18 +01:00
272c88ab50 force index.html - independent of subpath 2020-12-30 18:12:41 +01:00
c4227faf14 Merge branch 'reactrouter' into 'master'
use react router to route through pages

See merge request lukas/openmediacenter!28
2020-12-29 19:39:30 +00:00
80a04456e6 fix some tests
fix merge issues
2020-12-29 19:39:30 +00:00
e11f021efe we need a prepare state because some jobs havent an node env.
use node14 anyways
2020-12-22 18:55:45 +01:00
60b14b3c0d add package-lock.json to project 2020-12-22 18:26:50 +01:00
350471363e new way to initialize npm packages 2020-12-22 17:42:03 +01:00
bce4ec49da no colored cicd -- no coverage 2020-12-21 19:59:30 +00:00
4b664d0ae6 correct naming of gitlabci needs 2020-12-18 20:19:32 +01:00
e075a87750 rename gitlab jobs and use only one testing job 2020-12-18 19:55:47 +01:00
866d8f72b4 Merge branch 'electronapp' into 'master'
Electronapp

See merge request lukas/openmediacenter!27
2020-12-17 20:53:22 +00:00
7d696122fa build electron app
implement new fetch api calls
use typescript
2020-12-17 20:53:22 +00:00
c049aa345c Merge branch 'actors_page' into 'master'
Actor Page and Tiles on Player page

Closes #50

See merge request lukas/openmediacenter!23
2020-12-11 18:23:14 +00:00
c5d231d9f2 add style for actor tiles
render actors got from backend
backend test code to get actors
2020-12-11 18:23:13 +00:00
707c54e5f5 Merge branch 'react17plyr3' into 'master'
React17plyr3

See merge request lukas/openmediacenter!26
2020-11-27 22:43:12 +00:00
2d8bb06852 update all but react-scripts (wrong execution of unit tests so they are failing)
fix failing tests because of missing implementation of mount() in enzyme for react 17
2020-11-27 22:43:12 +00:00
2ae00f8af0 release version 0.1.2 2020-11-20 22:00:09 +01:00
9e53b217b0 Merge branch 'spacebetweenvideoactions' into 'master'
improved coloring of videoaction buttons on player page

See merge request lukas/openmediacenter!25
2020-11-20 20:56:51 +00:00
7fbc7e98c9 improved coloring of videoaction buttons on player page 2020-11-20 21:31:07 +01:00
598137ed63 Merge branch 'versionInSettingsPage' into 'master'
Version in settings page

See merge request lukas/openmediacenter!24
2020-11-13 22:52:38 +00:00
bbb36606e1 print settings info as a footer on bottom of settings page
fix display of scrollbar on settingspage
2020-11-13 22:52:38 +00:00
fd692c7c02 run code quality in dind runner 2020-11-12 09:32:46 +00:00
0eb9167deb fix typo and fullfill naming conventions 2020-11-04 23:52:22 +01:00
362484f92c make scandir independent of calldirectory of reindex script
remove log after postinst reindex
2020-10-27 12:48:26 +01:00
a7790b289a use correct magic var in php entry scripts to hold script folder structure independent of call folder
fix php socket location in postinst script
2020-10-26 20:59:48 +01:00
635e9f009c fix right path of extraction script 2020-10-26 20:35:30 +01:00
83227da6da Merge branch 'noredirectonhomepage' into 'master'
no redirect to categorypage - reindex after deb install - no multiply add of same tag possible

See merge request lukas/openmediacenter!21
2020-10-25 18:48:23 +00:00
777cc2a712 reformattings
no redirect on tagclick on homepage
no multiple add of same tag possible
2020-10-25 18:48:23 +00:00
812c45cb13 non interactive reinstall and delete package within remote server 2020-10-22 20:06:37 +02:00
92d6ae0e7e cleanup temp folder correctly and rename correct deb package 2020-10-22 19:52:34 +02:00
5eed1849bc improve creation of symlink for php socket and install dep package for example container 2020-10-22 19:36:29 +02:00
8f7ba53c39 Merge branch 'nonblockingReindex' into 'master'
reindexing in background and gravity cleanup

Closes #29

See merge request lukas/openmediacenter!20
2020-10-21 19:14:46 +00:00
5b6b1e3473 improved reindexing to reindex in background
new button to cleanup gravity
2020-10-21 19:14:45 +00:00
ee073c0dab Merge branch 'settingsinfobar' into 'master'
add new InfoHeader on settingspage with general infos about database and items

See merge request lukas/openmediacenter!18
2020-10-19 21:12:07 +00:00
28f3d6db70 use flexbox to wrap settings tiles correctly
new icon for different tags
ignore test files in codeclimate test
2020-10-19 21:12:07 +00:00
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
c4098a8d3d added some tests for main App.js 2020-06-18 19:42:42 +02:00
043750170b added tests for homepage 2020-06-17 19:51:02 +02:00
23a1fdca75 fixing preventdefault function not found error 2020-06-15 22:02:34 +02:00
72c68d8a7c fixing page reload on search request 2020-06-14 22:01:13 +02:00
133 changed files with 25657 additions and 2203 deletions

65
.codeclimate.yml Normal file
View File

@ -0,0 +1,65 @@
version: "2"
plugins:
csslint:
enabled: true
coffeelint:
enabled: true
duplication:
enabled: true
config:
languages:
- ruby
- javascript
- python
- php
eslint:
enabled: true
channel: __ESLINT_CHANNEL__
fixme:
enabled: true
rubocop:
enabled: true
exclude_patterns:
- config/
- db/
- dist/
- features/
- "**/node_modules/"
- script/
- "**/spec/"
- "**/test/"
- "**/tests/"
- Tests/
- "**/vendor/"
- "**/*_test.go"
- "**/*.d.ts"
- "**/*.min.js"
- "**/*.min.css"
- "**/__tests__/"
- "**/__mocks__/"
- "**/*.test.js"
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,87 @@
image: node:latest image: node:14
stages: stages:
- test - prepare
- coverage
- build - build
- test
- packaging
- deploy
cache: cache:
key: ${CI_COMMIT_REF_SLUG}
paths: paths:
- .npm/
- node_modules/ - node_modules/
test: include:
- template: Code-Quality.gitlab-ci.yml
variables:
SAST_DISABLE_DIND: "true"
Node_dependencies:
stage: prepare
script:
- npm ci --cache .npm --prefer-offline
Minimize:
stage: build
script:
- npm run build
artifacts:
expire_in: 7 days
paths:
- build/
needs: ["Node_dependencies"]
Frontend_Tests:
stage: test stage: test
script: script:
- npm install - npm run test
- CI=true npm run test
artifacts: artifacts:
reports: reports:
junit: junit:
- ./junit.xml - ./junit.xml
needs: ["Node_dependencies"]
coverage: code_quality:
stage: coverage tags:
- dind
Debian_Server:
stage: packaging
image: debian
script: script:
- CI=true npm run coverage - vers=$(grep -Po '"version":.*?[^\\]",' package.json | grep -Po '[0-9]+\.[0-9]+\.[0-9]+') # parse the version out of package .json
- 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
- 'echo "Version: ${vers}" >> ./OpenMediaCenter/DEBIAN/control'
- chmod -R 0775 *
- dpkg-deb --build OpenMediaCenter
- mv OpenMediaCenter.deb OpenMediaCenter-${vers}_amd64.deb
artifacts: artifacts:
reports: paths:
cobertura: - deb/OpenMediaCenter-*.deb
- ./coverage/cobertura-coverage.xml needs: ["Minimize"]
build: Test_Server:
stage: build stage: deploy
image: luki42/alpineopenssh:latest
needs:
- Frontend_Tests
- Debian_Server
only:
- master
script: script:
- npm run build - 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 deb/OpenMediaCenter-*.deb root@192.168.0.42:/tmp/
- ssh root@192.168.0.42 "DEBIAN_FRONTEND=noninteractive apt-get --reinstall -y -qq install /tmp/OpenMediaCenter-*.deb && rm /tmp/OpenMediaCenter-*.deb"
allow_failure: true

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;
}
}

5
api/actor.php Normal file
View File

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

View File

@ -1,18 +0,0 @@
<?php
$return = new stdClass();
if (file_exists("/tmp/output.log")) {
$out = file_get_contents("/tmp/output.log");
// clear log file
file_put_contents("/tmp/output.log", "");
$return->message = $out;
$return->contentAvailable = true;
if (substr($out, -strlen("-42")) == "-42") {
unlink("/tmp/output.log");
}
} else {
$return->contentAvailable = false;
}
echo json_encode($return);

View File

@ -1,265 +1,18 @@
<?php <?php
require 'Database.php'; require_once __DIR__ . '/src/Database.php';
require 'TMDBMovie.php'; require_once __DIR__ . '/src/TMDBMovie.php';
require_once __DIR__ . '/src/SSettings.php';
require_once __DIR__ . '/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 = __DIR__ . "/../" . $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 __DIR__ . '/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;
} }
@ -55,7 +51,7 @@ class Database
* get name of current active database * get name of current active database
* @return string name * @return string name
*/ */
public function getDatabaseName(){ public function getDatabaseName() {
return $this->dbname; return $this->dbname;
} }
} }

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 'Database.php';
require_once 'TMDBMovie.php';
require_once '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++;
}
}
/**
* 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();
}
/**
* 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");
}
}
}
/**
* 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,75 @@
<?php
require_once __DIR__ . '/../SSettings.php';
require_once 'RequestBase.php';
class Actor extends RequestBase {
function initHandlers() {
$this->databaseAdds();
$this->databaseRequests();
}
function databaseAdds() {
$this->addActionHandler("createActor", function () {
// skip tag create if already existing
$actorname = $_POST["actorname"];
$query = "INSERT IGNORE INTO actors (name) VALUES ('$actorname')";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
$this->addActionHandler("addActorToVideo", function () {
// skip tag create if already existing
$actorid = $_POST["actorid"];
$videoid = $_POST["videoid"];
$query = "INSERT IGNORE INTO actors_videos (actor_id, video_id) VALUES ($actorid,$videoid)";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
}
function databaseRequests() {
$this->addActionHandler("getAllActors", function () {
// query the actors corresponding to video
$query = "SELECT * FROM actors";
$result = $this->conn->query($query);
$this->commitMessage(json_encode(mysqli_fetch_all($result, MYSQLI_ASSOC)));
});
$this->addActionHandler("getActorsOfVideo", function () {
// query the actors corresponding to video
$video_id = $_POST["videoid"];
$query = "SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=$video_id";
$result = $this->conn->query($query);
$this->commitMessage(json_encode(mysqli_fetch_all($result, MYSQLI_ASSOC)));
});
$this->addActionHandler("getActorInfo", function (){
$actorid = $_POST["actorid"];
$query = "SELECT movie_id, movie_name FROM actors_videos
JOIN videos v on v.movie_id = actors_videos.video_id
WHERE actors_videos.actor_id=$actorid";
$result = $this->conn->query($query);
$actorinfo = $this->conn->query("SELECT name, thumbnail, actor_id FROM actors WHERE actor_id=$actorid");
$reply = array("videos" => mysqli_fetch_all($result, MYSQLI_ASSOC), "info" => mysqli_fetch_assoc($actorinfo));
$this->commitMessage(json_encode($reply));
});
}
}

View File

@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../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 {
$this->commitMessage('{"data": "error"}');
}
}
/**
* add the action handlers in this abstract method
*/
abstract function initHandlers();
/**
* Send response message and exit script
* @param $message string the response message
*/
function commitMessage($message) {
echo $message;
exit(0);
}
}

View File

@ -0,0 +1,160 @@
<?php
require_once 'RequestBase.php';
require_once __DIR__ . '/../VideoParser.php';
/**
* Class Settings
* Backend for the Settings page
*/
class Settings extends RequestBase {
function initHandlers() {
$this->getFromDB();
$this->saveToDB();
$this->reIndexHandling();
}
/**
* 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 settings and infotile values
$query = "
SELECT (
SELECT COUNT(*)
FROM videos
) AS videonr,
(
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS Size
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '" . Database::getInstance()->getDatabaseName() . "'
GROUP BY table_schema
) AS dbsize,
(
SELECT COUNT(*)
FROM tags
) AS difftagnr,
(
SELECT COUNT(*)
FROM video_tags
) AS tagsadded,
settings.*
FROM settings
LIMIT 1
";
$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('{"result": "success"}');
} else {
$this->commitMessage('{"result": "success"}');
}
});
}
/**
* methods for handling reindexing and cleanup of db gravity
*/
private function reIndexHandling() {
$this->addActionHandler("startReindex", function () {
$indexrunning = false;
if (file_exists("/tmp/output.log")) {
$out = file_get_contents("/tmp/output.log");
if (substr($out, -strlen("-42")) == "-42") {
unlink("/tmp/output.log");
} else {
$indexrunning = true;
}
}
if (!$indexrunning) {
// start extraction of video previews in background
$cmd = 'php extractvideopreviews.php';
exec(sprintf("%s > %s 2>&1 & echo $! >> %s", $cmd, '/dev/zero', '/tmp/openmediacenterpid'));
$this->commitMessage('{"result": "success"}');
} else {
$this->commitMessage('{"result": "success"}');
}
});
$this->addActionHandler("cleanupGravity", function () {
$vp = new VideoParser();
$vp->cleanUpGravity();
});
$this->addActionHandler("getStatusMessage", function () {
$return = new stdClass();
if (file_exists("/tmp/output.log")) {
$out = file_get_contents("/tmp/output.log");
// clear log file
file_put_contents("/tmp/output.log", "");
$return->message = $out;
$return->contentAvailable = true;
if (substr($out, -strlen("-42")) == "-42") {
unlink("/tmp/output.log");
}
} else {
$return->contentAvailable = false;
}
$this->commitMessage(json_encode($return));
});
}
}

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

@ -0,0 +1,101 @@
<?php
require_once 'RequestBase.php';
/**
* Class Tags
* backend to handle Tag database interactions
*/
class Tags extends RequestBase {
function initHandlers() {
$this->addToDB();
$this->getFromDB();
$this->delete();
}
private function addToDB() {
/**
* creates a new tag
* query requirements:
* * tagname -- name of the new tag
*/
$this->addActionHandler("createTag", function () {
// skip tag create if already existing
$query = "INSERT IGNORE 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'];
// skip tag add if already assigned
$query = "INSERT IGNORE 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 . '"}');
}
});
}
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 delete() {
/**
* delete a Tag with specified id
*/
$this->addActionHandler("deleteTag", function () {
$tag_id = $_POST['tagId'];
$force = $_POST['force'];
// delete key constraints first
if ($force === "true") {
$query = "DELETE FROM video_tags WHERE tag_id=$tag_id";
if ($this->conn->query($query) !== TRUE) {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
}
$query = "DELETE FROM tags WHERE tag_id=$tag_id";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
// check if error is a constraint error
if (preg_match('/^.*a foreign key constraint fails.*$/i', $this->conn->error)) {
$this->commitMessage('{"result":"not empty tag"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->eror . '"}');
}
}
});
}
}

245
api/src/handlers/Video.php Executable file
View File

@ -0,0 +1,245 @@
<?php
require_once __DIR__ . '/../SSettings.php';
require_once 'RequestBase.php';
/**
* Class Video
* backend for all interactions with videoloads and receiving of video infos
*/
class Video extends RequestBase {
private $videopath;
public function __construct() {
$settings = new SSettings();
// load video path from settings
$this->videopath = $settings->getVideoPath();
}
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'])) {
$tag = $_POST['tag'];
// if not all tags allowed filter for specific one
if (strtolower($_POST['tag']) != "all") {
$query = "SELECT movie_id,movie_name FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_id = '$tag'
ORDER BY likes DESC, create_date, movie_name";
}
}
$result = $this->conn->query($query);
$rows = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r);
}
$this->commitMessage(json_encode($rows));
});
$this->addActionHandler("getRandomMovies", function () {
$return = new stdClass();
$query = "SELECT movie_id,movie_name FROM videos ORDER BY RAND() LIMIT " . $_POST['number'];
$result = $this->conn->query($query);
$return->rows = array();
// get tags of random videos
$ids = [];
while ($r = mysqli_fetch_assoc($result)) {
array_push($return->rows, $r);
array_push($ids, "video_tags.video_id=" . $r['movie_id']);
}
$idstring = implode(" OR ", $ids);
$return->tags = array();
$query = "SELECT t.tag_name,t.tag_id FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE $idstring
GROUP BY t.tag_id";
$result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
array_push($return->tags, array('tag_name' => $r['tag_name'], 'tag_id' => $r['tag_id']));
}
$this->commitMessage(json_encode($return));
});
$this->addActionHandler("getSearchKeyWord", function () {
$search = $_POST['keyword'];
$query = "SELECT movie_id,movie_name FROM videos
WHERE movie_name LIKE '%$search%'
ORDER BY likes DESC, create_date DESC, movie_name";
$result = $this->conn->query($query);
$rows = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r);
}
$this->commitMessage(json_encode($rows));
});
}
/**
* function to handle stuff for loading specific videos and startdata
*/
private function loadVideos() {
$this->addActionHandler("loadVideo", function () {
$video_id = $_POST['movieid'];
// todo join with actor db and add actors of movieid
$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();
$arr = array();
if ($row["poster"] == null) {
$arr["thumbnail"] = $row["thumbnail"];
} else {
$arr["thumbnail"] = $row["poster"];
}
$arr["movie_id"] = $row["movie_id"];
$arr["movie_name"] = $row["movie_name"];
// todo drop video url from db -- maybe one with and one without extension
// 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["length"] = $row["length"];
// load tags of this video
$arr['tags'] = array();
$query = "SELECT t.tag_name, t.tag_id FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE video_tags.video_id=$video_id
GROUP BY t.tag_id";
$result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
array_push($arr['tags'], $r);
}
// get the random predict tags
$arr['suggesttag'] = array();
// select 5 random tags which are not selected for current video
$query = "SELECT * FROM tags
WHERE tag_id NOT IN (
SELECT video_tags.tag_id FROM video_tags
WHERE video_id=$video_id)
ORDER BY rand()
LIMIT 5";
$result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
array_push($arr['suggesttag'], $r);
}
// query the actors corresponding to video
$query = "SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=$video_id";
$result = $this->conn->query($query);
$arr['actors'] = mysqli_fetch_all($result, MYSQLI_ASSOC);
$this->commitMessage(json_encode($arr));
});
$this->addActionHandler("readThumbnail", function () {
$query = "SELECT thumbnail FROM videos WHERE movie_id='" . $_POST['movieid'] . "'";
$result = $this->conn->query($query);
$row = $result->fetch_assoc();
$this->commitMessage($row["thumbnail"]);
});
$this->addActionHandler("getStartData", function () {
$query = "SELECT COUNT(*) as nr FROM videos";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr = array();
$arr['total'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['tagged'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='hd'";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['hd'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='fullhd'";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['fullhd'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='lowquality'";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['sd'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM tags";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['tags'] = $r['nr'];
$this->commitMessage(json_encode($arr));
});
}
/**
* function to handle api handlers for stuff to add to video or database
*/
private function addToVideo() {
$this->addActionHandler("addLike", function () {
$movieid = $_POST['movieid'];
$query = "update videos set likes = likes + 1 where movie_id = '$movieid'";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
$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 __DIR__ . '/src/handlers/Tags.php';
$tags = new Tags();
$tags->handleAction();

5
api/video.php Normal file
View File

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

View File

@ -1,232 +0,0 @@
<?php
require 'Database.php';
$conn = Database::getInstance()->getConnection();
//$_POST['action'] = "getRandomMovies";$_POST['number'] =6;
if (isset($_POST['action'])) {
$action = $_POST['action'];
switch ($action) {
case "getMovies":
$query = "SELECT movie_id,movie_name FROM videos ORDER BY likes DESC, create_date DESC, movie_name ASC";
if (isset($_POST['tag'])) {
$tag = $_POST['tag'];
if ($_POST['tag'] != "all") {
$query = "SELECT movie_id,movie_name FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name = '$tag'
ORDER BY likes DESC, create_date ASC, movie_name ASC";
}
}
$result = $conn->query($query);
$rows = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r);
}
echo(json_encode($rows));
break;
case "getRandomMovies":
$return = new stdClass();
$query = "SELECT movie_id,movie_name FROM videos ORDER BY RAND() LIMIT " . $_POST['number'];
$result = $conn->query($query);
$return->rows = array();
// get tags of random videos
$ids = [];
while ($r = mysqli_fetch_assoc($result)) {
array_push($return->rows, $r);
array_push($ids, "video_tags.video_id=" . $r['movie_id']);
}
$idstring = implode(" OR ", $ids);
$return->tags = array();
$query = "SELECT t.tag_name FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE $idstring
GROUP BY t.tag_name";
$result = $conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
array_push($return->tags, $r);
}
echo(json_encode($return));
break;
case "getSearchKeyWord":
$search = $_POST['keyword'];
$query = "SELECT movie_id,movie_name FROM videos
WHERE movie_name LIKE '%$search%'
ORDER BY likes DESC, create_date DESC, movie_name ASC";
$result = $conn->query($query);
$rows = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r);
}
echo(json_encode($rows));
break;
case "loadVideo":
$query = "SELECT movie_name,movie_id,movie_url,thumbnail,poster,likes,quality,length FROM videos WHERE movie_id='" . $_POST['movieid'] . "'";
$result = $conn->query($query);
$row = $result->fetch_assoc();
$arr = array();
if ($row["poster"] == null) {
$arr["thumbnail"] = $row["thumbnail"];
} else {
$arr["thumbnail"] = $row["poster"];
}
$arr["movie_id"] = $row["movie_id"];
$arr["movie_name"] = $row["movie_name"];
$arr["movie_url"] = str_replace("?","%3F",$row["movie_url"]);
$arr["likes"] = $row["likes"];
$arr["quality"] = $row["quality"];
$arr["length"] = $row["length"];
// load tags of this video
$arr['tags'] = array();
$query = "SELECT t.tag_name FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE video_tags.video_id=" . $_POST['movieid'] . "
GROUP BY t.tag_name";
$result = $conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
array_push($arr['tags'], $r);
}
echo(json_encode($arr));
break;
case "getDbSize":
$query = "SELECT table_schema AS \"Database\",
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS \"Size\"
FROM information_schema.TABLES
WHERE TABLE_SCHEMA='hub'
GROUP BY table_schema;";
$result = $conn->query($query);
if ($result->num_rows == 1) {
$row = $result->fetch_assoc();
echo '{"data":"' . $row["Size"] . 'MB"}';
}
break;
case "readThumbnail":
$query = "SELECT thumbnail FROM videos WHERE movie_id='" . $_POST['movieid'] . "'";
$result = $conn->query($query);
$row = $result->fetch_assoc();
echo($row["thumbnail"]);
break;
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";
$result = $conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr = array();
$arr['total'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id";
$result = $conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['tagged'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='hd'";
$result = $conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['hd'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='fullhd'";
$result = $conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['fullhd'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='lowquality'";
$result = $conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['sd'] = $r['nr'];
$query = "SELECT COUNT(*) as nr FROM tags";
$result = $conn->query($query);
$r = mysqli_fetch_assoc($result);
$arr['tags'] = $r['nr'];
echo(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":
$movieid = $_POST['movieid'];
$tagid = $_POST['id'];
$query = "INSERT INTO video_tags(tag_id, video_id) VALUES ('$tagid','$movieid')";
if ($conn->query($query) === TRUE) {
echo('{"result":"success"}');
} else {
echo('{"result":"' . $conn->error . '"}');
}
break;
}
} else {
echo('{data:"error"}');
}
return;

View File

@ -1,3 +1,22 @@
create table if not exists actors
(
actor_id int auto_increment
primary key,
name varchar(50) null,
thumbnail mediumblob null
)
comment 'informations about different actors';
create table if not exists settings
(
video_path varchar(255) null,
episode_path varchar(255) null,
password varchar(32) null,
mediacenter_name varchar(32) null,
TMDB_grabbing tinyint null,
DarkMode tinyint default 0 null
);
create table if not exists tags create table if not exists tags
( (
tag_id int auto_increment tag_id int auto_increment
@ -12,13 +31,29 @@ create table if not exists videos
movie_name varchar(200) null, movie_name varchar(200) null,
movie_url varchar(250) null, movie_url varchar(250) null,
thumbnail mediumblob null, thumbnail mediumblob null,
poster mediumblob null,
likes int default 0 null, likes int default 0 null,
create_date datetime default CURRENT_TIMESTAMP null,
quality int null, quality int null,
length int null comment 'in seconds', length int null comment 'in seconds',
create_date datetime default CURRENT_TIMESTAMP null poster mediumblob null
); );
create table if not exists actors_videos
(
actor_id int null,
video_id int null,
constraint actors_videos_actors_id_fk
foreign key (actor_id) references actors (actor_id),
constraint actors_videos_videos_movie_id_fk
foreign key (video_id) references videos (movie_id)
);
create index actors_videos_actor_id_index
on actors_videos (actor_id);
create index actors_videos_video_id_index
on actors_videos (video_id);
create table if not exists video_tags create table if not exists video_tags
( (
tag_id int null, tag_id int null,
@ -27,11 +62,16 @@ 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)
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,9 @@
Package: OpenMediaCenter
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,36 @@
#!/bin/bash
# enable nginx site
ln -sf /etc/nginx/sites-available/OpenMediaCenter.conf /etc/nginx/sites-enabled/OpenMediaCenter.conf
# link general socket to current one
phpsymlink="/var/run/php/php-fpm.sock";
# create a gneral symlink to the php socket if not already existing
if [ -L ${phpsymlink} ] ; then
if [ -e ${phpsymlink} ] ; then
echo "general php symlink already exists."
else
ln -sf /var/run/php/php*.*-fpm.sock /var/run/php/php-fpm.sock
fi
else
ln -sf /var/run/php/php*.*-fpm.sock /var/run/php/php-fpm.sock
fi
# 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
# trigger a movie reindex
php /var/www/openmediacenter/api/extractvideopreviews.php
rm /tmp/output.log

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;
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 /index.html;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
}
}

1
declaration.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.css';

19522
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,31 @@
{ {
"name": "openmediacenter", "name": "openmediacenter",
"version": "0.1.0", "version": "0.1.2",
"private": true, "private": true,
"main": "public/electron.js",
"author": {
"email": "lukas.heiligenbrunner@gmail.com",
"name": "Lukas Heiligenbrunner",
"url": "https://heili.eu"
},
"dependencies": { "dependencies": {
"bootstrap": "^4.5.0", "@fortawesome/fontawesome-svg-core": "^1.2.32",
"plyr-react": "^2.2.0", "@fortawesome/free-regular-svg-icons": "^5.15.1",
"react": "^16.13.1", "@fortawesome/free-solid-svg-icons": "^5.15.1",
"react-bootstrap": "^1.0.1", "@fortawesome/react-fontawesome": "^0.1.13",
"react-dom": "^16.13.1", "bootstrap": "^4.5.3",
"react-scripts": "^3.4.1" "plyr-react": "^3.0.7",
"react": "^17.0.1",
"react-bootstrap": "^1.4.0",
"react-dom": "^17.0.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"typescript": "^4.1.3"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test --reporters=jest-junit --reporters=default", "test": "CI=true react-scripts test --reporters=jest-junit --verbose --silent --coverage --reporters=default"
"coverage": "react-scripts test --coverage --watchAll=false",
"eject": "react-scripts eject"
}, },
"jest": { "jest": {
"collectCoverageFrom": [ "collectCoverageFrom": [
@ -27,10 +37,24 @@
"text-summary" "text-summary"
] ]
}, },
"proxy": "http://192.168.0.248", "proxy": "http://192.168.0.209",
"homepage": "/", "homepage": "/",
"eslintConfig": { "eslintConfig": {
"extends": "react-app" "extends": [
"react-app",
"react-app/jest"
],
"overrides": [
{
"files": [
"**/*.ts?(x)"
],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "error"
}
}
]
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@ -45,11 +69,18 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^4.2.4", "@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^9.5.0", "@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^7.2.1", "@testing-library/user-event": "^12.6.0",
"@types/react-router-dom": "^5.1.6",
"@types/react-router": "5.1.8",
"@types/jest": "^26.0.19",
"@types/node": "^12.19.9",
"@types/react": "^16.14.2",
"@types/react-dom": "^16.9.10",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-react-16": "^1.15.5",
"jest-junit": "^10.0.0" "jest-junit": "^12.0.0",
"react-scripts": "4.0.1"
} }
} }

View File

@ -1,105 +0,0 @@
import React from 'react';
import "./css/App.css"
import HomePage from "./pages/HomePage/HomePage";
import RandomPage from "./pages/RandomPage/RandomPage";
// include bootstraps css
import 'bootstrap/dist/css/bootstrap.min.css';
import SettingsPage from "./pages/SettingsPage/SettingsPage";
import CategoryPage from "./pages/CategoryPage/CategoryPage";
class App extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {page: "default"};
// bind this to the method for being able to call methods such as this.setstate
this.showVideo = this.showVideo.bind(this);
this.hideVideo = this.hideVideo.bind(this);
}
videoelement = null;
MainBody() {
let page;
if (this.state.page === "default") {
page = <HomePage viewbinding={{showVideo: this.showVideo, hideVideo: this.hideVideo}}/>;
this.mypage = page;
} else if (this.state.page === "random") {
page = <RandomPage viewbinding={{showVideo: this.showVideo, hideVideo: this.hideVideo}}/>;
this.mypage = page;
} else if (this.state.page === "settings") {
page = <SettingsPage/>;
this.mypage = page;
} else if (this.state.page === "categories") {
page = <CategoryPage viewbinding={{showVideo: this.showVideo, hideVideo: this.hideVideo}}/>;
this.mypage = page;
} else if (this.state.page === "video") {
// show videoelement if neccessary
page = this.videoelement;
console.log(page);
} else if (this.state.page === "lastpage") {
// return back to last page
page = this.mypage;
} else {
page = <div>unimplemented yet!</div>;
}
return (page);
}
render() {
return (
<div className="App">
<nav className="navbar navbar-expand-sm bg-primary navbar-dark">
<div className="navbar-brand">OpenMediaCenter</div>
<ul className="navbar-nav">
<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
</div>
</li>
<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
</div>
</li>
<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
</div>
</li>
<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
</div>
</li>
</ul>
</nav>
{this.MainBody()}
</div>
);
}
showVideo(element) {
this.videoelement = element;
this.setState({
page: "video"
});
}
hideVideo() {
this.setState({
page: "lastpage"
});
this.element = null;
}
}
export default App;

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;
text-decoration: none;
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-style: dotted;
border-width: 0 0 2px 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

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import App from './App'; import App from './App';
import {shallow} from 'enzyme' import {shallow} from 'enzyme';
describe('<App/>', function () { describe('<App/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -10,11 +10,34 @@ 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('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();
});
}); });
}); });

124
src/App.tsx Normal file
View File

@ -0,0 +1,124 @@
import React from 'react';
import HomePage from './pages/HomePage/HomePage';
import RandomPage from './pages/RandomPage/RandomPage';
import GlobalInfos from './utils/GlobalInfos';
// include bootstraps css
import 'bootstrap/dist/css/bootstrap.min.css';
import style from './App.module.css';
import SettingsPage from './pages/SettingsPage/SettingsPage';
import CategoryPage from './pages/CategoryPage/CategoryPage';
import {APINode, callAPI} from './utils/Api';
import {NoBackendConnectionPopup} from './elements/Popups/NoBackendConnectionPopup/NoBackendConnectionPopup';
import {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom';
import Player from './pages/Player/Player';
import ActorOverviewPage from './pages/ActorOverviewPage/ActorOverviewPage';
import ActorPage from './pages/ActorPage/ActorPage';
import {SettingsTypes} from './types/ApiTypes';
interface state {
generalSettingsLoaded: boolean;
passwordsupport: boolean;
mediacentername: string;
onapierror: boolean;
}
/**
* The main App handles the main tabs and which content to show
*/
class App extends React.Component<{}, state> {
constructor(props: {}) {
super(props);
this.state = {
generalSettingsLoaded: false,
passwordsupport: false,
mediacentername: 'OpenMediaCenter',
onapierror: false
};
}
initialAPICall(): void {
// this is the first api call so if it fails we know there is no connection to backend
callAPI(APINode.Settings, {action: 'loadInitialData'}, (result: SettingsTypes.initialApiCallData) => {
// set theme
GlobalInfos.enableDarkTheme(result.DarkMode);
this.setState({
generalSettingsLoaded: true,
passwordsupport: result.passwordEnabled,
mediacentername: result.mediacenter_name,
onapierror: false
});
// set tab title to received mediacenter name
document.title = result.mediacenter_name;
}, error => {
this.setState({onapierror: true});
});
}
componentDidMount(): void {
this.initialAPICall();
}
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
// add the main theme to the page body
document.body.className = themeStyle.backgroundcolor;
return (
<Router>
<div className={style.app}>
<div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}>
<div className={style.navbrand}>{this.state.mediacentername}</div>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/'} activeStyle={{opacity: '0.85'}}>Home</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/random'} activeStyle={{opacity: '0.85'}}>Random
Video</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/categories'} activeStyle={{opacity: '0.85'}}>Categories</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/settings'} activeStyle={{opacity: '0.85'}}>Settings</NavLink>
</div>
{this.routing()}
</div>
{this.state.onapierror ? this.ApiError() : null}
</Router>
);
}
routing(): JSX.Element {
return (
<Switch>
<Route path="/random">
<RandomPage/>
</Route>
<Route path="/categories">
<CategoryPage/>
</Route>
<Route path="/settings">
<SettingsPage/>
</Route>
<Route exact path="/player/:id">
<Player/>
</Route>
<Route exact path="/actors">
<ActorOverviewPage/>
</Route>
<Route path="/actors/:id">
<ActorPage/>
</Route>
<Route path="/">
<HomePage/>
</Route>
</Switch>
);
}
ApiError(): JSX.Element {
// on api error show popup and retry and show again if failing..
return (<NoBackendConnectionPopup onHide={(): void => this.initialAPICall()}/>);
}
}
export default App;

View File

@ -0,0 +1,48 @@
/**
* The coloring elements for dark theme
*/
.backgroundcolor {
background-color: #141520;
}
.textcolor {
color: white;
}
.subtextcolor {
color: #dedad6;
}
.lighttextcolor {
color: #d5d5d5;
}
.navitem::after {
background: white;
}
.navitem {
color: white;
}
.navitem:hover {
color: 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,47 @@
/**
* The coloring elements for light theme
*/
.navitem {
color: black;
}
.navitem:hover {
color: black;
}
.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;
}

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

@ -0,0 +1,29 @@
.actortile {
background-color: #179017;
border-radius: 10px;
cursor: pointer;
float: left;
height: 200px;
margin: 3px;
transition: opacity ease 0.5s;
width: 130px;
}
.actortile:hover {
opacity: 0.7;
transition: opacity ease 0.5s;
}
.actortile_thumbnail {
color: #c6c6c6;
height: 160px;
margin-top: 10px;
text-align: center;
}
.actortile_name {
color: white;
text-align: center;
/*todo dynamic text coloring dependent on theme*/
}

View File

@ -0,0 +1,23 @@
import {shallow} from 'enzyme';
import React from 'react';
import ActorTile from './ActorTile';
describe('<ActorTile/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<ActorTile actor={{thumbnail: '-1', name: 'testname', id: 3}}/>);
wrapper.unmount();
});
it('simulate click with custom handler', function () {
const func = jest.fn((_) => {});
const wrapper = shallow(<ActorTile actor={{thumbnail: '-1', name: 'testname', id: 3}} onClick={() => func()}/>);
const func1 = jest.fn();
prepareViewBinding(func1);
wrapper.simulate('click');
expect(func1).toBeCalledTimes(0);
expect(func).toBeCalledTimes(1);
});
});

View File

@ -0,0 +1,47 @@
import style from './ActorTile.module.css';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faUser} from '@fortawesome/free-solid-svg-icons';
import React from 'react';
import {Link} from 'react-router-dom';
import {ActorType} from '../../types/VideoTypes';
interface props {
actor: ActorType;
onClick?: (actor: ActorType) => void
}
class ActorTile extends React.Component<props> {
constructor(props: props) {
super(props);
this.state = {};
}
render(): JSX.Element {
if (this.props.onClick) {
return this.renderActorTile(this.props.onClick);
} else {
return (
<Link to={{pathname: '/actors/' + this.props.actor.actor_id}}>
{this.renderActorTile(() => {
})}
</Link>
);
}
}
renderActorTile(customclickhandler: (actor: ActorType) => void): JSX.Element {
return (
<div className={style.actortile} onClick={(): void => customclickhandler(this.props.actor)}>
<div className={style.actortile_thumbnail}>
{this.props.actor.thumbnail === null ? <FontAwesomeIcon style={{
lineHeight: '130px'
}} icon={faUser} size='5x'/> : 'dfdf' /* todo render picture provided here! */}
</div>
<div className={style.actortile_name}>{this.props.actor.name}</div>
</div>
);
}
}
export default ActorTile;

View File

@ -1,89 +0,0 @@
import React from "react";
import Modal from 'react-bootstrap/Modal'
import Dropdown from "react-bootstrap/Dropdown";
import DropdownButton from "react-bootstrap/DropdownButton";
class AddTagPopup extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
selection: {
name: "nothing selected",
id: -1
},
items: []
};
this.props = props;
}
componentDidMount() {
const updateRequest = new FormData();
updateRequest.append('action', 'getAllTags');
fetch('/api/videoload.php', {method: 'POST', body: updateRequest})
.then((response) => response.json())
.then((result) => {
this.setState({
items: result
});
});
}
render() {
return (
<>
<Modal
show={this.props.show}
onHide={this.props.onHide}
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.map((i) => (
<Dropdown.Item key={i.tag_name} onClick={() => {
this.setState({selection: {name: i.tag_name, id: i.tag_id}})
}}>{i.tag_name}</Dropdown.Item>
)) :
<Dropdown.Item>loading tags...</Dropdown.Item>}
</DropdownButton>
</Modal.Body>
<Modal.Footer>
<button className='btn btn-primary' onClick={() => {
this.storeselection();
}}>Add
</button>
</Modal.Footer>
</Modal>
</>
);
}
storeselection() {
const updateRequest = new FormData();
updateRequest.append('action', 'addTag');
updateRequest.append('id', this.state.selection.id);
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/videoload.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.result !== "success") {
console.log("error occured while writing to db -- todo error handling");
console.log(result.result);
}
this.props.onHide();
}));
}
}
export default AddTagPopup;

View File

@ -1,60 +0,0 @@
import React from "react";
import {shallow} from 'enzyme'
import "@testing-library/jest-dom"
import AddTagPopup from "./AddTagPopup";
describe('<AddTagPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.unmount();
});
it('test dropdown insertion', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.setState({items: ["test1", "test2", "test3"]});
expect(wrapper.find('DropdownItem')).toHaveLength(3);
});
it('test storeseletion click event', done => {
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/>);
wrapper.setProps({
onHide: () => {
func()
}
});
wrapper.setState({
items: ["test1", "test2", "test3"],
selection: {
name: "test1",
id: 42
}
});
// first call of fetch is getting of available tags
expect(global.fetch).toHaveBeenCalledTimes(1);
wrapper.find('ModalFooter').find('button').simulate('click');
// now called 2 times
expect(global.fetch).toHaveBeenCalledTimes(2);
process.nextTick(() => {
//callback to close window should have called
expect(func).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
});

View File

@ -0,0 +1,8 @@
.button {
background-color: green;
border-radius: 5px;
border-width: 0;
color: white;
margin-right: 15px;
padding: 6px;
}

View File

@ -0,0 +1,32 @@
import {shallow} from 'enzyme';
import React from 'react';
import {Button} from './Button';
function prepareFetchApi(response) {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
describe('<Button/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<Button onClick={() => {}} title='test'/>);
wrapper.unmount();
});
it('renders title', function () {
const wrapper = shallow(<Button onClick={() => {}} title='test1'/>);
expect(wrapper.text()).toBe('test1');
});
it('test onclick handling', () => {
const func = jest.fn();
const wrapper = shallow(<Button onClick={func} title='test1'/>);
wrapper.find('button').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,16 @@
import React from 'react';
import style from './Button.module.css';
interface ButtonProps {
title: string | JSX.Element;
onClick?: () => void;
color?: React.CSSProperties;
}
export function Button(props: ButtonProps): JSX.Element {
return (
<button className={style.button} style={props.color} onClick={props.onClick}>
{props.title}
</button>
);
}

View File

@ -0,0 +1,58 @@
/* styling for tile */
.infoheaderitem {
background-color: lightblue;
border-radius: 5px;
flex: calc(25% - 10px);
margin: 5px;
}
/* On screens that are 1317px wide or less, go from four columns to two columns */
@media screen and (max-width: 1317px) {
.infoheaderitem {
flex: calc(50% - 10px);
}
}
/* change opacity of icon when hovering whole tile */
.infoheaderitem:hover .icon {
opacity: 0.75;
transition: opacity linear 0.4s;
}
/* change cursor on hover */
.infoheaderitem:hover {
cursor: pointer;
}
.icon {
float: left;
height: 130px;
margin-left: 5px;
margin-right: 17px;
margin-top: 20px;
opacity: 0.5;
text-align: center;
width: 30%;
}
/* big main text in tile */
.maintext {
font-size: x-large;
font-weight: bold;
margin-top: 30px;
}
/* small subtext in tile */
.subtext {
font-size: large;
margin-top: 5px;
opacity: 0.7;
}
.loadAnimation {
display: inline-block;
line-height: 145px;
margin-left: calc(25% - 15px);
vertical-align: middle;
}

View File

@ -0,0 +1,43 @@
import {shallow} from 'enzyme';
import React from 'react';
import InfoHeaderItem from './InfoHeaderItem';
describe('<InfoHeaderItem/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<InfoHeaderItem/>);
wrapper.unmount();
});
it('renders correct text', function () {
const wrapper = shallow(<InfoHeaderItem text='mytext'/>);
expect(wrapper.find('.maintext').text()).toBe('mytext');
});
it('renders correct subtext', function () {
const wrapper = shallow(<InfoHeaderItem text='mimi' subtext='testtext'/>);
expect(wrapper.find('.subtext').text()).toBe('testtext');
});
it('test no subtext if no text defined', function () {
const wrapper = shallow(<InfoHeaderItem subtext='testi'/>);
expect(wrapper.find('.subtext')).toHaveLength(0);
});
it('test custom click handler', function () {
const func = jest.fn();
const wrapper = shallow(<InfoHeaderItem onClick={() => func()}/>);
expect(func).toBeCalledTimes(0);
wrapper.simulate('click');
expect(func).toBeCalledTimes(1);
});
it('test insertion of loading spinner', function () {
const wrapper = shallow(<InfoHeaderItem text={null}/>);
expect(wrapper.find('Spinner').length).toBe(1);
});
it('test loading spinner if undefined', function () {
const wrapper = shallow(<InfoHeaderItem/>);
expect(wrapper.find('Spinner').length).toBe(1);
});
});

View File

@ -0,0 +1,43 @@
import React from 'react';
import style from './InfoHeaderItem.module.css';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {Spinner} from 'react-bootstrap';
import {IconDefinition} from '@fortawesome/fontawesome-common-types';
interface props {
onClick?: () => void
backColor: string
icon: IconDefinition
text: string | number
subtext: string | number
}
/**
* a component to display one of the short quickinfo tiles on dashboard
*/
class InfoHeaderItem extends React.Component<props> {
render(): JSX.Element {
return (
<div onClick={(): void => {
// call clicklistener if defined
if (this.props.onClick != null) this.props.onClick();
}} className={style.infoheaderitem} style={{backgroundColor: this.props.backColor}}>
<div className={style.icon}>
<FontAwesomeIcon style={{
verticalAlign: 'middle',
lineHeight: '130px'
}} icon={this.props.icon} size='5x'/>
</div>
{this.props.text !== null && this.props.text !== undefined ?
<>
<div className={style.maintext}>{this.props.text}</div>
<div className={style.subtext}>{this.props.subtext}</div>
</>
: <span className={style.loadAnimation}><Spinner animation='border'/></span>
}
</div>
);
}
}
export default InfoHeaderItem;

View File

@ -1,65 +0,0 @@
import React from "react";
import Modal from 'react-bootstrap/Modal'
import {Form} from "react-bootstrap";
class NewTagPopup extends React.Component {
constructor(props, context) {
super(props, context);
this.props = props;
}
render() {
return (
<>
<Modal
show={this.props.show}
onHide={this.props.onHide}
size="lg"
aria-labelledby="contained-modal-title-vcenter"
centered>
<Modal.Header closeButton>
<Modal.Title id="contained-modal-title-vcenter">
Create a new Tag!
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group>
<Form.Label>Tag Name:</Form.Label>
<Form.Control id='namefield' type="text" placeholder="Enter Tag name" onChange={(v) => {
this.value = v.target.value
}}/>
<Form.Text className="text-muted">
This Tag will automatically show up on category page.
</Form.Text>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<button className='btn btn-primary' onClick={() => {
this.storeselection();
}}>Add
</button>
</Modal.Footer>
</Modal>
</>
);
}
storeselection() {
const updateRequest = new FormData();
updateRequest.append('action', 'createTag');
updateRequest.append('tagname', this.value);
fetch('/api/Tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json())
.then((result) => {
if (result.result !== "success") {
console.log("error occured while writing to db -- todo error handling");
console.log(result.result);
}
this.props.onHide();
});
}
}
export default NewTagPopup;

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,38 @@
import React from 'react';
import {shallow} from 'enzyme';
import PageTitle, {Line} 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');
});
});
describe('<Line/>', () => {
it('renders without crashing', function () {
const wrapper = shallow(<Line/>);
wrapper.unmount();
});
});

View File

@ -0,0 +1,44 @@
import React from 'react';
import style from './PageTitle.module.css';
import GlobalInfos from '../../utils/GlobalInfos';
interface props {
title: string;
subtitle: string | number | null;
}
/**
* Component for generating PageTitle with bottom Line
*/
class PageTitle extends React.Component<props> {
render(): JSX.Element {
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(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<>
<hr className={themeStyle.hrcolor}/>
</>
);
}
}
export default PageTitle;

View File

@ -0,0 +1,19 @@
.newactorbutton {
background-color: green;
border-radius: 5px;
border-width: 0;
color: white;
margin-right: 15px;
margin-top: 12px;
padding: 6px;
width: 140px;
}
.searchinput {
float: left;
width: 120px;
}
.searchbar {
margin-bottom: 13px;
}

View File

@ -0,0 +1,89 @@
import {shallow} from 'enzyme';
import React from 'react';
import AddActorPopup from './AddActorPopup';
import {callAPI} from '../../../utils/Api';
describe('<AddActorPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AddActorPopup/>);
wrapper.unmount();
});
it('simulate change to other page', function () {
const wrapper = shallow(<AddActorPopup/>);
expect(wrapper.find('NewActorPopupContent')).toHaveLength(0);
wrapper.find('PopupBase').props().banner.props.onClick();
// check if new content is showing
expect(wrapper.find('NewActorPopupContent')).toHaveLength(1);
});
it('hide new actor page', function () {
const wrapper = shallow(<AddActorPopup/>);
wrapper.find('PopupBase').props().banner.props.onClick();
// call onhide event listener manually
wrapper.find('NewActorPopupContent').props().onHide();
// expect other page to be hidden again
expect(wrapper.find('NewActorPopupContent')).toHaveLength(0);
});
it('test api call and insertion of actor tiles', function () {
global.callAPIMock([{id: 1, name: 'test'}, {id: 2, name: 'test2'}]);
const wrapper = shallow(<AddActorPopup/>);
expect(wrapper.find('ActorTile')).toHaveLength(2);
});
it('simulate actortile click', function () {
const func = jest.fn();
const wrapper = shallow(<AddActorPopup onHide={() => {func();}} movie_id={1}/>);
global.callAPIMock({result: 'success'});
wrapper.setState({actors: [{actor_id: 1, name: 'test'}]}, () => {
wrapper.find('ActorTile').dive().simulate('click');
expect(callAPI).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledTimes(1);
});
});
it('test failing actortile click', function () {
const func = jest.fn();
const wrapper = shallow(<AddActorPopup onHide={() => {func();}}/>);
global.callAPIMock({result: 'nosuccess'});
wrapper.setState({actors: [{actor_id: 1, name: 'test'}]}, () => {
wrapper.find('ActorTile').dive().simulate('click');
expect(callAPI).toHaveBeenCalledTimes(1);
// hide funtion should not have been called on error!
expect(func).toHaveBeenCalledTimes(0);
});
});
it('test no actor on loading', function () {
const wrapper = shallow(<AddActorPopup/>);
expect(wrapper.find('PopupBase').find('ActorTile')).toHaveLength(0);
});
it('test Enter submit if only one element left', function () {
const wrapper = shallow(<AddActorPopup/>);
callAPIMock({});
wrapper.setState({actors: [{name: 'test', actor_id: 1}]});
wrapper.find('PopupBase').props().ParentSubmit();
expect(callAPI).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,201 @@
import PopupBase from '../PopupBase';
import React from 'react';
import ActorTile from '../../ActorTile/ActorTile';
import style from './AddActorPopup.module.css';
import {NewActorPopupContent} from '../NewActorPopup/NewActorPopup';
import {APINode, callAPI} from '../../../utils/Api';
import {ActorType} from '../../../types/VideoTypes';
import {GeneralSuccess} from '../../../types/GeneralTypes';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faFilter, faTimes} from '@fortawesome/free-solid-svg-icons';
import {Button} from '../../GPElements/Button';
import {addKeyHandler, removeKeyHandler} from '../../../utils/ShortkeyHandler';
interface props {
onHide: () => void;
movie_id: number;
}
interface state {
contentDefault: boolean;
actors: ActorType[];
filter: string;
filtervisible: boolean;
}
/**
* Popup for Adding a new Actor to a Video
*/
class AddActorPopup extends React.Component<props, state> {
// filterfield anchor, needed to focus after filter btn click
private filterfield: HTMLInputElement | null | undefined;
constructor(props: props) {
super(props);
this.state = {
contentDefault: true,
actors: [],
filter: '',
filtervisible: false
};
this.tileClickHandler = this.tileClickHandler.bind(this);
this.filterSearch = this.filterSearch.bind(this);
this.parentSubmit = this.parentSubmit.bind(this);
this.keypress = this.keypress.bind(this);
}
componentWillUnmount(): void {
removeKeyHandler(this.keypress);
}
componentDidMount(): void {
addKeyHandler(this.keypress);
// fetch the available actors
this.loadActors();
}
render(): JSX.Element {
return (
<>
{/* todo render actor tiles here and add search field*/}
<PopupBase title='Add new Actor to Video' onHide={this.props.onHide} banner={
<button
className={style.newactorbutton}
onClick={(): void => {
this.setState({contentDefault: false});
}}>Create new Actor</button>} ParentSubmit={this.parentSubmit}>
{this.resolvePage()}
</PopupBase>
</>
);
}
/**
* selector for current showing popup page
* @returns {JSX.Element}
*/
resolvePage(): JSX.Element {
if (this.state.contentDefault) return (this.getContent());
else return (<NewActorPopupContent onHide={(): void => {
this.loadActors();
this.setState({contentDefault: true});
}}/>);
}
/**
* returns content for the newActor popup
* @returns {JSX.Element}
*/
getContent(): JSX.Element {
if (this.state.actors.length !== 0) {
return (
<>
<div className={style.searchbar}>
{
this.state.filtervisible ?
<>
<input className={'form-control mr-sm-2 ' + style.searchinput}
type='text' placeholder='Filter' value={this.state.filter}
onChange={(e): void => {
this.setState({filter: e.target.value});
}}
ref={(input): void => {this.filterfield = input;}}/>
<Button title={<FontAwesomeIcon style={{
verticalAlign: 'middle',
lineHeight: '130px'
}} icon={faTimes} size='1x'/>} color={{backgroundColor: 'red'}} onClick={(): void => {
this.setState({filter: '', filtervisible: false});
}}/>
</> :
<Button
title={<span>Filter <FontAwesomeIcon style={{
verticalAlign: 'middle',
lineHeight: '130px'
}} icon={faFilter} size='1x'/></span>}
color={{backgroundColor: 'cornflowerblue', color: 'white'}}
onClick={(): void => this.enableFilterField()}/>
}
</div>
{this.state.actors.filter(this.filterSearch).map((el) => (<ActorTile actor={el} onClick={this.tileClickHandler}/>))}
</>
);
} else {
return (<div>somekind of loading</div>);
}
}
/**
* event handling for ActorTile Click
*/
tileClickHandler(actor: ActorType): void {
// fetch the available actors
callAPI<GeneralSuccess>(APINode.Actor, {
action: 'addActorToVideo',
actorid: actor.actor_id,
videoid: this.props.movie_id
}, result => {
if (result.result === 'success') {
// return back to player page
this.props.onHide();
} else {
console.error('an error occured while fetching actors: ' + result);
}
});
}
/**
* load the actors from backend and set state
*/
loadActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, result => {
this.setState({actors: result});
});
}
/**
* enable filterfield and focus into searchbar
*/
private enableFilterField(): void {
this.setState({filtervisible: true}, () => {
// focus filterfield after state update
this.filterfield?.focus();
});
}
/**
* filter the actor array for search matches
* @param actor
*/
private filterSearch(actor: ActorType): boolean {
return actor.name.toLowerCase().includes(this.state.filter.toLowerCase());
}
/**
* handle a Popupbase parent submit action
*/
private parentSubmit(): void {
// allow submit only if one item is left in selection
const filteredList = this.state.actors.filter(this.filterSearch);
if (filteredList.length === 1) {
// simulate click if parent submit
this.tileClickHandler(filteredList[0]);
}
}
/**
* key event handling
* @param event keyevent
*/
private keypress(event: KeyboardEvent): void {
// hide if escape is pressed
if (event.key === 'f') {
this.enableFilterField();
}
}
}
export default AddActorPopup;

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

@ -0,0 +1,35 @@
import React from 'react';
import {shallow} from 'enzyme';
import '@testing-library/jest-dom';
import AddTagPopup from './AddTagPopup';
describe('<AddTagPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.unmount();
});
it('test tag insertion', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.setState({
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 tag click', function () {
const wrapper = shallow(<AddTagPopup submit={jest.fn()} onHide={jest.fn()}/>);
wrapper.setState({
items: [{tag_id: 1, tag_name: 'test'}]
}, () => {
wrapper.find('Tag').first().dive().simulate('click');
expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,51 @@
import React from 'react';
import Tag from '../../Tag/Tag';
import PopupBase from '../PopupBase';
import {APINode, callAPI} from '../../../utils/Api';
import {TagType} from '../../../types/VideoTypes';
interface props {
onHide: () => void;
submit: (tagId: number, tagName: string) => void;
movie_id: number;
}
interface state {
items: TagType[];
}
/**
* component creates overlay to add a new tag to a video
*/
class AddTagPopup extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {items: []};
}
componentDidMount(): void {
callAPI(APINode.Tags, {action: 'getAllTags'}, (result: TagType[]) => {
this.setState({
items: result
});
});
}
render(): JSX.Element {
return (
<PopupBase title='Add a Tag to this Video:' onHide={this.props.onHide}>
{this.state.items ?
this.state.items.map((i) => (
<Tag tagInfo={i}
onclick={(): void => {
this.props.submit(i.tag_id, i.tag_name);
this.props.onHide();
}}/>
)) : null}
</PopupBase>
);
}
}
export default AddTagPopup;

View File

@ -0,0 +1,8 @@
.savebtn {
background-color: greenyellow;
border: 0;
border-radius: 4px;
float: right;
margin-top: 30px;
padding: 3px;
}

View File

@ -0,0 +1,60 @@
import React from 'react';
import {shallow} from 'enzyme';
import '@testing-library/jest-dom';
import NewActorPopup, {NewActorPopupContent} from './NewActorPopup';
import {callAPI} from '../../../utils/Api';
describe('<NewActorPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<NewActorPopup/>);
wrapper.unmount();
});
});
describe('<NewActorPopupContent/>', () => {
it('renders without crashing', function () {
const wrapper = shallow(<NewActorPopupContent/>);
wrapper.unmount();
});
it('simulate button click', function () {
global.callAPIMock({});
const func = jest.fn();
const wrapper = shallow(<NewActorPopupContent onHide={() => {func();}}/>);
// manually set typed in actorname
wrapper.instance().value = 'testactorname';
global.fetch = prepareFetchApi({});
expect(callAPI).toBeCalledTimes(0);
wrapper.find('button').simulate('click');
// fetch should have been called once now
expect(callAPI).toBeCalledTimes(1);
expect(func).toHaveBeenCalledTimes(1);
});
it('test not allowing request if textfield is empty', function () {
const wrapper = shallow(<NewActorPopupContent/>);
global.fetch = prepareFetchApi({});
expect(global.fetch).toBeCalledTimes(0);
wrapper.find('button').simulate('click');
// fetch should not be called now
expect(global.fetch).toBeCalledTimes(0);
});
it('test input change', function () {
const wrapper = shallow(<NewActorPopupContent/>);
wrapper.find('input').simulate('change', {target: {value: 'testinput'}});
expect(wrapper.instance().value).toBe('testinput');
});
});

View File

@ -0,0 +1,56 @@
import React from 'react';
import PopupBase from '../PopupBase';
import style from './NewActorPopup.module.css';
import {APINode, callAPI} from '../../../utils/Api';
import {GeneralSuccess} from '../../../types/GeneralTypes';
interface NewActorPopupProps {
onHide: () => void;
}
/**
* creates modal overlay to define a new Tag
*/
class NewActorPopup extends React.Component<NewActorPopupProps> {
render(): JSX.Element {
return (
<PopupBase title='Add new Tag' onHide={this.props.onHide} height='200px' width='400px'>
<NewActorPopupContent onHide={this.props.onHide}/>
</PopupBase>
);
}
}
export class NewActorPopupContent extends React.Component<NewActorPopupProps> {
value: string | undefined;
render(): JSX.Element {
return (
<>
<div>
<input type='text' placeholder='Actor Name' onChange={(v): void => {
this.value = v.target.value;
}}/></div>
<button className={style.savebtn} onClick={(): void => this.storeselection()}>Save</button>
</>
);
}
/**
* store the filled in form to the backend
*/
storeselection(): void {
// check if user typed in name
if (this.value === '' || this.value === undefined) return;
callAPI(APINode.Actor, {action: 'createActor', actorname: this.value}, (result: GeneralSuccess) => {
if (result.result !== 'success') {
console.log('error occured while writing to db -- todo error handling');
console.log(result.result);
}
this.props.onHide();
});
}
}
export default NewActorPopup;

View File

@ -0,0 +1,8 @@
.savebtn {
background-color: greenyellow;
border: 0;
border-radius: 4px;
float: right;
margin-top: 30px;
padding: 3px;
}

View File

@ -1,8 +1,8 @@
import React from "react"; import React from 'react';
import {shallow} from 'enzyme' import {shallow} from 'enzyme';
import "@testing-library/jest-dom" import '@testing-library/jest-dom';
import NewTagPopup from "./NewTagPopup"; import NewTagPopup from './NewTagPopup';
describe('<NewTagPopup/>', function () { describe('<NewTagPopup/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -11,23 +11,20 @@ describe('<NewTagPopup/>', function () {
}); });
it('test storeseletion click event', done => { it('test storeseletion click event', done => {
const mockSuccessResponse = {}; global.fetch = prepareFetchApi({});
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(<NewTagPopup/>); const wrapper = shallow(<NewTagPopup/>);
wrapper.setProps({ wrapper.setProps({
onHide: () => { onHide: () => {
func() func();
} }
}); });
wrapper.find('ModalFooter').find('button').simulate('click'); wrapper.instance().value = 'testvalue';
wrapper.find('button').simulate('click');
expect(global.fetch).toHaveBeenCalledTimes(1); expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => { process.nextTick(() => {
@ -38,4 +35,12 @@ describe('<NewTagPopup/>', function () {
done(); done();
}); });
}); });
it('simulate textfield change', function () {
const wrapper = shallow(<NewTagPopup/>);
wrapper.find('input').simulate('change', {target: {value: 'testvalue'}});
expect(wrapper.instance().value).toBe('testvalue');
});
}); });

View File

@ -0,0 +1,42 @@
import React from 'react';
import PopupBase from '../PopupBase';
import style from './NewTagPopup.module.css';
import {APINode, callAPI} from '../../../utils/Api';
import {GeneralSuccess} from '../../../types/GeneralTypes';
interface props {
onHide: () => void
}
/**
* creates modal overlay to define a new Tag
*/
class NewTagPopup extends React.Component<props> {
private value: string = '';
render(): JSX.Element {
return (
<PopupBase title='Add new Tag' onHide={this.props.onHide} height='200px' width='400px' ParentSubmit={(): void => this.storeselection()}>
<div><input type='text' placeholder='Tagname' onChange={(v): void => {
this.value = v.target.value;
}}/></div>
<button className={style.savebtn} onClick={(): void => this.storeselection()}>Save</button>
</PopupBase>
);
}
/**
* store the filled in form to the backend
*/
storeselection(): void {
callAPI(APINode.Tags, {action: 'createTag', tagname: this.value}, (result: GeneralSuccess) => {
if (result.result !== 'success') {
console.log('error occured while writing to db -- todo error handling');
console.log(result.result);
}
this.props.onHide();
});
}
}
export default NewTagPopup;

View File

@ -0,0 +1,29 @@
import {shallow} from 'enzyme';
import React from 'react';
import {NoBackendConnectionPopup} from './NoBackendConnectionPopup';
import {getBackendDomain} from '../../../utils/Api';
describe('<NoBackendConnectionPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<NoBackendConnectionPopup onHide={() => {}}/>);
wrapper.unmount();
});
it('hides on refresh click', function () {
const func = jest.fn();
const wrapper = shallow(<NoBackendConnectionPopup onHide={func}/>);
expect(func).toBeCalledTimes(0);
wrapper.find('button').simulate('click');
expect(func).toBeCalledTimes(1);
});
it('simulate change of textfield', function () {
const wrapper = shallow(<NoBackendConnectionPopup onHide={() => {}}/>);
wrapper.find('input').simulate('change', {target: {value: 'testvalue'}});
expect(getBackendDomain()).toBe('testvalue');
});
});

View File

@ -0,0 +1,20 @@
import React from 'react';
import PopupBase from '../PopupBase';
import style from '../NewActorPopup/NewActorPopup.module.css';
import {setCustomBackendDomain} from '../../../utils/Api';
interface NBCProps {
onHide: (_: void) => void
}
export function NoBackendConnectionPopup(props: NBCProps): JSX.Element {
return (
<PopupBase title='No connection to backend API!' onHide={props.onHide} height='200px' width='600px'>
<div>
<input type='text' placeholder='http://192.168.0.2' onChange={(v): void => {
setCustomBackendDomain(v.target.value);
}}/></div>
<button className={style.savebtn} onClick={(): void => props.onHide()}>Refresh</button>
</PopupBase>
);
}

View File

@ -0,0 +1,45 @@
.popup {
border: 3px #3574fe solid;
border-radius: 18px;
height: fit-content;
left: 20%;
min-height: 80%;
opacity: 0.95;
position: absolute;
top: 10%;
width: 60%;
z-index: 2;
}
.header {
cursor: move;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
}
.title {
float: left;
font-size: x-large;
margin-left: 15px;
margin-top: 10px;
opacity: 1;
width: 60%;
}
.banner {
display: flex;
flex-direction: row;
float: left;
justify-content: flex-end;
width: 40%;
}
.content {
margin-left: 20px;
margin-right: 20px;
margin-top: 10px;
opacity: 1;
overflow: auto;
}

View File

@ -0,0 +1,41 @@
import {shallow} from 'enzyme';
import React from 'react';
import PopupBase from './PopupBase';
describe('<PopupBase/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<PopupBase/>);
wrapper.unmount();
});
let events;
function mockKeyPress() {
events = [];
document.addEventListener = jest.fn((event, cb) => {
events[event] = cb;
});
}
it('simulate keypress', function () {
mockKeyPress();
const func = jest.fn();
shallow(<PopupBase onHide={() => func()}/>);
// trigger the keypress event
events.keyup({key: 'Escape'});
expect(func).toBeCalledTimes(1);
});
it('test an Enter sumit', function () {
mockKeyPress();
const func = jest.fn();
shallow(<PopupBase ParentSubmit={() => func()}/>);
// trigger the keypress event
events.keyup({key: 'Enter'});
expect(func).toBeCalledTimes(1);
});
});

View File

@ -0,0 +1,141 @@
import GlobalInfos from '../../utils/GlobalInfos';
import style from './PopupBase.module.css';
import {Line} from '../PageTitle/PageTitle';
import React, {RefObject} from 'react';
import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler';
interface props {
width?: string;
height?: string;
banner?: JSX.Element;
title: string;
onHide: () => void;
ParentSubmit?: () => void;
}
/**
* wrapper class for generic types of popups
*/
class PopupBase extends React.Component<props> {
private wrapperRef: RefObject<HTMLDivElement>;
private framedimensions: { minHeight: string | undefined; width: string | undefined; height: string | undefined };
constructor(props: props) {
super(props);
this.state = {items: []};
this.wrapperRef = React.createRef();
this.handleClickOutside = this.handleClickOutside.bind(this);
this.keypress = this.keypress.bind(this);
// parse style props
this.framedimensions = {
width: (this.props.width ? this.props.width : undefined),
height: (this.props.height ? this.props.height : undefined),
minHeight: (this.props.height ? this.props.height : undefined)
};
}
componentDidMount(): void {
document.addEventListener('mousedown', this.handleClickOutside);
addKeyHandler(this.keypress);
// add element drag drop events
if (this.wrapperRef != null) {
this.dragElement();
}
}
componentWillUnmount(): void {
// remove the appended listeners
document.removeEventListener('mousedown', this.handleClickOutside);
removeKeyHandler(this.keypress);
}
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div style={this.framedimensions} className={[style.popup, themeStyle.thirdbackground].join(' ')} ref={this.wrapperRef}>
<div className={style.header}>
<div className={[style.title, themeStyle.textcolor].join(' ')}>{this.props.title}</div>
<div className={style.banner}>{this.props.banner}</div>
</div>
<Line/>
<div className={style.content}>
{this.props.children}
</div>
</div>
);
}
/**
* Alert if clicked on outside of element
*/
handleClickOutside(event: MouseEvent): void {
if (this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(event.target as Node)) {
this.props.onHide();
}
}
/**
* key event handling
* @param event keyevent
*/
keypress(event: KeyboardEvent): void {
// hide if escape is pressed
if (event.key === 'Escape') {
this.props.onHide();
} else if (event.key === 'Enter') {
// call a parentsubmit if defined
if (this.props.ParentSubmit) this.props.ParentSubmit();
}
}
/**
* make the element drag and droppable
*/
dragElement(): void {
let xOld = 0, yOld = 0;
const elmnt = this.wrapperRef.current;
if (elmnt === null) return;
if (elmnt.firstChild === null) return;
(elmnt.firstChild as HTMLDivElement).onmousedown = dragMouseDown;
function dragMouseDown(e: MouseEvent): void {
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: MouseEvent): void {
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:
if (elmnt === null) return;
elmnt.style.top = (elmnt.offsetTop - dy) + 'px';
elmnt.style.left = (elmnt.offsetLeft - dx) + 'px';
}
function closeDragElement(): void {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
}
}
}
export default PopupBase;

View File

@ -0,0 +1,28 @@
import {shallow} from 'enzyme';
import React from 'react';
import SubmitPopup from './SubmitPopup';
describe('<SubmitPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<SubmitPopup/>);
wrapper.unmount();
});
it('test submit click', function () {
const func = jest.fn();
const wrapper = shallow(<SubmitPopup submit={() => func()}/>);
wrapper.find('Button').findWhere(p => p.props().title === 'Submit').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
it('test cancel click', function () {
const func = jest.fn();
const wrapper = shallow(<SubmitPopup onHide={() => func()}/>);
wrapper.find('Button').findWhere(p => p.props().title === 'Cancel').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,18 @@
import React from 'react';
import PopupBase from '../PopupBase';
import {Button} from '../../GPElements/Button';
interface props {
onHide: (_: void) => void;
submit: (_: void) => void;
}
export default function SubmitPopup(props: props): JSX.Element {
return (
<PopupBase title='Are you sure?' onHide={props.onHide} height='160px' width='300px'>
<Button title='Submit' color={{backgroundColor: 'green'}} onClick={(): void => props.submit()}/>
<Button title='Cancel' color={{backgroundColor: 'red'}} onClick={(): void => props.onHide()}/>
</PopupBase>
);
}

View File

@ -1,112 +0,0 @@
import React from "react";
import "./Preview.css";
import Player from "../../pages/Player/Player";
import VideoContainer from "../VideoContainer/VideoContainer";
class Preview extends React.Component {
constructor(props, context) {
super(props, context);
this.props = props;
this.state = {
previewpicture: null,
name: null
};
}
componentWillUnmount() {
this.setState({});
}
componentDidMount() {
this.setState({
previewpicture: null,
name: this.props.name
});
const updateRequest = new FormData();
updateRequest.append('action', 'readThumbnail');
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/videoload.php', {method: 'POST', body: updateRequest})
.then((response) => response.text()
.then((result) => {
this.setState(prevState => ({
...prevState.previewpicture, previewpicture: result
}));
}));
}
render() {
return (
<div className='videopreview' onClick={() => this.itemClick()}>
<div className='previewtitle'>{this.state.name}</div>
<div className='previewpic'>
<img className='previewimage'
src={this.state.previewpicture}
alt='Pic loading.'/>
</div>
<div className='previewbottom'>
</div>
</div>
);
}
itemClick() {
console.log("item clicked!" + this.state.name);
this.props.viewbinding.showVideo(<Player
viewbinding={this.props.viewbinding}
movie_id={this.props.movie_id}/>);
}
}
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() {
return (
<div className='videopreview tagpreview' onClick={() => this.itemClick()}>
<div className='tagpreviewtitle'>
{this.props.name}
</div>
</div>
);
}
itemClick() {
this.fetchVideoData(this.props.name);
}
}
export default Preview;

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

@ -0,0 +1,79 @@
import React from 'react';
import style from './Preview.module.css';
import {Spinner} from 'react-bootstrap';
import {Link} from 'react-router-dom';
import GlobalInfos from '../../utils/GlobalInfos';
import {APINode, callAPIPlain} from '../../utils/Api';
interface PreviewProps {
name: string;
movie_id: number;
}
interface PreviewState {
previewpicture: string | null;
}
/**
* Component for single preview tile
* floating side by side
*/
class Preview extends React.Component<PreviewProps, PreviewState> {
constructor(props: PreviewProps) {
super(props);
this.state = {
previewpicture: null
};
}
componentDidMount(): void {
callAPIPlain(APINode.Video, {action: 'readThumbnail', movieid: this.props.movie_id}, (result) => {
this.setState({
previewpicture: result
});
});
}
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<Link to={'/player/' + this.props.movie_id}>
<div className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}>
<div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div>
<div className={style.previewpic}>
{this.state.previewpicture !== null ?
<img className={style.previewimage}
src={this.state.previewpicture}
alt='Pic loading.'/> :
<span className={style.loadAnimation}><Spinner animation='border'/></span>}
</div>
<div className={style.previewbottom}>
</div>
</div>
</Link>
);
}
}
/**
* Component for a Tag-name tile (used in category page)
*/
export class TagPreview extends React.Component<{ name: string }> {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div
className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}>
<div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>
{this.props.name}
</div>
</div>
);
}
}
export default Preview;

View File

@ -1,61 +1,45 @@
import React from 'react'; import React from 'react';
import {shallow} from 'enzyme' import {shallow} from 'enzyme';
import Preview, {TagPreview} from "./Preview"; import Preview, {TagPreview} from './Preview';
describe('<Preview/>', function () { describe('<Preview/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
const wrapper = shallow(<Preview/>); const wrapper = shallow(<Preview movie_id={1}/>);
wrapper.unmount(); wrapper.unmount();
}); });
// check if preview title renders correctly
it('renders title', () => {
const wrapper = shallow(<Preview name='test'/>);
expect(wrapper.find('.previewtitle').text()).toBe('test');
});
it('click event triggered', () => {
const func = jest.fn();
const wrapper = shallow(<Preview/>);
wrapper.setProps({
viewbinding: {
showVideo: () => {
func()
}
}
});
wrapper.find('.videopreview').simulate('click');
//callback to open player should have called
expect(func).toHaveBeenCalledTimes(1);
});
it('picture rendered correctly', done => { it('picture rendered correctly', done => {
const mockSuccessResponse = 'testsrc'; const mockSuccessResponse = 'testsrc';
const mockJsonPromise = Promise.resolve(mockSuccessResponse); const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({ const mockFetchPromise = Promise.resolve({
text: () => mockJsonPromise, text: () => mockJsonPromise
}); });
global.fetch = jest.fn().mockImplementation(() => mockFetchPromise); global.fetch = jest.fn().mockImplementation(() => mockFetchPromise);
const wrapper = shallow(<Preview/>); const wrapper = shallow(<Preview name='test' movie_id={1}/>);
// now called 1 times // now called 1 times
expect(global.fetch).toHaveBeenCalledTimes(1); expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => { process.nextTick(() => {
// received picture should be rendered into wrapper // received picture should be rendered into wrapper
expect(wrapper.find(".previewimage").props().src).not.toBeNull(); expect(wrapper.find('.previewimage').props().src).not.toBeNull();
// check if preview title renders correctly
expect(wrapper.find('.previewtitle').text()).toBe('test');
global.fetch.mockClear(); global.fetch.mockClear();
done(); done();
}); });
}); });
it('spinner loads correctly', function () {
const wrapper = shallow(<Preview movie_id={1}/>);
// expect load animation to be visible
expect(wrapper.find('.loadAnimation')).toHaveLength(1);
});
}); });
describe('<TagPreview/>', function () { describe('<TagPreview/>', function () {
@ -69,39 +53,5 @@ describe('<TagPreview/>', function () {
const wrapper = shallow(<TagPreview name='test'/>); const wrapper = shallow(<TagPreview name='test'/>);
expect(wrapper.find('.tagpreviewtitle').text()).toBe('test'); expect(wrapper.find('.tagpreviewtitle').text()).toBe('test');
}); });
it('click event triggered', done => {
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(<TagPreview/>);
wrapper.setProps({
categorybinding: () => {
func()
}
});
// first call of fetch is getting of available tags
expect(global.fetch).toHaveBeenCalledTimes(0);
wrapper.find('.videopreview').simulate('click');
// now called 1 times
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
//callback to close window should have called
expect(func).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
}); });

View File

@ -1,12 +0,0 @@
import React from "react";
import "./SideBar.css"
class SideBar extends React.Component {
render() {
return (<div className='sideinfo'>
{this.props.children}
</div>);
}
}
export default SideBar;

View File

@ -1,24 +1,25 @@
.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;
}
.sideinfogeometry {
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,8 +1,8 @@
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';
describe('<SideBar/>', function () { describe('<SideBar/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -12,6 +12,16 @@ describe('<SideBar/>', function () {
it('renders childs correctly', function () { it('renders childs correctly', 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

@ -0,0 +1,49 @@
import React from 'react';
import style from './SideBar.module.css';
import GlobalInfos from '../../utils/GlobalInfos';
interface SideBarProps {
hiddenFrame?: boolean;
width?: string;
}
/**
* component for sidebar-info
*/
class SideBar extends React.Component<SideBarProps> {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
const classnn = style.sideinfogeometry + ' ' + (this.props.hiddenFrame === undefined ? style.sideinfo + ' ' + themeStyle.secbackground : '');
return (<div className={classnn} style={{width: this.props.width}}>
{this.props.children}
</div>);
}
}
/**
* The title of the sidebar
*/
export class SideBarTitle extends React.Component {
render(): JSX.Element {
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(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div
className={style.sidebarinfo + ' ' + themeStyle.thirdbackground + ' ' + themeStyle.lighttextcolor}>{this.props.children}</div>
);
}
}
export default SideBar;

View File

@ -1,21 +0,0 @@
import React from "react";
import "./Tag.css"
class Tag extends React.Component {
constructor(props, context) {
super(props, context);
this.props = props;
}
render() {
// todo onclick events correctlyy
return (
<button className='tagbtn' onClick={this.props.onClick}
data-testid="Test-Tag">{this.props.children}</button>
);
}
}
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

@ -1,17 +1,31 @@
import React from "react"; import React from 'react';
import Tag from './Tag' import Tag from './Tag';
import "@testing-library/jest-dom" import '@testing-library/jest-dom';
import {shallow} from 'enzyme' import {shallow} from 'enzyme';
describe('<Tag/>', function () { describe('<Tag/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
const wrapper = shallow(<Tag>test</Tag>); const wrapper = shallow(<Tag tagInfo={{tag_name: 'testname', tag_id: 1}}/>);
wrapper.unmount(); wrapper.unmount();
}); });
it('renders childs correctly', function () { it('renders childs correctly', function () {
const wrapper = shallow(<Tag>test</Tag>); const wrapper = shallow(<Tag tagInfo={{tag_name: 'test', tag_id: 1}}/>);
expect(wrapper.children().text()).toBe("test"); expect(wrapper.children().text()).toBe('test');
});
it('test custom onclick function', function () {
const func = jest.fn();
const wrapper = shallow(<Tag
tagInfo={{tag_name: 'test', tag_id: 1}}
onclick={() => {func();}}>test</Tag>);
expect(func).toBeCalledTimes(0);
wrapper.simulate('click');
expect(func).toBeCalledTimes(1);
}); });
}); });

47
src/elements/Tag/Tag.tsx Normal file
View File

@ -0,0 +1,47 @@
import React from 'react';
import styles from './Tag.module.css';
import {Link} from 'react-router-dom';
import {TagType} from '../../types/VideoTypes';
interface props {
onclick?: (_: string) => void
tagInfo: TagType
}
/**
* A Component representing a single Category tag
*/
class Tag extends React.Component<props> {
render(): JSX.Element {
if (this.props.onclick) {
return this.renderButton();
} else {
return (
<Link to={'/categories/' + this.props.tagInfo.tag_id}>
{this.renderButton()}
</Link>
);
}
}
renderButton(): JSX.Element {
return (
<button className={styles.tagbtn} onClick={(): void => this.TagClick()}
data-testid='Test-Tag'>{this.props.tagInfo.tag_name}</button>
);
}
/**
* click handling for a Tag
*/
TagClick(): void {
if (this.props.onclick) {
// call custom onclick handling
this.props.onclick(this.props.tagInfo.tag_name); // todo check if param is neccessary
return;
}
}
}
export default Tag;

View File

@ -1,77 +0,0 @@
import React from "react";
import Preview from "../Preview/Preview";
class VideoContainer extends React.Component {
constructor(props, context) {
super(props, context);
this.data = props.data;
this.state = {
loadeditems: [],
selectionnr: null
};
}
// stores current index of loaded elements
loadindex = 0;
componentDidMount() {
document.addEventListener('scroll', this.trackScrolling);
this.loadPreviewBlock(12);
}
render() {
return (
<div className='maincontent'>
{this.state.loadeditems.map(elem => (
<Preview
key={elem.movie_id}
name={elem.movie_name}
movie_id={elem.movie_id}
viewbinding={this.props.viewbinding}/>
))}
{/*todo css for no items to show*/}
{this.state.loadeditems.length === 0 ?
"no items to show!" : null}
</div>
);
}
componentWillUnmount() {
this.setState({});
document.removeEventListener('scroll', this.trackScrolling);
}
loadPreviewBlock(nr) {
console.log("loadpreviewblock called ...")
let ret = [];
for (let i = 0; i < nr; i++) {
// only add if not end
if (this.data.length > this.loadindex + i) {
ret.push(this.data[this.loadindex + i]);
}
}
this.setState({
loadeditems: [
...this.state.loadeditems,
...ret
]
});
this.loadindex += nr;
}
trackScrolling = () => {
// comparison if current scroll position is on bottom
// 200 stands for bottom offset to trigger load
if (window.innerHeight + document.documentElement.scrollTop + 200 >= document.documentElement.offsetHeight) {
this.loadPreviewBlock(8);
}
}
}
export default VideoContainer;

View File

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

View File

@ -1,6 +1,6 @@
import {shallow} from "enzyme"; import {shallow} from 'enzyme';
import React from "react"; import React from 'react';
import VideoContainer from "./VideoContainer"; import VideoContainer from './VideoContainer';
describe('<VideoContainer/>', function () { describe('<VideoContainer/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -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', () => {
@ -25,6 +25,6 @@ describe('<VideoContainer/>', function () {
it('no items available', () => { it('no items available', () => {
const wrapper = shallow(<VideoContainer data={[]}/>); const wrapper = shallow(<VideoContainer data={[]}/>);
expect(wrapper.find('Preview')).toHaveLength(0); expect(wrapper.find('Preview')).toHaveLength(0);
expect(wrapper.find(".maincontent").text()).toBe("no items to show!"); expect(wrapper.find('.maincontent').text()).toBe('no items to show!');
}); });
}); });

View File

@ -0,0 +1,97 @@
import React from 'react';
import Preview from '../Preview/Preview';
import style from './VideoContainer.module.css';
import {VideoTypes} from '../../types/ApiTypes';
interface props {
data: VideoTypes.VideoUnloadedType[]
}
interface state {
loadeditems: VideoTypes.VideoUnloadedType[];
selectionnr: number;
}
/**
* A videocontainer storing lots of Preview elements
* includes scroll handling and loading of preview infos
*/
class VideoContainer extends React.Component<props, state> {
// stores current index of loaded elements
loadindex: number = 0;
constructor(props: props) {
super(props);
this.state = {
loadeditems: [],
selectionnr: 0
};
}
componentDidMount(): void {
document.addEventListener('scroll', this.trackScrolling);
this.loadPreviewBlock(16);
}
render(): JSX.Element {
return (
<div className={style.maincontent}>
{this.state.loadeditems.map(elem => (
<Preview
key={elem.movie_id}
name={elem.movie_name}
movie_id={elem.movie_id}/>
))}
{/*todo css for no items to show*/}
{this.state.loadeditems.length === 0 ?
'no items to show!' : null}
{this.props.children}
</div>
);
}
componentWillUnmount(): void {
this.setState({});
// unbind scroll listener when unmounting component
document.removeEventListener('scroll', this.trackScrolling);
}
/**
* load previews to the container
* @param nr number of previews to load
*/
loadPreviewBlock(nr: number): void {
console.log('loadpreviewblock called ...');
let ret = [];
for (let i = 0; i < nr; i++) {
// only add if not end
if (this.props.data.length > this.loadindex + i) {
ret.push(this.props.data[this.loadindex + i]);
}
}
this.setState({
loadeditems: [
...this.state.loadeditems,
...ret
]
});
this.loadindex += nr;
}
/**
* scroll event handler -> load new previews if on bottom
*/
trackScrolling = (): void => {
// comparison if current scroll position is on bottom --> 200 is bottom offset to trigger load
if (window.innerHeight + document.documentElement.scrollTop + 200 >= document.documentElement.offsetHeight) {
this.loadPreviewBlock(8);
}
};
}
export default VideoContainer;

View File

@ -1,8 +1,10 @@
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';
// don't allow console logs within production env
global.console.log = process.env.NODE_ENV !== 'development' ? (s: string | number | boolean): void => {} : global.console.log;
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<App/> <App/>

View File

@ -0,0 +1,6 @@
.container {
float: left;
margin-right: 20px;
padding-left: 20px;
width: 70%;
}

View File

@ -0,0 +1,43 @@
import {shallow} from 'enzyme';
import React from 'react';
import ActorOverviewPage from './ActorOverviewPage';
describe('<ActorOverviewPage/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<ActorOverviewPage/>);
wrapper.unmount();
});
it('test inerstion of actor tiles', function () {
const wrapper = shallow(<ActorOverviewPage/>);
wrapper.setState({
actors: [{
thumbnail: '',
name: 'testname',
actor_id: 42
}]
});
expect(wrapper.find('ActorTile')).toHaveLength(1);
});
it('test newtagpopup visibility', function () {
const wrapper = shallow(<ActorOverviewPage/>);
expect(wrapper.find('NewActorPopup')).toHaveLength(0);
wrapper.find('SideBar').find('Button').simulate('click');
expect(wrapper.find('NewActorPopup')).toHaveLength(1);
});
it('test popup hiding', function () {
const wrapper = shallow(<ActorOverviewPage/>);
wrapper.setState({NActorPopupVisible: true});
wrapper.find('NewActorPopup').props().onHide();
expect(wrapper.find('NewActorPopup')).toHaveLength(0);
});
});

View File

@ -0,0 +1,59 @@
import React from 'react';
import {APINode, callAPI} from '../../utils/Api';
import {ActorType} from '../../types/VideoTypes';
import ActorTile from '../../elements/ActorTile/ActorTile';
import PageTitle from '../../elements/PageTitle/PageTitle';
import SideBar from '../../elements/SideBar/SideBar';
import style from './ActorOverviewPage.module.css';
import {Button} from '../../elements/GPElements/Button';
import NewActorPopup from '../../elements/Popups/NewActorPopup/NewActorPopup';
interface props {
}
interface state {
actors: ActorType[];
NActorPopupVisible: boolean
}
class ActorOverviewPage extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {
actors: [],
NActorPopupVisible: false
};
}
componentDidMount(): void {
this.fetchAvailableActors();
}
render(): JSX.Element {
return (
<>
<PageTitle title='Actors' subtitle={this.state.actors.length + ' Actors'}/>
<SideBar>
<Button title='Add Actor' onClick={(): void => this.setState({NActorPopupVisible: true})}/>
</SideBar>
<div className={style.container}>
{this.state.actors.map((el) => (<ActorTile key={el.actor_id} actor={el}/>))}
</div>
{this.state.NActorPopupVisible ?
<NewActorPopup onHide={(): void => {
this.setState({NActorPopupVisible: false});
this.fetchAvailableActors(); // refetch actors
}}/> : null}
</>
);
}
fetchAvailableActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, result => {
this.setState({actors: result});
});
}
}
export default ActorOverviewPage;

View File

@ -0,0 +1,9 @@
.pic {
margin-bottom: 25px;
text-align: center;
}
.overviewbutton {
float: right;
margin-top: 25px;
}

View File

@ -0,0 +1,27 @@
import {shallow} from 'enzyme';
import React from 'react';
import {ActorPage} from './ActorPage';
describe('<ActorPage/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<ActorPage match={{params: {id: 10}}}/>);
wrapper.unmount();
});
it('fetch infos', function () {
callAPIMock({
videos: [{
movie_id: 0,
movie_name: 'test'
}], info: {
thumbnail: '',
name: '',
actor_id: 0
}
});
const wrapper = shallow(<ActorPage match={{params: {id: 10}}}/>);
expect(wrapper.find('VideoContainer')).toHaveLength(1);
});
});

View File

@ -0,0 +1,81 @@
import React from 'react';
import PageTitle from '../../elements/PageTitle/PageTitle';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faUser} from '@fortawesome/free-solid-svg-icons';
import style from './ActorPage.module.css';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import {APINode, callAPI} from '../../utils/Api';
import {ActorType} from '../../types/VideoTypes';
import {Link, withRouter} from 'react-router-dom';
import {RouteComponentProps} from 'react-router';
import {Button} from '../../elements/GPElements/Button';
import {ActorTypes, VideoTypes} from '../../types/ApiTypes';
interface state {
data: VideoTypes.VideoUnloadedType[],
actor: ActorType
}
/**
* empty default props with id in url
*/
interface props extends RouteComponentProps<{ id: string }> {
}
/**
* info page about a specific actor and a list of all its videos
*/
export class ActorPage extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {data: [], actor: {actor_id: 0, name: '', thumbnail: ''}};
}
render(): JSX.Element {
return (
<>
<PageTitle title={this.state.actor.name} subtitle={this.state.data ? this.state.data.length + ' videos' : null}>
<span className={style.overviewbutton}>
<Link to='/actors'>
<Button onClick={(): void => {}} title='Go to Actor overview'/>
</Link>
</span>
</PageTitle>
<SideBar>
<div className={style.pic}>
<FontAwesomeIcon style={{color: 'white'}} icon={faUser} size='10x'/>
</div>
<SideBarTitle>Attention: This is an early preview!</SideBarTitle>
</SideBar>
{this.state.data.length !== 0 ?
<VideoContainer
data={this.state.data}/> :
<div>No Data found!</div>}
</>
);
}
componentDidMount(): void {
this.getActorInfo();
}
/**
* request more actor info from backend
*/
getActorInfo(): void {
callAPI(APINode.Actor, {
action: 'getActorInfo',
actorid: this.props.match.params.id
}, (result: ActorTypes.videofetchresult) => {
this.setState({
data: result.videos ? result.videos : [],
actor: result.info
});
});
}
}
export default withRouter(ActorPage);

View File

@ -1,109 +0,0 @@
import React from "react";
import SideBar from "../../elements/SideBar/SideBar";
import Tag from "../../elements/Tag/Tag";
import {TagPreview} from "../../elements/Preview/Preview";
import NewTagPopup from "../../elements/NewTagPopup/NewTagPopup";
class CategoryPage extends React.Component {
constructor(props, context) {
super(props, context);
this.props = props;
this.state = {
loadedtags: [],
selected: null
};
}
componentDidMount() {
this.loadTags();
}
render() {
return (
<>
<div className='pageheader'>
<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 ?
(<div className='maincontent'>
{this.state.loadedtags ?
this.state.loadedtags.map((m) => (
<TagPreview
key={m.tag_name}
name={m.tag_name}
tag_id={m.tag_id}
viewbinding={this.props.viewbinding}
categorybinding={this.setPage}/>
)) :
"loading"}
</div>) :
<>
{this.selectionelements}
<button data-testid='backbtn' className="btn btn-success"
onClick={this.loadCategoryPageDefault}>Back
</button>
</>
}
{this.state.popupvisible ?
<NewTagPopup show={this.state.popupvisible}
onHide={() => {
this.setState({popupvisible: false});
this.loadTags();
}}/> :
null
}
</>
);
}
setPage = (element, tagname) => {
this.selectionelements = element;
this.setState({selected: tagname});
};
loadCategoryPageDefault = () => {
this.setState({selected: null});
};
/**
* load all available tags from db.
*/
loadTags() {
const updateRequest = new FormData();
updateRequest.append('action', 'getAllTags');
// fetch all videos available
fetch('/api/Tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
this.setState({loadedtags: result});
}))
.catch(() => {
console.log("no connection to backend");
});
}
}
export default CategoryPage;

View File

@ -1,107 +1,10 @@
import {shallow, mount} from "enzyme"; import {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 () {
const wrapper = shallow(<CategoryPage/>); const wrapper = shallow(<CategoryPage/>);
wrapper.unmount(); wrapper.unmount();
}); });
it('test tag fetch call', done => {
global.fetch = prepareFetchApi(["first", "second"]);
const wrapper = shallow(<CategoryPage/>);
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
//callback to close window should have called
expect(wrapper.state().loadedtags.length).toBe(2);
global.fetch.mockClear();
done();
});
});
it('test errored fetch call', done => {
global.fetch = prepareFetchApi({});
let message;
global.console.log = jest.fn((m) => {
message = m;
})
const wrapper = shallow(<CategoryPage/>);
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
//callback to close window should have called
expect(message).toBe("no connection to backend");
global.fetch.mockClear();
done();
});
});
it('test new tag popup', function () {
const wrapper = mount(<CategoryPage/>);
expect(wrapper.find("NewTagPopup")).toHaveLength(0);
wrapper.find('[data-testid="btnaddtag"]').simulate('click');
// newtagpopup should be showing now
expect(wrapper.find("NewTagPopup")).toHaveLength(1);
// click close button in modal
wrapper.find(".modal-header").find("button").simulate("click");
expect(wrapper.find("NewTagPopup")).toHaveLength(0);
});
it('test setpage callback', done => {
global.fetch = prepareFetchApi([{}, {}]);
const wrapper = mount(<CategoryPage/>);
wrapper.setState({
loadedtags: [
{
tag_name: "testname",
tag_id: 42
}
]
});
wrapper.find("TagPreview").find("div").first().simulate("click");
process.nextTick(() => {
// expect callback to have loaded correct tag
expect(wrapper.state().selected).toBe("testname");
// expect to receive a videocontainer with simulated data
expect(wrapper.instance().selectionelements).toMatchObject(<VideoContainer data={[{}, {}]}/>);
global.fetch.mockClear();
done();
});
});
it('test back to category view callback', function () {
const wrapper = shallow(<CategoryPage/>);
wrapper.setState({
selected: "test"
});
expect(wrapper.state().selected).not.toBeNull();
wrapper.find('[data-testid="backbtn"]').simulate("click");
expect(wrapper.state().selected).toBeNull();
});
}); });

View File

@ -0,0 +1,25 @@
import React from 'react';
import {Route, Switch} from 'react-router-dom';
import {CategoryViewWR} from './CategoryView';
import TagView from './TagView';
/**
* Component for Category Page
* Contains a Tag Overview and loads specific Tag videos in VideoContainer
*/
class CategoryPage extends React.Component {
render(): JSX.Element {
return (
<Switch>
<Route path='/categories/:id'>
<CategoryViewWR/>
</Route>
<Route path='/categories'>
<TagView/>
</Route>
</Switch>
);
}
}
export default CategoryPage;

View File

@ -0,0 +1,74 @@
import {shallow} from 'enzyme';
import React from 'react';
import {CategoryView} from './CategoryView';
describe('<CategoryView/>', function () {
function instance() {
return shallow(<CategoryView match={{params: {id: 10}}} history={{push: jest.fn()}}/>);
}
it('renders without crashing ', function () {
const wrapper = instance();
wrapper.unmount();
});
it('test backbutton', function () {
const wrapper = instance();
const func = jest.fn();
wrapper.setProps({history: {push: func}});
expect(func).toHaveBeenCalledTimes(0);
wrapper.find('button').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
it('test delete of tag', function () {
const wrapper = instance();
callAPIMock({result: 'success'});
// simulate button click
wrapper.find('Button').props().onClick();
expect(wrapper.instance().props.history.push).toHaveBeenCalledTimes(1);
});
it('test delete of non empty tag', function () {
const wrapper = instance();
callAPIMock({result: 'not empty tag'});
// simulate button click
wrapper.find('Button').props().onClick();
// expect SubmitPopup showing
expect(wrapper.find('SubmitPopup')).toHaveLength(1);
// mock deleteTag function
wrapper.instance().deleteTag = jest.fn((v) => {});
// simulate submit
wrapper.find('SubmitPopup').props().submit();
// expect deleteTag function to have been called with force parameter
expect(wrapper.instance().deleteTag).toHaveBeenCalledWith(true);
});
it('test cancel of ', function () {
const wrapper = instance();
callAPIMock({result: 'not empty tag'});
// simulate button click
wrapper.find('Button').props().onClick();
// expect SubmitPopup showing
expect(wrapper.find('SubmitPopup')).toHaveLength(1);
// mock deleteTag function
wrapper.instance().deleteTag = jest.fn((v) => {});
// simulate submit
wrapper.find('SubmitPopup').props().onHide();
// expect deleteTag function to have been called with force parameter
expect(wrapper.instance().deleteTag).toHaveBeenCalledTimes(0);
});
});

View File

@ -0,0 +1,123 @@
import {RouteComponentProps} from 'react-router';
import React from 'react';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import {APINode, callAPI} from '../../utils/Api';
import {withRouter} from 'react-router-dom';
import {VideoTypes} from '../../types/ApiTypes';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import {DefaultTags, GeneralSuccess} from '../../types/GeneralTypes';
import {Button} from '../../elements/GPElements/Button';
import SubmitPopup from '../../elements/Popups/SubmitPopup/SubmitPopup';
interface CategoryViewProps extends RouteComponentProps<{ id: string }> {}
interface CategoryViewState {
loaded: boolean;
submitForceDelete: boolean;
}
/**
* plain class (for unit testing only)
*/
export class CategoryView extends React.Component<CategoryViewProps, CategoryViewState> {
private videodata: VideoTypes.VideoUnloadedType[] = [];
constructor(props: CategoryViewProps) {
super(props);
this.state = {
loaded: false,
submitForceDelete: false
};
}
componentDidMount(): void {
this.fetchVideoData(parseInt(this.props.match.params.id));
}
componentDidUpdate(prevProps: Readonly<CategoryViewProps>, prevState: Readonly<CategoryViewState>): void {
// trigger video refresh if id changed
if (prevProps.match.params.id !== this.props.match.params.id) {
this.setState({loaded: false});
this.fetchVideoData(parseInt(this.props.match.params.id));
}
}
render(): JSX.Element {
return (
<>
<PageTitle
title='Categories'
subtitle={this.videodata.length + ' Videos'}/>
<SideBar>
<SideBarTitle>Default Tags:</SideBarTitle>
<Tag tagInfo={DefaultTags.all}/>
<Tag tagInfo={DefaultTags.fullhd}/>
<Tag tagInfo={DefaultTags.hd}/>
<Tag tagInfo={DefaultTags.lowq}/>
<Line/>
<Button title='Delete Tag' onClick={(): void => {this.deleteTag(false);}} color={{backgroundColor: 'red'}}/>
</SideBar>
{this.state.loaded ?
<VideoContainer
data={this.videodata}/> : null}
<button data-testid='backbtn' className='btn btn-success'
onClick={(): void => {
this.props.history.push('/categories');
}}>Back to Categories
</button>
{this.handlePopups()}
</>
);
}
private handlePopups(): JSX.Element {
if (this.state.submitForceDelete) {
return (<SubmitPopup
onHide={(): void => this.setState({submitForceDelete: false})}
submit={(): void => {this.deleteTag(true);}}/>);
} else {
return <></>;
}
}
/**
* fetch data for a specific tag from backend
* @param id tagid
*/
private fetchVideoData(id: number): void {
callAPI<VideoTypes.VideoUnloadedType[]>(APINode.Video, {action: 'getMovies', tag: id}, result => {
this.videodata = result;
this.setState({loaded: true});
});
}
/**
* delete the current tag
*/
private deleteTag(force: boolean): void {
callAPI<GeneralSuccess>(APINode.Tags, {
action: 'deleteTag',
tagId: parseInt(this.props.match.params.id),
force: force
}, result => {
console.log(result.result);
if (result.result === 'success') {
this.props.history.push('/categories');
} else if (result.result === 'not empty tag') {
// show submisison tag to ask if really delete
this.setState({submitForceDelete: true});
}
});
}
}
/**
* export with react Router wrapped (default use)
*/
export const CategoryViewWR = withRouter(CategoryView);

View File

@ -0,0 +1,43 @@
import {shallow} from 'enzyme';
import React from 'react';
import TagView from './TagView';
describe('<TagView/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<TagView/>);
wrapper.unmount();
});
it('test Tag insertion', function () {
const wrapper = shallow(<TagView/>);
wrapper.setState({loadedtags: [{tag_name: 'test', tag_id: 42}]});
expect(wrapper.find('TagPreview')).toHaveLength(1);
});
it('test new tag popup', function () {
const wrapper = shallow(<TagView/>);
expect(wrapper.find('NewTagPopup')).toHaveLength(0);
wrapper.find('[data-testid="btnaddtag"]').simulate('click');
// newtagpopup should be showing now
expect(wrapper.find('NewTagPopup')).toHaveLength(1);
});
it('test add popup', function () {
const wrapper = shallow(<TagView/>);
expect(wrapper.find('NewTagPopup')).toHaveLength(0);
wrapper.setState({popupvisible: true});
expect(wrapper.find('NewTagPopup')).toHaveLength(1);
});
it('test hiding of popup', function () {
const wrapper = shallow(<TagView/>);
wrapper.setState({popupvisible: true});
wrapper.find('NewTagPopup').props().onHide();
expect(wrapper.find('NewTagPopup')).toHaveLength(0);
});
});

View File

@ -0,0 +1,90 @@
import {TagType} from '../../types/VideoTypes';
import React from 'react';
import videocontainerstyle from '../../elements/VideoContainer/VideoContainer.module.css';
import {Link} from 'react-router-dom';
import {TagPreview} from '../../elements/Preview/Preview';
import {APINode, callAPI} from '../../utils/Api';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import {DefaultTags} from '../../types/GeneralTypes';
import NewTagPopup from '../../elements/Popups/NewTagPopup/NewTagPopup';
interface TagViewState {
loadedtags: TagType[];
popupvisible: boolean;
}
interface props {}
class TagView extends React.Component<props, TagViewState> {
constructor(props: props) {
super(props);
this.state = {
loadedtags: [],
popupvisible: false
};
}
componentDidMount(): void {
this.loadTags();
}
render(): JSX.Element {
return (
<>
<PageTitle
title='Categories'
subtitle={this.state.loadedtags.length + ' different Tags'}/>
<SideBar>
<SideBarTitle>Default Tags:</SideBarTitle>
<Tag tagInfo={DefaultTags.all}/>
<Tag tagInfo={DefaultTags.fullhd}/>
<Tag tagInfo={DefaultTags.hd}/>
<Tag tagInfo={DefaultTags.lowq}/>
<Line/>
<button data-testid='btnaddtag' className='btn btn-success' onClick={(): void => {
this.setState({popupvisible: true});
}}>Add a new Tag!
</button>
</SideBar>
<div className={videocontainerstyle.maincontent}>
{this.state.loadedtags ?
this.state.loadedtags.map((m) => (
<Link to={'/categories/' + m.tag_id} key={m.tag_id}>
<TagPreview name={m.tag_name}/></Link>
)) :
'loading'}
</div>
{this.handlePopups()}
</>
);
}
/**
* load all available tags from db.
*/
loadTags(): void {
callAPI<TagType[]>(APINode.Tags, {action: 'getAllTags'}, result => {
this.setState({loadedtags: result});
});
}
private handlePopups(): JSX.Element {
if (this.state.popupvisible) {
return (
<NewTagPopup onHide={(): void => {
this.setState({popupvisible: false});
this.loadTags();
}}/>
);
} else {
return (<></>);
}
}
}
export default TagView;

Some files were not shown because too many files have changed in this diff Show More