Merge branch 'master' into threedotsonvideohover

# Conflicts:
#	src/elements/Preview/Preview.tsx
#	src/elements/Tag/Tag.tsx
#	src/pages/Player/Player.tsx
This commit is contained in:
lukas 2021-07-29 19:49:56 +02:00
commit c13e758301
109 changed files with 7669 additions and 3674 deletions

292
.eslintrc.js Normal file
View File

@ -0,0 +1,292 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
module.exports = {
env: {
es6: true
},
parserOptions: {
sourceType: 'module'
},
extends: [
'plugin:prettier/recommended', // https://github.com/prettier/eslint-plugin-prettier#recommended-configuration
'prettier'
],
plugins: ['eslint-comments', 'react', 'react-hooks', 'jest'],
settings: {
react: {
version: 'detect'
}
},
ignorePatterns: ['node_modules/', '**/*.js'],
overrides: [
{
files: ['*.ts', '*.tsx'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint/eslint-plugin'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_'}],
'no-unused-vars': 'off'
}
}
],
// Map from global var to bool specifying if it can be redefined
globals: {
jest: true,
__DEV__: true,
__dirname: false,
__fbBatchedBridgeConfig: false,
AbortController: false,
alert: false,
cancelAnimationFrame: false,
cancelIdleCallback: false,
clearImmediate: true,
clearInterval: false,
clearTimeout: false,
console: false,
document: false,
ErrorUtils: false,
escape: false,
Event: false,
EventTarget: false,
exports: false,
fetch: false,
FileReader: false,
FormData: false,
global: false,
Headers: false,
Intl: false,
Map: true,
module: false,
navigator: false,
process: false,
Promise: true,
requestAnimationFrame: true,
requestIdleCallback: true,
require: false,
Set: true,
setImmediate: true,
setInterval: false,
setTimeout: false,
URL: false,
URLSearchParams: false,
WebSocket: true,
window: false,
XMLHttpRequest: false,
JSX: true,
KeyboardEvent: true,
MouseEvent: true,
Node: true,
HTMLDivElement: true,
HTMLInputElement: true
},
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "error",
// General
'comma-dangle': [1, 'never'], // allow or disallow trailing commas
'no-cond-assign': 1, // disallow assignment in conditional expressions
'no-console': 0, // disallow use of console (off by default in the node environment)
'no-const-assign': 2, // disallow assignment to const-declared variables
'no-constant-condition': 0, // disallow use of constant expressions in conditions
'no-control-regex': 1, // disallow control characters in regular expressions
'no-debugger': 1, // disallow use of debugger
'no-dupe-class-members': 2, // Disallow duplicate name in class members
'no-dupe-keys': 2, // disallow duplicate keys when creating object literals
'no-empty': 0, // disallow empty statements
'no-ex-assign': 1, // disallow assigning to the exception in a catch block
'no-extra-boolean-cast': 1, // disallow double-negation boolean casts in a boolean context
'no-extra-parens': 0, // disallow unnecessary parentheses (off by default)
'no-extra-semi': 1, // disallow unnecessary semicolons
'no-func-assign': 1, // disallow overwriting functions written as function declarations
'no-inner-declarations': 0, // disallow function or variable declarations in nested blocks
'no-invalid-regexp': 1, // disallow invalid regular expression strings in the RegExp constructor
'no-negated-in-lhs': 1, // disallow negation of the left operand of an in expression
'no-obj-calls': 1, // disallow the use of object properties of the global object (Math and JSON) as functions
'no-regex-spaces': 1, // disallow multiple spaces in a regular expression literal
'no-reserved-keys': 0, // disallow reserved words being used as object literal keys (off by default)
'no-sparse-arrays': 1, // disallow sparse arrays
'no-unreachable': 2, // disallow unreachable statements after a return, throw, continue, or break statement
'use-isnan': 1, // disallow comparisons with the value NaN
'valid-jsdoc': 0, // Ensure JSDoc comments are valid (off by default)
'valid-typeof': 1, // Ensure that the results of typeof are compared against a valid string
// Best Practices
// These are rules designed to prevent you from making mistakes. They either prescribe a better way of doing something or help you avoid footguns.
'block-scoped-var': 0, // treat var statements as if they were block scoped (off by default)
complexity: 0, // specify the maximum cyclomatic complexity allowed in a program (off by default)
'consistent-return': 0, // require return statements to either always or never specify values
curly: 1, // specify curly brace conventions for all control statements
'default-case': 0, // require default case in switch statements (off by default)
'dot-notation': 1, // encourages use of dot notation whenever possible
eqeqeq: [1, 'allow-null'], // require the use of === and !==
'guard-for-in': 0, // make sure for-in loops have an if statement (off by default)
'no-alert': 1, // disallow the use of alert, confirm, and prompt
'no-caller': 1, // disallow use of arguments.caller or arguments.callee
'no-div-regex': 1, // disallow division operators explicitly at beginning of regular expression (off by default)
'no-else-return': 0, // disallow else after a return in an if (off by default)
'no-eq-null': 0, // disallow comparisons to null without a type-checking operator (off by default)
'no-eval': 2, // disallow use of eval()
'no-extend-native': 1, // disallow adding to native types
'no-extra-bind': 1, // disallow unnecessary function binding
'no-fallthrough': 1, // disallow fallthrough of case statements
'no-floating-decimal': 1, // disallow the use of leading or trailing decimal points in numeric literals (off by default)
'no-implied-eval': 1, // disallow use of eval()-like methods
'no-labels': 1, // disallow use of labeled statements
'no-iterator': 1, // disallow usage of __iterator__ property
'no-lone-blocks': 1, // disallow unnecessary nested blocks
'no-loop-func': 0, // disallow creation of functions within loops
'no-multi-str': 0, // disallow use of multiline strings
'no-native-reassign': 0, // disallow reassignments of native objects
'no-new': 1, // disallow use of new operator when not part of the assignment or comparison
'no-new-func': 2, // disallow use of new operator for Function object
'no-new-wrappers': 1, // disallows creating new instances of String,Number, and Boolean
'no-octal': 1, // disallow use of octal literals
'no-octal-escape': 1, // disallow use of octal escape sequences in string literals, such as var foo = "Copyright \251";
'no-proto': 1, // disallow usage of __proto__ property
'no-redeclare': 0, // disallow declaring the same variable more then once
'no-return-assign': 1, // disallow use of assignment in return statement
'no-script-url': 1, // disallow use of javascript: urls.
'no-self-compare': 1, // disallow comparisons where both sides are exactly the same (off by default)
'no-sequences': 1, // disallow use of comma operator
'no-unused-expressions': 0, // disallow usage of expressions in statement position
'no-useless-escape': 1, // disallow escapes that don't have any effect in literals
'no-void': 1, // disallow use of void operator (off by default)
'no-warning-comments': 0, // disallow usage of configurable warning terms in comments": 1, // e.g. TODO or FIXME (off by default)
'no-with': 1, // disallow use of the with statement
radix: 1, // require use of the second argument for parseInt() (off by default)
'semi-spacing': 1, // require a space after a semi-colon
'vars-on-top': 0, // requires to declare all vars on top of their containing scope (off by default)
'wrap-iife': 0, // require immediate function invocation to be wrapped in parentheses (off by default)
yoda: 1, // require or disallow Yoda conditions
// Variables
// These rules have to do with variable declarations.
'no-catch-shadow': 1, // disallow the catch clause parameter name being the same as a variable in the outer scope (off by default in the node environment)
'no-delete-var': 1, // disallow deletion of variables
'no-label-var': 1, // disallow labels that share a name with a variable
'no-shadow': 1, // disallow declaration of variables already declared in the outer scope
'no-shadow-restricted-names': 1, // disallow shadowing of names such as arguments
'no-undef': 2, // disallow use of undeclared variables unless mentioned in a /*global */ block
'no-undefined': 0, // disallow use of undefined variable (off by default)
'no-undef-init': 1, // disallow use of undefined when initializing variables
'no-unused-vars': [1, {vars: 'all', args: 'none', ignoreRestSiblings: true}], // disallow declaration of variables that are not used in the code
'no-use-before-define': 0, // disallow use of variables before they are defined
// Node.js
// These rules are specific to JavaScript running on Node.js.
'handle-callback-err': 1, // enforces error handling in callbacks (off by default) (on by default in the node environment)
'no-mixed-requires': 1, // disallow mixing regular variable and require declarations (off by default) (on by default in the node environment)
'no-new-require': 1, // disallow use of new operator with the require function (off by default) (on by default in the node environment)
'no-path-concat': 1, // disallow string concatenation with __dirname and __filename (off by default) (on by default in the node environment)
'no-process-exit': 0, // disallow process.exit() (on by default in the node environment)
'no-restricted-modules': 1, // restrict usage of specified node modules (off by default)
'no-sync': 0, // disallow use of synchronous methods (off by default)
// ESLint Comments Plugin
// The following rules are made available via `eslint-plugin-eslint-comments`
'eslint-comments/no-aggregating-enable': 1, // disallows eslint-enable comments for multiple eslint-disable comments
'eslint-comments/no-unlimited-disable': 1, // disallows eslint-disable comments without rule names
'eslint-comments/no-unused-disable': 1, // disallow disables that don't cover any errors
'eslint-comments/no-unused-enable': 1, // // disallow enables that don't enable anything or enable rules that weren't disabled
// Stylistic Issues
// These rules are purely matters of style and are quite subjective.
'key-spacing': 0,
'keyword-spacing': 1, // enforce spacing before and after keywords
'jsx-quotes': [1, 'prefer-single'], // enforces the usage of double quotes for all JSX attribute values which doesnt contain a double quote
'comma-spacing': 0,
'no-multi-spaces': 0,
'brace-style': 0, // enforce one true brace style (off by default)
camelcase: 1, // require camel case names
'consistent-this': 1, // enforces consistent naming when capturing the current execution context (off by default)
'eol-last': 1, // enforce newline at the end of file, with no multiple empty lines
'func-names': 0, // require function expressions to have a name (off by default)
'func-style': 0, // enforces use of function declarations or expressions (off by default)
'new-cap': 0, // require a capital letter for constructors
'new-parens': 1, // disallow the omission of parentheses when invoking a constructor with no arguments
'no-nested-ternary': 0, // disallow nested ternary expressions (off by default)
'no-array-constructor': 1, // disallow use of the Array constructor
'no-empty-character-class': 1, // disallow the use of empty character classes in regular expressions
'no-lonely-if': 0, // disallow if as the only statement in an else block (off by default)
'no-new-object': 1, // disallow use of the Object constructor
'no-spaced-func': 1, // disallow space between function identifier and application
'no-ternary': 0, // disallow the use of ternary operators (off by default)
'no-trailing-spaces': 1, // disallow trailing whitespace at the end of lines
'no-underscore-dangle': 0, // disallow dangling underscores in identifiers
'no-mixed-spaces-and-tabs': 1, // disallow mixed spaces and tabs for indentation
quotes: [1, 'single', 'avoid-escape'], // specify whether double or single quotes should be used
'quote-props': 0, // require quotes around object literal property names (off by default)
semi: 1, // require or disallow use of semicolons instead of ASI
'sort-vars': 0, // sort variables within the same declaration block (off by default)
'space-in-brackets': 0, // require or disallow spaces inside brackets (off by default)
'space-in-parens': 0, // require or disallow spaces inside parentheses (off by default)
'space-infix-ops': 1, // require spaces around operators
'space-unary-ops': [1, {words: true, nonwords: false}], // require or disallow spaces before/after unary operators (words on by default, nonwords off by default)
'max-nested-callbacks': 0, // specify the maximum depth callbacks can be nested (off by default)
'one-var': 0, // allow just one var statement per function (off by default)
'wrap-regex': 0, // require regex literals to be wrapped in parentheses (off by default)
// Legacy
// The following rules are included for compatibility with JSHint and JSLint. While the names of the rules may not match up with the JSHint/JSLint counterpart, the functionality is the same.
'max-depth': 0, // specify the maximum depth that blocks can be nested (off by default)
'max-len': 0, // specify the maximum length of a line in your program (off by default)
'max-params': 0, // limits the number of parameters that can be used in the function declaration. (off by default)
'max-statements': 0, // specify the maximum number of statement allowed in a function (off by default)
'no-bitwise': 1, // disallow use of bitwise operators (off by default)
'no-plusplus': 0, // disallow use of unary operators, ++ and -- (off by default)
// React Plugin
// The following rules are made available via `eslint-plugin-react`.
'react/display-name': 0,
'react/jsx-boolean-value': 0,
'react/jsx-no-comment-textnodes': 2,
'react/jsx-no-duplicate-props': 2,
'react/jsx-no-undef': 2,
'react/jsx-sort-props': 0,
'react/jsx-uses-react': 1,
'react/jsx-uses-vars': 1,
'react/no-did-mount-set-state': 1,
'react/no-did-update-set-state': 1,
'react/no-multi-comp': 0,
'react/no-string-refs': 1,
'react/no-unknown-property': 0,
'react/prop-types': 0,
'react/react-in-jsx-scope': 1,
'react/self-closing-comp': 1,
'react/wrap-multilines': 0,
// React-Hooks Plugin
// The following rules are made available via `eslint-plugin-react-hooks`
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// Jest Plugin
// The following rules are made available via `eslint-plugin-jest`.
'jest/no-disabled-tests': 1,
'jest/no-focused-tests': 1,
'jest/no-identical-title': 1,
'jest/valid-expect': 1
}
};

View File

@ -1,23 +1,19 @@
image: node:14 image: node:14
stages: stages:
- build - build_frontend
- build_backend
- test - test
- packaging - packaging
- deploy - deploy
include:
- template: Code-Quality.gitlab-ci.yml
variables:
SAST_DISABLE_DIND: "true"
Minimize_Frontend: Minimize_Frontend:
stage: build stage: build_frontend
before_script: before_script:
- yarn install --cache-folder .yarn - yarn install --cache-folder .yarn
script: script:
- yarn run build - yarn run build
- rm build/*/*/*.map
artifacts: artifacts:
expire_in: 2 days expire_in: 2 days
paths: paths:
@ -30,11 +26,15 @@ Minimize_Frontend:
Build_Backend: Build_Backend:
image: golang:latest image: golang:latest
stage: build stage: build_backend
script: script:
- cd apiGo - cd apiGo
- go build -v -o openmediacenter - go build -v -o openmediacenter
- env GOOS=windows GOARCH=amd64 go build -v -o openmediacenter.exe - cp -r ../build/ ./static/
- go build -v -tags static -o openmediacenter_full
- env GOOS=windows GOARCH=amd64 go build -v -tags static -o openmediacenter.exe
needs:
- Minimize_Frontend
artifacts: artifacts:
expire_in: 2 days expire_in: 2 days
paths: paths:
@ -46,6 +46,7 @@ Frontend_Tests:
- yarn install --cache-folder .yarn - yarn install --cache-folder .yarn
script: script:
- yarn run test - yarn run test
needs: []
artifacts: artifacts:
reports: reports:
junit: junit:
@ -63,14 +64,27 @@ Backend_Tests:
- cd apiGo - cd apiGo
- go get -u github.com/jstemmer/go-junit-report - go get -u github.com/jstemmer/go-junit-report
- go test -v ./... 2>&1 | go-junit-report -set-exit-code > report.xml - go test -v ./... 2>&1 | go-junit-report -set-exit-code > report.xml
needs: []
artifacts: artifacts:
when: always when: always
reports: reports:
junit: ./apiGo/report.xml junit: ./apiGo/report.xml
code_quality: lint:
tags: stage: test
- dind before_script:
- yarn install --cache-folder .yarn
script:
- yarn run lint
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .yarn/
- ./node_modules/
artifacts:
reports:
codequality: gl-codequality.json
needs: []
Debian_Server: Debian_Server:
stage: packaging stage: packaging
@ -98,7 +112,7 @@ Debian_Server:
Test_Server: Test_Server:
stage: deploy stage: deploy
image: luki42/alpineopenssh:latest image: luki42/ssh:latest
needs: needs:
- Frontend_Tests - Frontend_Tests
- Backend_Tests - Backend_Tests
@ -107,7 +121,7 @@ Test_Server:
- master - master
script: script:
- eval $(ssh-agent -s) - eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY") - echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh - mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config' - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- scp deb/OpenMediaCenter-*.deb root@192.168.0.42:/tmp/ - scp deb/OpenMediaCenter-*.deb root@192.168.0.42:/tmp/

10
.prettierrc.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
bracketSpacing: false,
jsxBracketSameLine: true,
singleQuote: true,
tabWidth: 4,
trailingComma: 'none',
printWidth: 135,
semi: true,
jsxSingleQuote: true
};

View File

@ -22,24 +22,30 @@ and in dark mode:
![](https://i.ibb.co/xzhdsbJ/Screenshot-20200812-172926.png) ![](https://i.ibb.co/xzhdsbJ/Screenshot-20200812-172926.png)
## Installation ## Installation
First of all clone the repository.
`git clone https://gitlab.heili.eu/lukas/openmediacenter.git` Download the latest release .deb file from the Releases page and install it via `apt install ./OpenMediaCenter-0.1.x_amd64.deb`
Then build a production build via npm. Now you could optionally check if the service is up and running: `systemctl status OpenMediaCenter`
`npm run build`
Afterwards you can copy the content of the generated `build` folder as well as the `api` folder to your webserver root.
You need also to setup a Database with the structure described in [SQL Style Reference](https://gitlab.heili.eu/lukas/openmediacenter/-/blob/master/database.sql).
The login data to this database needs to be specified in the `api/Database.php` file.
## Usage ## Usage
Now you can access your MediaCenter via your servers global ip (: Now you can access your MediaCenter via your servers global ip on port 8080 (:
At the settings tab you can set the correct videopath on server and click reindex afterwards. At the settings tab you can set the correct videopath on server and click reindex afterwards.
## Development
Build and start the go backend:
`go build`
Start frontend dev server:
`npm start`
### Environent Variables:
`REACT_APP_CUST_BACK_DOMAIN` :: Set a custom movie domain
## Contact ## Contact
Any contribution is appreciated. Any contribution is appreciated.
Feel free to contact me (lukas.heiligenbrunner@gmail.com), open an issue or request a new feature. Feel free to contact me (lukas.heiligenbrunner@gmail.com), open an issue or request a new feature.

View File

@ -12,32 +12,83 @@ func AddActorsHandlers() {
} }
func getActorsFromDB() { func getActorsFromDB() {
AddHandler("getAllActors", ActorNode, nil, func() []byte { /**
* @api {post} /api/actor [getAllActors]
* @apiDescription Get all available Actors
* @apiName getAllActors
* @apiGroup Actor
*
* @apiSuccess {Object[]} . Array of Actors available
* @apiSuccess {uint32} .ActorId Actor Id
* @apiSuccess {string} .Name Actor Name
* @apiSuccess {string} .Thumbnail Portrait Thumbnail
*/
AddHandler("getAllActors", ActorNode, func(info *HandlerInfo) []byte {
query := "SELECT actor_id, name, thumbnail FROM actors" query := "SELECT actor_id, name, thumbnail FROM actors"
return jsonify(readActorsFromResultset(database.Query(query))) return jsonify(readActorsFromResultset(database.Query(query)))
}) })
var gaov struct { /**
MovieId int * @api {post} /api/actor [getActorsOfVideo]
} * @apiDescription Get all actors playing in one video
AddHandler("getActorsOfVideo", ActorNode, &gaov, func() []byte { * @apiName getActorsOfVideo
* @apiGroup Actor
*
* @apiParam {int} MovieId ID of video
*
* @apiSuccess {Object[]} . Array of Actors available
* @apiSuccess {uint32} .ActorId Actor Id
* @apiSuccess {string} .Name Actor Name
* @apiSuccess {string} .Thumbnail Portrait Thumbnail
*/
AddHandler("getActorsOfVideo", ActorNode, func(info *HandlerInfo) []byte {
var args struct {
MovieId int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos query := fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=%d`, gaov.MovieId) WHERE actors_videos.video_id=%d`, args.MovieId)
return jsonify(readActorsFromResultset(database.Query(query))) return jsonify(readActorsFromResultset(database.Query(query)))
}) })
var gai struct { /**
ActorId int * @api {post} /api/actor [getActorInfo]
} * @apiDescription Get all infos for an actor
AddHandler("getActorInfo", ActorNode, &gai, func() []byte { * @apiName getActorInfo
* @apiGroup Actor
*
* @apiParam {int} ActorId ID of Actor
*
* @apiSuccess {VideoUnloadedType[]} Videos Array of Videos this actor plays in
* @apiSuccess {uint32} Videos.MovieId Video Id
* @apiSuccess {string} Videos.MovieName Video Name
*
* @apiSuccess {Info} Info Infos about the actor
* @apiSuccess {uint32} Info.ActorId Actor Id
* @apiSuccess {string} Info.Name Actor Name
* @apiSuccess {string} Info.Thumbnail Actor Thumbnail
*/
AddHandler("getActorInfo", ActorNode, func(info *HandlerInfo) []byte {
var args struct {
ActorId int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := fmt.Sprintf(`SELECT movie_id, movie_name FROM actors_videos query := fmt.Sprintf(`SELECT movie_id, movie_name FROM actors_videos
JOIN videos v on v.movie_id = actors_videos.video_id JOIN videos v on v.movie_id = actors_videos.video_id
WHERE actors_videos.actor_id=%d`, gai.ActorId) WHERE actors_videos.actor_id=%d`, args.ActorId)
videos := readVideosFromResultset(database.Query(query)) videos := readVideosFromResultset(database.Query(query))
query = fmt.Sprintf("SELECT actor_id, name, thumbnail FROM actors WHERE actor_id=%d", gai.ActorId) query = fmt.Sprintf("SELECT actor_id, name, thumbnail FROM actors WHERE actor_id=%d", args.ActorId)
actor := readActorsFromResultset(database.Query(query))[0] actor := readActorsFromResultset(database.Query(query))[0]
var result = struct { var result = struct {
@ -53,20 +104,51 @@ func getActorsFromDB() {
} }
func saveActorsToDB() { func saveActorsToDB() {
var ca struct { /**
ActorName string * @api {post} /api/video [createActor]
} * @apiDescription Create a new Actor
AddHandler("createActor", ActorNode, &ca, func() []byte { * @apiName createActor
* @apiGroup Actor
*
* @apiParam {string} ActorName Name of new Actor
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("createActor", ActorNode, func(info *HandlerInfo) []byte {
var args struct {
ActorName string
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := "INSERT IGNORE INTO actors (name) VALUES (?)" query := "INSERT IGNORE INTO actors (name) VALUES (?)"
return database.SuccessQuery(query, ca.ActorName) return database.SuccessQuery(query, args.ActorName)
}) })
var aatv struct { /**
ActorId int * @api {post} /api/video [addActorToVideo]
MovieId int * @apiDescription Add Actor to Video
} * @apiName addActorToVideo
AddHandler("addActorToVideo", ActorNode, &aatv, func() []byte { * @apiGroup Actor
query := fmt.Sprintf("INSERT IGNORE INTO actors_videos (actor_id, video_id) VALUES (%d,%d)", aatv.ActorId, aatv.MovieId) *
* @apiParam {int} ActorId Id of Actor
* @apiParam {int} MovieId Id of Movie to add to
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("addActorToVideo", ActorNode, func(info *HandlerInfo) []byte {
var args struct {
ActorId int
MovieId int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := fmt.Sprintf("INSERT IGNORE INTO actors_videos (actor_id, video_id) VALUES (%d,%d)", args.ActorId, args.MovieId)
return database.SuccessQuery(query) return database.SuccessQuery(query)
}) })
} }

View File

@ -4,8 +4,9 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "gopkg.in/oauth2.v3"
"net/http" "net/http"
"openmediacenter/apiGo/api/oauth"
) )
const APIPREFIX = "/api" const APIPREFIX = "/api"
@ -15,73 +16,76 @@ const (
TagNode = iota TagNode = iota
SettingsNode = iota SettingsNode = iota
ActorNode = iota ActorNode = iota
TVShowNode = iota
) )
type HandlerInfo struct {
ID string
Token string
Data map[string]interface{}
}
type actionStruct struct { type actionStruct struct {
Action string Action string
} }
type Handler struct { type Handler struct {
action string action string
handler func() []byte handler func(info *HandlerInfo) []byte
arguments interface{} apiNode int
apiNode int
} }
var handlers []Handler var handlers = make(map[string]Handler)
func AddHandler(action string, apiNode int, n interface{}, h func() []byte) { func AddHandler(action string, apiNode int, h func(info *HandlerInfo) []byte) {
// append new handler to the handlers // append new handler to the handlers
handlers = append(handlers, Handler{action, h, n, apiNode}) handlers[fmt.Sprintf("%s/%d", action, apiNode)] = Handler{action, h, apiNode}
} }
func ServerInit(port uint16) { func ServerInit() {
http.Handle(APIPREFIX+"/video", http.HandlerFunc(videoHandler)) http.Handle(APIPREFIX+"/video", oauth.ValidateToken(handlefunc, VideoNode))
http.Handle(APIPREFIX+"/tags", http.HandlerFunc(tagHandler)) http.Handle(APIPREFIX+"/tags", oauth.ValidateToken(handlefunc, TagNode))
http.Handle(APIPREFIX+"/settings", http.HandlerFunc(settingsHandler)) http.Handle(APIPREFIX+"/settings", oauth.ValidateToken(handlefunc, SettingsNode))
http.Handle(APIPREFIX+"/actor", http.HandlerFunc(actorHandler)) http.Handle(APIPREFIX+"/actor", oauth.ValidateToken(handlefunc, ActorNode))
http.Handle(APIPREFIX+"/tvshow", oauth.ValidateToken(handlefunc, TVShowNode))
fmt.Printf("OpenMediacenter server up and running on port %d\n", port) // initialize oauth service and add corresponding auth routes
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil)) oauth.InitOAuth()
} }
func handleAPICall(action string, requestBody string, apiNode int) []byte { func handleAPICall(action string, requestBody string, apiNode int, info *HandlerInfo) []byte {
for i := range handlers { handler, ok := handlers[fmt.Sprintf("%s/%d", action, apiNode)]
if handlers[i].action == action && handlers[i].apiNode == apiNode { if !ok {
// call the handler and return // handler doesn't exist!
fmt.Printf("no handler found for Action: %d/%s\n", apiNode, action)
if handlers[i].arguments != nil { return nil
// decode the arguments to the corresponding arguments object
err := json.Unmarshal([]byte(requestBody), &handlers[i].arguments)
if err != nil {
fmt.Printf("failed to decode arguments of action %s :: %s\n", action, requestBody)
}
}
return handlers[i].handler()
}
} }
fmt.Printf("no handler found for Action: %d/%s\n", apiNode, action)
return nil // check if info even exists
if info == nil {
info = &HandlerInfo{}
}
// parse the arguments
var args map[string]interface{}
err := json.Unmarshal([]byte(requestBody), &args)
if err != nil {
fmt.Printf("failed to decode arguments of action %s :: %s\n", action, requestBody)
} else {
// check if map has an action
if _, ok := args["action"]; ok {
delete(args, "action")
}
info.Data = args
}
// call the handler
return handler.handler(info)
} }
func actorHandler(rw http.ResponseWriter, req *http.Request) { func handlefunc(rw http.ResponseWriter, req *http.Request, node int, tokenInfo *oauth2.TokenInfo) {
handlefunc(rw, req, ActorNode)
}
func videoHandler(rw http.ResponseWriter, req *http.Request) {
handlefunc(rw, req, VideoNode)
}
func tagHandler(rw http.ResponseWriter, req *http.Request) {
handlefunc(rw, req, TagNode)
}
func settingsHandler(rw http.ResponseWriter, req *http.Request) {
handlefunc(rw, req, SettingsNode)
}
func handlefunc(rw http.ResponseWriter, req *http.Request, node int) {
// only allow post requests // only allow post requests
if req.Method != "POST" { if req.Method != "POST" {
return return
@ -97,5 +101,13 @@ func handlefunc(rw http.ResponseWriter, req *http.Request, node int) {
fmt.Println("failed to read action from request! :: " + body) fmt.Println("failed to read action from request! :: " + body)
} }
rw.Write(handleAPICall(t.Action, body, node)) // load userid from received token object
id := (*tokenInfo).GetClientID()
userinfo := &HandlerInfo{
ID: id,
Token: (*tokenInfo).GetCode(),
}
rw.Write(handleAPICall(t.Action, body, node, userinfo))
} }

View File

@ -5,13 +5,13 @@ import (
) )
func cleanUp() { func cleanUp() {
handlers = nil handlers = make(map[string]Handler)
} }
func TestAddHandler(t *testing.T) { func TestAddHandler(t *testing.T) {
cleanUp() cleanUp()
AddHandler("test", ActorNode, nil, func() []byte { AddHandler("test", ActorNode, func(info *HandlerInfo) []byte {
return nil return nil
}) })
if len(handlers) != 1 { if len(handlers) != 1 {
@ -23,13 +23,13 @@ func TestCallOfHandler(t *testing.T) {
cleanUp() cleanUp()
i := 0 i := 0
AddHandler("test", ActorNode, nil, func() []byte { AddHandler("test", ActorNode, func(info *HandlerInfo) []byte {
i++ i++
return nil return nil
}) })
// simulate the call of the api // simulate the call of the api
handleAPICall("test", "", ActorNode) handleAPICall("test", "", ActorNode, nil)
if i != 1 { if i != 1 {
t.Errorf("Unexpected number of Lambda calls : %d/1", i) t.Errorf("Unexpected number of Lambda calls : %d/1", i)
@ -39,26 +39,32 @@ func TestCallOfHandler(t *testing.T) {
func TestDecodingOfArguments(t *testing.T) { func TestDecodingOfArguments(t *testing.T) {
cleanUp() cleanUp()
var myvar struct { AddHandler("test", ActorNode, func(info *HandlerInfo) []byte {
Test string var args struct {
TestInt int Test string
} TestInt int
AddHandler("test", ActorNode, &myvar, func() []byte { }
err := FillStruct(&args, info.Data)
if err != nil {
t.Errorf("Error parsing args: %s", err.Error())
return nil
}
if args.TestInt != 42 || args.Test != "myString" {
t.Errorf("Wrong parsing of argument parameters : %d/42 - %s/myString", args.TestInt, args.Test)
}
return nil return nil
}) })
// simulate the call of the api // simulate the call of the api
handleAPICall("test", `{"Test":"myString","TestInt":42}`, ActorNode) handleAPICall("test", `{"Test":"myString","TestInt":42}`, ActorNode, nil)
if myvar.TestInt != 42 || myvar.Test != "myString" {
t.Errorf("Wrong parsing of argument parameters : %d/42 - %s/myString", myvar.TestInt, myvar.Test)
}
} }
func TestNoHandlerCovers(t *testing.T) { func TestNoHandlerCovers(t *testing.T) {
cleanUp() cleanUp()
ret := handleAPICall("test", "", ActorNode) ret := handleAPICall("test", "", ActorNode, nil)
if ret != nil { if ret != nil {
t.Error("Expect nil return within unhandled api action") t.Error("Expect nil return within unhandled api action")

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"openmediacenter/apiGo/api/types" "openmediacenter/apiGo/api/types"
"reflect"
) )
// MovieId - MovieName : pay attention to the order! // MovieId - MovieName : pay attention to the order!
@ -61,6 +62,22 @@ func readActorsFromResultset(rows *sql.Rows) []types.Actor {
return result return result
} }
// ID - Name : pay attention to the order!
func readTVshowsFromResultset(rows *sql.Rows) []types.TVShow {
result := []types.TVShow{}
for rows.Next() {
var vid types.TVShow
err := rows.Scan(&vid.Id, &vid.Name)
if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app
}
result = append(result, vid)
}
rows.Close()
return result
}
func jsonify(v interface{}) []byte { func jsonify(v interface{}) []byte {
// jsonify results // jsonify results
str, err := json.Marshal(v) str, err := json.Marshal(v)
@ -69,3 +86,45 @@ func jsonify(v interface{}) []byte {
} }
return str return str
} }
// setField set a specific field of an object with an object provided
func setField(obj interface{}, name string, value interface{}) error {
structValue := reflect.ValueOf(obj).Elem()
structFieldValue := structValue.FieldByName(name)
if !structFieldValue.IsValid() {
return fmt.Errorf("no such field: %s in obj", name)
}
if !structFieldValue.CanSet() {
return fmt.Errorf("cannot set %s field value", name)
}
structFieldType := structFieldValue.Type()
val := reflect.ValueOf(value)
if structFieldType != val.Type() {
if val.Type().ConvertibleTo(structFieldType) {
// if type is convertible - convert and set
structFieldValue.Set(val.Convert(structFieldType))
} else {
return fmt.Errorf("provided value %s type didn't match obj field type and isn't convertible", name)
}
} else {
// set value if type is the same
structFieldValue.Set(val)
}
return nil
}
// FillStruct fill a custom struct with objects of a map
func FillStruct(i interface{}, m map[string]interface{}) error {
for k, v := range m {
err := setField(i, k, v)
if err != nil {
return err
}
}
return nil
}

View File

@ -5,7 +5,10 @@ import (
"fmt" "fmt"
"openmediacenter/apiGo/api/types" "openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
"openmediacenter/apiGo/database/settings"
"openmediacenter/apiGo/videoparser" "openmediacenter/apiGo/videoparser"
"regexp"
"strings"
) )
func AddSettingsHandlers() { func AddSettingsHandlers() {
@ -15,52 +18,106 @@ func AddSettingsHandlers() {
} }
func getSettingsFromDB() { func getSettingsFromDB() {
AddHandler("loadInitialData", SettingsNode, nil, func() []byte { /**
query := "SELECT DarkMode, password, mediacenter_name, video_path from settings" * @api {post} /api/settings [loadGeneralSettings]
* @apiDescription Get the settings object
* @apiName loadGeneralSettings
* @apiGroup Settings
*
* @apiSuccess {Object} Settings Settings object
* @apiSuccess {string} Settings.VideoPath webserver path to the videos
* @apiSuccess {string} Settings.EpisodePath webserver path to the tvshows
* @apiSuccess {string} Settings.MediacenterName overall name of the mediacenter
* @apiSuccess {string} Settings.Password new server password (-1 if no password set)
* @apiSuccess {bool} Settings.TMDBGrabbing TMDB grabbing support to grab tag info and thumbnails
* @apiSuccess {bool} Settings.DarkMode Darkmode enabled?
* @apiSuccess {Object} Sizes Sizes object
* @apiSuccess {uint32} Sizes.VideoNr total number of videos
* @apiSuccess {float32} Sizes.DBSize total size of database
* @apiSuccess {uint32} Sizes.DifferentTags number of different tags available
* @apiSuccess {uint32} Sizes.TagsAdded number of different tags added to videos
*/
AddHandler("loadGeneralSettings", SettingsNode, func(info *HandlerInfo) []byte {
result, _, sizes := database.GetSettings()
type InitialDataType struct { var ret = struct {
DarkMode int Settings *types.SettingsType
Pasword int Sizes *types.SettingsSizeType
Mediacenter_name string }{
VideoPath string Settings: &result,
Sizes: &sizes,
} }
return jsonify(ret)
})
result := InitialDataType{} /**
* @api {post} /api/settings [loadInitialData]
err := database.QueryRow(query).Scan(&result.DarkMode, &result.Pasword, &result.Mediacenter_name, &result.VideoPath) * @apiDescription load startdata to display on homepage
if err != nil { * @apiName loadInitialData
fmt.Println("error while parsing db data: " + err.Error()) * @apiGroup Settings
} *
* @apiSuccess {string} VideoPath webserver path to the videos
* @apiSuccess {string} EpisodePath webserver path to the tvshows
* @apiSuccess {string} MediacenterName overall name of the mediacenter
* @apiSuccess {string} Pasword new server password (-1 if no password set)
* @apiSuccess {bool} DarkMode Darkmode enabled?
* @apiSuccess {bool} TVShowEnabled is are TVShows enabled
*/
AddHandler("loadInitialData", SettingsNode, func(info *HandlerInfo) []byte {
sett := settings.LoadSettings()
type InitialDataTypeResponse struct { type InitialDataTypeResponse struct {
DarkMode bool DarkMode bool
Pasword bool Pasword bool
Mediacenter_name string MediacenterName string
VideoPath string VideoPath string
TVShowPath string
TVShowEnabled bool
} }
regexMatchUrl := regexp.MustCompile("^http(|s)://([0-9]){1,3}\\.([0-9]){1,3}\\.([0-9]){1,3}\\.([0-9]){1,3}:[0-9]{1,5}")
videoUrl := regexMatchUrl.FindString(sett.VideoPath)
tvshowurl := regexMatchUrl.FindString(sett.TVShowPath)
serverVideoPath := strings.TrimPrefix(sett.VideoPath, videoUrl)
serverTVShowPath := strings.TrimPrefix(sett.TVShowPath, tvshowurl)
res := InitialDataTypeResponse{ res := InitialDataTypeResponse{
DarkMode: result.DarkMode != 0, DarkMode: sett.DarkMode,
Pasword: result.Pasword != -1, Pasword: sett.Pasword != "-1",
Mediacenter_name: result.Mediacenter_name, MediacenterName: sett.MediacenterName,
VideoPath: result.VideoPath, VideoPath: serverVideoPath,
TVShowPath: serverTVShowPath,
TVShowEnabled: settings.TVShowsEnabled(),
} }
str, _ := json.Marshal(res) str, _ := json.Marshal(res)
return str return str
}) })
AddHandler("loadGeneralSettings", SettingsNode, nil, func() []byte {
result := database.GetSettings()
return jsonify(result)
})
} }
func saveSettingsToDB() { func saveSettingsToDB() {
var sgs struct { /**
Settings types.SettingsType * @api {post} /api/settings [saveGeneralSettings]
} * @apiDescription Save the global settings provided
AddHandler("saveGeneralSettings", SettingsNode, &sgs, func() []byte { * @apiName saveGeneralSettings
* @apiGroup Settings
*
* @apiParam {string} VideoPath webserver path to the videos
* @apiParam {string} EpisodePath webserver path to the tvshows
* @apiParam {string} MediacenterName overall name of the mediacenter
* @apiParam {string} Password new server password (-1 if no password set)
* @apiParam {bool} TMDBGrabbing TMDB grabbing support to grab tag info and thumbnails
* @apiParam {bool} DarkMode Darkmode enabled?
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("saveGeneralSettings", SettingsNode, func(info *HandlerInfo) []byte {
var args types.SettingsType
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := ` query := `
UPDATE settings SET UPDATE settings SET
video_path=?, video_path=?,
@ -71,24 +128,47 @@ func saveSettingsToDB() {
DarkMode=? DarkMode=?
WHERE 1` WHERE 1`
return database.SuccessQuery(query, return database.SuccessQuery(query,
sgs.Settings.VideoPath, sgs.Settings.EpisodePath, sgs.Settings.Password, args.VideoPath, args.EpisodePath, args.Password,
sgs.Settings.MediacenterName, sgs.Settings.TMDBGrabbing, sgs.Settings.DarkMode) args.MediacenterName, args.TMDBGrabbing, args.DarkMode)
}) })
} }
// methods for handling reindexing and cleanup of db gravity // methods for handling reindexing and cleanup of db gravity
func reIndexHandling() { func reIndexHandling() {
AddHandler("startReindex", SettingsNode, nil, func() []byte { /**
* @api {post} /api/settings [startReindex]
* @apiDescription Start Database video reindex Job
* @apiName startReindex
* @apiGroup Settings
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("startReindex", SettingsNode, func(info *HandlerInfo) []byte {
videoparser.StartReindex() videoparser.StartReindex()
return database.ManualSuccessResponse(nil) return database.ManualSuccessResponse(nil)
}) })
AddHandler("cleanupGravity", SettingsNode, nil, func() []byte { /**
* @api {post} /api/settings [startTVShowReindex]
* @apiDescription Start Database TVShow reindex job
* @apiName startTVShowReindex
* @apiGroup Settings
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("startTVShowReindex", SettingsNode, func(info *HandlerInfo) []byte {
videoparser.StartTVShowReindex()
return database.ManualSuccessResponse(nil)
})
/**
* @api {post} /api/settings [cleanupGravity]
* @apiDescription Start Database cleanup job
* @apiName cleanupGravity
* @apiGroup Settings
*/
AddHandler("cleanupGravity", SettingsNode, func(info *HandlerInfo) []byte {
videoparser.StartCleanup() videoparser.StartCleanup()
return nil return nil
}) })
AddHandler("getStatusMessage", SettingsNode, nil, func() []byte {
return jsonify(videoparser.GetStatusMessage())
})
} }

161
apiGo/api/TVShows.go Normal file
View File

@ -0,0 +1,161 @@
package api
import (
"fmt"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/database/settings"
)
func AddTvshowHandlers() {
// do not add handlers if tvshows not enabled
if !settings.TVShowsEnabled() {
return
}
/**
* @api {post} /api/tvshow [getTVShows]
* @apiDescription get all available tv shows
* @apiName getTVShows
* @apiGroup TVshow
*
* @apiSuccess {Object[]} .
* @apiSuccess {uint32} .Id tvshow id
* @apiSuccess {string} .Name tvshow name
*/
AddHandler("getTVShows", TVShowNode, func(info *HandlerInfo) []byte {
query := "SELECT id, name FROM tvshow"
rows := database.Query(query)
return jsonify(readTVshowsFromResultset(rows))
})
/**
* @api {post} /api/tvshow [getEpisodes]
* @apiDescription get all Episodes of a TVShow
* @apiName getEpisodes
* @apiGroup TVshow
*
* @apiParam {uint32} ShowID id of tvshow to get episodes from
*
* @apiSuccess {Object[]} .
* @apiSuccess {uint32} .ID episode id
* @apiSuccess {string} .Name episode name
* @apiSuccess {uint8} .Season Season number
* @apiSuccess {uint8} .Episode Episode number
*/
AddHandler("getEpisodes", TVShowNode, func(info *HandlerInfo) []byte {
var args struct {
ShowID uint32
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := fmt.Sprintf("SELECT id, name, season, episode FROM tvshow_episodes WHERE tvshow_id=%d", args.ShowID)
rows := database.Query(query)
type Episode struct {
ID uint32
Name string
Season uint8
Episode uint8
}
episodes := []Episode{}
for rows.Next() {
var ep Episode
err := rows.Scan(&ep.ID, &ep.Name, &ep.Season, &ep.Episode)
if err != nil {
fmt.Println(err.Error())
continue
}
episodes = append(episodes, ep)
}
return jsonify(episodes)
})
/**
* @api {post} /api/tvshow [loadEpisode]
* @apiDescription load all info of episode
* @apiName loadEpisode
* @apiGroup TVshow
*
* @apiParam {uint32} ID id of episode
*
* @apiSuccess {uint32} TVShowID episode id
* @apiSuccess {string} Name episode name
* @apiSuccess {uint8} Season Season number
* @apiSuccess {uint8} Episode Episode number
* @apiSuccess {string} Path webserver path of video file
*/
AddHandler("loadEpisode", TVShowNode, func(info *HandlerInfo) []byte {
var args struct {
ID uint32
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := fmt.Sprintf(`
SELECT tvshow_episodes.name, season, tvshow_id, episode, filename, t.foldername
FROM tvshow_episodes
JOIN tvshow t on t.id = tvshow_episodes.tvshow_id
WHERE tvshow_episodes.id=%d`, args.ID)
row := database.QueryRow(query)
var ret struct {
Name string
Season uint8
Episode uint8
TVShowID uint32
Path string
}
var filename string
var foldername string
err := row.Scan(&ret.Name, &ret.Season, &ret.TVShowID, &ret.Episode, &filename, &foldername)
if err != nil {
fmt.Println(err.Error())
return nil
}
ret.Path = foldername + "/" + filename
return jsonify(ret)
})
/**
* @api {post} /api/tvshow [readThumbnail]
* @apiDescription Load Thubnail of specific episode
* @apiName readThumbnail
* @apiGroup TVshow
*
* @apiParam {int} Id id of episode to load thumbnail
*
* @apiSuccess {string} . Base64 encoded Thubnail
*/
AddHandler("readThumbnail", TVShowNode, func(info *HandlerInfo) []byte {
var args struct {
Id int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
var pic []byte
query := fmt.Sprintf("SELECT thumbnail FROM tvshow WHERE id=%d", args.Id)
err := database.QueryRow(query).Scan(&pic)
if err != nil {
fmt.Printf("the thumbnail of movie id %d couldn't be found", args.Id)
return nil
}
return pic
})
}

View File

@ -13,14 +13,30 @@ func AddTagHandlers() {
} }
func deleteFromDB() { func deleteFromDB() {
var dT struct { /**
TagId int * @api {post} /api/tags [deleteTag]
Force bool * @apiDescription Start Database video reindex Job
} * @apiName deleteTag
AddHandler("deleteTag", TagNode, &dT, func() []byte { * @apiGroup Tags
*
* @apiParam {bool} [Force] force delete tag with its constraints
* @apiParam {int} TagId id of tag to delete
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("deleteTag", TagNode, func(info *HandlerInfo) []byte {
var args struct {
TagId int
Force bool
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
// delete key constraints first // delete key constraints first
if dT.Force { if args.Force {
query := fmt.Sprintf("DELETE FROM video_tags WHERE tag_id=%d", dT.TagId) query := fmt.Sprintf("DELETE FROM video_tags WHERE tag_id=%d", args.TagId)
err := database.Edit(query) err := database.Edit(query)
// respond only if result not successful // respond only if result not successful
@ -29,7 +45,7 @@ func deleteFromDB() {
} }
} }
query := fmt.Sprintf("DELETE FROM tags WHERE tag_id=%d", dT.TagId) query := fmt.Sprintf("DELETE FROM tags WHERE tag_id=%d", args.TagId)
err := database.Edit(query) err := database.Edit(query)
if err == nil { if err == nil {
@ -37,9 +53,9 @@ func deleteFromDB() {
return database.ManualSuccessResponse(err) return database.ManualSuccessResponse(err)
} else { } else {
// check with regex if its the key constraint error // check with regex if its the key constraint error
r, _ := regexp.Compile("^.*a foreign key constraint fails.*$") r := regexp.MustCompile("^.*a foreign key constraint fails.*$")
if r.MatchString(err.Error()) { if r.MatchString(err.Error()) {
return []byte(`{"result":"not empty tag"}`) return database.ManualSuccessResponse(fmt.Errorf("not empty tag"))
} else { } else {
return database.ManualSuccessResponse(err) return database.ManualSuccessResponse(err)
} }
@ -48,27 +64,68 @@ func deleteFromDB() {
} }
func getFromDB() { func getFromDB() {
AddHandler("getAllTags", TagNode, nil, func() []byte { /**
* @api {post} /api/tags [getAllTags]
* @apiDescription get all available Tags
* @apiName getAllTags
* @apiGroup Tags
*
* @apiSuccess {Object[]} array of tag objects
* @apiSuccess {uint32} TagId
* @apiSuccess {string} TagName name of the Tag
*/
AddHandler("getAllTags", TagNode, func(info *HandlerInfo) []byte {
query := "SELECT tag_id,tag_name from tags" query := "SELECT tag_id,tag_name from tags"
return jsonify(readTagsFromResultset(database.Query(query))) return jsonify(readTagsFromResultset(database.Query(query)))
}) })
} }
func addToDB() { func addToDB() {
var ct struct { /**
TagName string * @api {post} /api/tags [createTag]
} * @apiDescription create a new tag
AddHandler("createTag", TagNode, &ct, func() []byte { * @apiName createTag
* @apiGroup Tags
*
* @apiParam {string} TagName name of the tag
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("createTag", TagNode, func(info *HandlerInfo) []byte {
var args struct {
TagName string
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := "INSERT IGNORE INTO tags (tag_name) VALUES (?)" query := "INSERT IGNORE INTO tags (tag_name) VALUES (?)"
return database.SuccessQuery(query, ct.TagName) return database.SuccessQuery(query, args.TagName)
}) })
var at struct { /**
MovieId int * @api {post} /api/tags [addTag]
TagId int * @apiDescription Add new tag to video
} * @apiName addTag
AddHandler("addTag", TagNode, &at, func() []byte { * @apiGroup Tags
*
* @apiParam {int} TagId Tag id to add to video
* @apiParam {int} MovieId Video Id of video to add tag to
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("addTag", TagNode, func(info *HandlerInfo) []byte {
var args struct {
MovieId int
TagId int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := "INSERT IGNORE INTO video_tags(tag_id, video_id) VALUES (?,?)" query := "INSERT IGNORE INTO video_tags(tag_id, video_id) VALUES (?,?)"
return database.SuccessQuery(query, at.TagId, at.MovieId) return database.SuccessQuery(query, args.TagId, args.MovieId)
}) })
} }

View File

@ -16,55 +16,163 @@ func AddVideoHandlers() {
} }
func getVideoHandlers() { func getVideoHandlers() {
var mrq struct { /**
Tag int * @api {post} /api/video [getMovies]
} * @apiDescription Request available Videos
AddHandler("getMovies", VideoNode, &mrq, func() []byte { * @apiName GetMovies
var query string * @apiGroup video
// 1 is the id of the ALL tag *
if mrq.Tag != 1 { * @apiParam {int} [Tag=1] id of VideoTag to get videos (1=all)
query = fmt.Sprintf(`SELECT movie_id,movie_name FROM videos *
INNER JOIN video_tags vt on videos.movie_id = vt.video_id * @apiSuccess {Object[]} Videos List of Videos
INNER JOIN tags t on vt.tag_id = t.tag_id * @apiSuccess {number} Videos.MovieId Id of Video
WHERE t.tag_id = '%d' * @apiSuccess {String} Videos.MovieName Name of video
ORDER BY likes DESC, create_date, movie_name`, mrq.Tag) * @apiSuccess {String} TagName Name of the Tag returned
} else { */
query = "SELECT movie_id,movie_name FROM videos ORDER BY create_date DESC, movie_name" AddHandler("getMovies", VideoNode, func(info *HandlerInfo) []byte {
var args struct {
Tag uint32
Sort uint8
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
} }
result := readVideosFromResultset(database.Query(query)) const (
date = iota
likes = iota
random = iota
names = iota
length = iota
)
// if wrong number passed no sorting is performed
var SortClause = ""
switch args.Sort {
case date:
SortClause = "ORDER BY create_date DESC, movie_name"
break
case likes:
SortClause = "ORDER BY likes DESC"
break
case random:
SortClause = "ORDER BY RAND()"
break
case names:
SortClause = "ORDER BY movie_name"
break
case length:
SortClause = "ORDER BY length DESC"
break
}
var query string
// 1 is the id of the ALL tag
if args.Tag != 1 {
query = fmt.Sprintf(`SELECT movie_id,movie_name,t.tag_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 = %d %s`, args.Tag, SortClause)
} else {
query = fmt.Sprintf("SELECT movie_id,movie_name, (SELECT 'All' as tag_name) FROM videos %s", SortClause)
}
var result struct {
Videos []types.VideoUnloadedType
TagName string
}
rows := database.Query(query)
vids := []types.VideoUnloadedType{}
var name string
for rows.Next() {
var vid types.VideoUnloadedType
err := rows.Scan(&vid.MovieId, &vid.MovieName, &name)
if err != nil {
return nil
}
vids = append(vids, vid)
}
if rows.Close() != nil {
return nil
}
// if the tag id doesn't exist the query won't return a name
if name == "" {
return nil
}
result.Videos = vids
result.TagName = name
// jsonify results // jsonify results
str, _ := json.Marshal(result) str, _ := json.Marshal(result)
return str return str
}) })
var rtn struct { /**
Movieid int * @api {post} /api/video [readThumbnail]
} * @apiDescription Load Thubnail of specific Video
AddHandler("readThumbnail", VideoNode, &rtn, func() []byte { * @apiName readThumbnail
* @apiGroup video
*
* @apiParam {int} Movieid id of video to load thumbnail
*
* @apiSuccess {string} . Base64 encoded Thubnail
*/
AddHandler("readThumbnail", VideoNode, func(info *HandlerInfo) []byte {
var args struct {
Movieid int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
var pic []byte var pic []byte
query := fmt.Sprintf("SELECT thumbnail FROM videos WHERE movie_id='%d'", rtn.Movieid) query := fmt.Sprintf("SELECT thumbnail FROM videos WHERE movie_id=%d", args.Movieid)
err := database.QueryRow(query).Scan(&pic) err := database.QueryRow(query).Scan(&pic)
if err != nil { if err != nil {
fmt.Printf("the thumbnail of movie id %d couldn't be found", rtn.Movieid) fmt.Printf("the thumbnail of movie id %d couldn't be found", args.Movieid)
return nil return nil
} }
return pic return pic
}) })
var grm struct { /**
Number int * @api {post} /api/video [getRandomMovies]
} * @apiDescription Load random videos
AddHandler("getRandomMovies", VideoNode, &grm, func() []byte { * @apiName getRandomMovies
* @apiGroup video
*
* @apiParam {int} Number number of random videos to load
*
* @apiSuccess {Object[]} Tags Array of tags occuring in selection
* @apiSuccess {string} Tags.TagName Tagname
* @apiSuccess {uint32} Tags.TagId Tag ID
*
* @apiSuccess {Object[]} Videos Array of the videos
* @apiSuccess {string} Videos.MovieName Video Name
* @apiSuccess {int} Videos.MovieId Video ID
*/
AddHandler("getRandomMovies", VideoNode, func(info *HandlerInfo) []byte {
var args struct {
Number int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
var result struct { var result struct {
Tags []types.Tag Tags []types.Tag
Videos []types.VideoUnloadedType Videos []types.VideoUnloadedType
} }
query := fmt.Sprintf("SELECT movie_id,movie_name FROM videos ORDER BY RAND() LIMIT %d", grm.Number) query := fmt.Sprintf("SELECT movie_id,movie_name FROM videos ORDER BY RAND() LIMIT %d", args.Number)
result.Videos = readVideosFromResultset(database.Query(query)) result.Videos = readVideosFromResultset(database.Query(query))
var ids string var ids string
@ -99,13 +207,30 @@ func getVideoHandlers() {
return str return str
}) })
var gsk struct { /**
KeyWord string * @api {post} /api/video [getSearchKeyWord]
} * @apiDescription Get videos for search keyword
AddHandler("getSearchKeyWord", VideoNode, &gsk, func() []byte { * @apiName getSearchKeyWord
* @apiGroup video
*
* @apiParam {string} KeyWord Keyword to search for
*
* @apiSuccess {Object[]} . List of Videos
* @apiSuccess {number} .MovieId Id of Video
* @apiSuccess {String} .MovieName Name of video
*/
AddHandler("getSearchKeyWord", VideoNode, func(info *HandlerInfo) []byte {
var args struct {
KeyWord string
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := fmt.Sprintf(`SELECT movie_id,movie_name FROM videos query := fmt.Sprintf(`SELECT movie_id,movie_name FROM videos
WHERE movie_name LIKE '%%%s%%' WHERE movie_name LIKE '%%%s%%'
ORDER BY likes DESC, create_date DESC, movie_name`, gsk.KeyWord) ORDER BY likes DESC, create_date DESC, movie_name`, args.KeyWord)
result := readVideosFromResultset(database.Query(query)) result := readVideosFromResultset(database.Query(query))
// jsonify results // jsonify results
@ -116,12 +241,47 @@ func getVideoHandlers() {
// function to handle stuff for loading specific videos and startdata // function to handle stuff for loading specific videos and startdata
func loadVideosHandlers() { func loadVideosHandlers() {
var lv struct { /**
MovieId int * @api {post} /api/video [loadVideo]
} * @apiDescription Load all data for a specific video
AddHandler("loadVideo", VideoNode, &lv, func() []byte { * @apiName loadVideo
* @apiGroup video
*
* @apiParam {int} MovieId ID of video
*
* @apiSuccess {string} MovieName Videoname
* @apiSuccess {uint32} MovieId Video ID
* @apiSuccess {string} MovieUrl Url to video file
* @apiSuccess {string} Poster Base64 encoded Poster
* @apiSuccess {uint64} Likes Number of likes
* @apiSuccess {uint16} Quality Video FrameWidth
* @apiSuccess {uint16} Length Video Length in seconds
*
*
* @apiSuccess {Object[]} Tags Array of tags of video
* @apiSuccess {string} Tags.TagName Tagname
* @apiSuccess {uint32} Tags.TagId Tag ID
*
* @apiSuccess {Object[]} SuggestedTag Array of tags for quick add suggestions
* @apiSuccess {string} SuggestedTag.TagName Tagname
* @apiSuccess {uint32} SuggestedTag.TagId Tag ID
*
* @apiSuccess {Object[]} Actors Array of Actors playing in this video
* @apiSuccess {uint32} Actors.ActorId Actor Id
* @apiSuccess {string} Actors.Name Actor Name
* @apiSuccess {string} Actors.Thumbnail Portrait Thumbnail
*/
AddHandler("loadVideo", VideoNode, func(info *HandlerInfo) []byte {
var args struct {
MovieId int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := fmt.Sprintf(`SELECT movie_name,movie_url,movie_id,thumbnail,poster,likes,quality,length query := fmt.Sprintf(`SELECT movie_name,movie_url,movie_id,thumbnail,poster,likes,quality,length
FROM videos WHERE movie_id=%d`, lv.MovieId) FROM videos WHERE movie_id=%d`, args.MovieId)
var res types.FullVideoType var res types.FullVideoType
var poster []byte var poster []byte
@ -129,7 +289,7 @@ func loadVideosHandlers() {
err := database.QueryRow(query).Scan(&res.MovieName, &res.MovieUrl, &res.MovieId, &thumbnail, &poster, &res.Likes, &res.Quality, &res.Length) err := database.QueryRow(query).Scan(&res.MovieName, &res.MovieUrl, &res.MovieId, &thumbnail, &poster, &res.Likes, &res.Quality, &res.Length)
if err != nil { if err != nil {
fmt.Printf("error getting full data list of videoid - %d", lv.MovieId) fmt.Printf("error getting full data list of videoid - %d", args.MovieId)
fmt.Println(err.Error()) fmt.Println(err.Error())
return nil return nil
} }
@ -149,7 +309,7 @@ func loadVideosHandlers() {
query = fmt.Sprintf(`SELECT t.tag_id, t.tag_name FROM video_tags query = fmt.Sprintf(`SELECT t.tag_id, t.tag_name FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE video_tags.video_id=%d WHERE video_tags.video_id=%d
GROUP BY t.tag_id`, lv.MovieId) GROUP BY t.tag_id`, args.MovieId)
res.Tags = readTagsFromResultset(database.Query(query)) res.Tags = readTagsFromResultset(database.Query(query))
@ -158,14 +318,14 @@ func loadVideosHandlers() {
SELECT video_tags.tag_id FROM video_tags SELECT video_tags.tag_id FROM video_tags
WHERE video_id=%d) WHERE video_id=%d)
ORDER BY rand() ORDER BY rand()
LIMIT 5`, lv.MovieId) LIMIT 5`, args.MovieId)
res.SuggestedTag = readTagsFromResultset(database.Query(query)) res.SuggestedTag = readTagsFromResultset(database.Query(query))
// query the actors corresponding to video // query the actors corresponding to video
query = fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos query = fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=%d`, lv.MovieId) WHERE actors_videos.video_id=%d`, args.MovieId)
res.Actors = readActorsFromResultset(database.Query(query)) res.Actors = readActorsFromResultset(database.Query(query))
@ -174,7 +334,20 @@ func loadVideosHandlers() {
return str return str
}) })
AddHandler("getStartData", VideoNode, nil, func() []byte { /**
* @api {post} /api/video [getStartData]
* @apiDescription Get general video informations at start
* @apiName getStartData
* @apiGroup video
*
* @apiSuccess {uint32} VideoNr Total nr of videos
* @apiSuccess {uint32} FullHdNr number of FullHD videos
* @apiSuccess {uint32} HDNr number of HD videos
* @apiSuccess {uint32} SDNr number of SD videos
* @apiSuccess {uint32} DifferentTags number of different Tags available
* @apiSuccess {uint32} Tagged number of different Tags assigned
*/
AddHandler("getStartData", VideoNode, func(info *HandlerInfo) []byte {
var result types.StartData var result types.StartData
// query settings and infotile values // query settings and infotile values
query := ` query := `
@ -215,19 +388,62 @@ func loadVideosHandlers() {
} }
func addToVideoHandlers() { func addToVideoHandlers() {
var al struct { /**
MovieId int * @api {post} /api/video [addLike]
} * @apiDescription Add a like to a video
AddHandler("addLike", VideoNode, &al, func() []byte { * @apiName addLike
query := fmt.Sprintf("update videos set likes = likes + 1 where movie_id = %d", al.MovieId) * @apiGroup video
*
* @apiParam {int} MovieId ID of video
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("addLike", VideoNode, func(info *HandlerInfo) []byte {
var args struct {
MovieId int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
query := fmt.Sprintf("update videos set likes = likes + 1 where movie_id = %d", args.MovieId)
return database.SuccessQuery(query) return database.SuccessQuery(query)
}) })
var dv struct { /**
MovieId int * @api {post} /api/video [deleteVideo]
} * @apiDescription Delete a specific video from database
AddHandler("deleteVideo", VideoNode, &dv, func() []byte { * @apiName deleteVideo
query := fmt.Sprintf("DELETE FROM videos WHERE movie_id=%d", dv.MovieId) * @apiGroup video
*
* @apiParam {int} MovieId ID of video
*
* @apiSuccess {string} result 'success' if successfully or error message if not
*/
AddHandler("deleteVideo", VideoNode, func(info *HandlerInfo) []byte {
var args struct {
MovieId int
}
if err := FillStruct(&args, info.Data); err != nil {
fmt.Println(err.Error())
return nil
}
// delete tag constraints
query := fmt.Sprintf("DELETE FROM video_tags WHERE video_id=%d", args.MovieId)
err := database.Edit(query)
// delete actor constraints
query = fmt.Sprintf("DELETE FROM actors_videos WHERE video_id=%d", args.MovieId)
err = database.Edit(query)
// respond only if result not successful
if err != nil {
return database.ManualSuccessResponse(err)
}
query = fmt.Sprintf("DELETE FROM videos WHERE movie_id=%d", args.MovieId)
return database.SuccessQuery(query) return database.SuccessQuery(query)
}) })
} }

View File

@ -0,0 +1,57 @@
package oauth
import (
"gopkg.in/oauth2.v3"
"openmediacenter/apiGo/database/settings"
)
type CustomClientStore struct {
oauth2.ClientStore
}
type CustomClientInfo struct {
oauth2.ClientInfo
ID string
Secret string
Domain string
UserID string
}
func NewCustomStore() oauth2.ClientStore {
s := new(CustomClientStore)
return s
}
func (a *CustomClientStore) GetByID(id string) (oauth2.ClientInfo, error) {
password := settings.GetPassword()
// if password not set assign default password
if password == nil {
defaultpassword := "openmediacenter"
password = &defaultpassword
}
clientinfo := CustomClientInfo{
ID: "openmediacenter",
Secret: *password,
Domain: "http://localhost:8081",
UserID: "openmediacenter",
}
return &clientinfo, nil
}
func (a *CustomClientInfo) GetID() string {
return a.ID
}
func (a *CustomClientInfo) GetSecret() string {
return a.Secret
}
func (a *CustomClientInfo) GetDomain() string {
return a.Domain
}
func (a *CustomClientInfo) GetUserID() string {
return a.UserID
}

62
apiGo/api/oauth/Oauth.go Normal file
View File

@ -0,0 +1,62 @@
package oauth
import (
"gopkg.in/oauth2.v3"
"gopkg.in/oauth2.v3/errors"
"gopkg.in/oauth2.v3/manage"
"gopkg.in/oauth2.v3/server"
"gopkg.in/oauth2.v3/store"
"log"
"net/http"
)
var srv *server.Server
func InitOAuth() {
manager := manage.NewDefaultManager()
// token store
manager.MustTokenStorage(store.NewMemoryTokenStore())
// create new secretstore
clientStore := NewCustomStore()
manager.MapClientStorage(clientStore)
srv = server.NewServer(server.NewConfig(), manager)
srv.SetClientInfoHandler(server.ClientFormHandler)
manager.SetRefreshTokenCfg(manage.DefaultRefreshTokenCfg)
srv.SetInternalErrorHandler(func(err error) (re *errors.Response) {
log.Println("Internal Error:", err.Error())
return
})
srv.SetResponseErrorHandler(func(re *errors.Response) {
log.Println("Response Error:", re.Error.Error())
})
http.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
err := srv.HandleAuthorizeRequest(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
})
http.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
err := srv.HandleTokenRequest(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
}
func ValidateToken(f func(rw http.ResponseWriter, req *http.Request, node int, tokenInfo *oauth2.TokenInfo), node int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokeninfo, err := srv.ValidationBearerToken(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f(w, r, node, &tokeninfo)
}
}

View File

@ -7,12 +7,12 @@ type VideoUnloadedType struct {
type FullVideoType struct { type FullVideoType struct {
MovieName string MovieName string
MovieId int MovieId uint32
MovieUrl string MovieUrl string
Poster string Poster string
Likes int Likes uint64
Quality int Quality uint16
Length int Length uint16
Tags []Tag Tags []Tag
SuggestedTag []Tag SuggestedTag []Tag
Actors []Actor Actors []Actor
@ -20,22 +20,22 @@ type FullVideoType struct {
type Tag struct { type Tag struct {
TagName string TagName string
TagId int TagId uint32
} }
type Actor struct { type Actor struct {
ActorId int ActorId uint32
Name string Name string
Thumbnail string Thumbnail string
} }
type StartData struct { type StartData struct {
VideoNr int VideoNr uint32
FullHdNr int FullHdNr uint32
HDNr int HDNr uint32
SDNr int SDNr uint32
DifferentTags int DifferentTags uint32
Tagged int Tagged uint32
} }
type SettingsType struct { type SettingsType struct {
@ -46,11 +46,16 @@ type SettingsType struct {
PasswordEnabled bool PasswordEnabled bool
TMDBGrabbing bool TMDBGrabbing bool
DarkMode bool DarkMode bool
}
VideoNr int
DBSize float32 type SettingsSizeType struct {
DifferentTags int VideoNr uint32
TagsAdded int DBSize float32
DifferentTags uint32
PathPrefix string TagsAdded uint32
}
type TVShow struct {
Id uint32
Name string
} }

View File

@ -90,9 +90,7 @@ func Close() {
db.Close() db.Close()
} }
func GetSettings() types.SettingsType { func GetSettings() (result types.SettingsType, PathPrefix string, sizes types.SettingsSizeType) {
var result types.SettingsType
// query settings and infotile values // query settings and infotile values
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT ( SELECT (
@ -120,7 +118,7 @@ func GetSettings() types.SettingsType {
var DarkMode int var DarkMode int
var TMDBGrabbing int var TMDBGrabbing int
err := QueryRow(query).Scan(&result.VideoNr, &result.DBSize, &result.DifferentTags, &result.TagsAdded, err := QueryRow(query).Scan(&sizes.VideoNr, &sizes.DBSize, &sizes.DifferentTags, &sizes.TagsAdded,
&result.VideoPath, &result.EpisodePath, &result.Password, &result.MediacenterName, &TMDBGrabbing, &DarkMode) &result.VideoPath, &result.EpisodePath, &result.Password, &result.MediacenterName, &TMDBGrabbing, &DarkMode)
if err != nil { if err != nil {
@ -130,7 +128,6 @@ func GetSettings() types.SettingsType {
result.TMDBGrabbing = TMDBGrabbing != 0 result.TMDBGrabbing = TMDBGrabbing != 0
result.PasswordEnabled = result.Password != "-1" result.PasswordEnabled = result.Password != "-1"
result.DarkMode = DarkMode != 0 result.DarkMode = DarkMode != 0
result.PathPrefix = SettingsVideoPrefix PathPrefix = SettingsVideoPrefix
return
return result
} }

View File

@ -0,0 +1,38 @@
package settings
import (
"fmt"
"openmediacenter/apiGo/database"
)
func GetPassword() *string {
pwd := LoadSettings().Pasword
if pwd == "-1" {
return nil
} else {
return &pwd
}
}
type SettingsType struct {
DarkMode bool
Pasword string
MediacenterName string
VideoPath string
TVShowPath string
}
func LoadSettings() *SettingsType {
query := "SELECT DarkMode, password, mediacenter_name, video_path, episode_path from settings"
result := SettingsType{}
var darkmode uint8
err := database.QueryRow(query).Scan(&darkmode, &result.Pasword, &result.MediacenterName, &result.VideoPath, &result.TVShowPath)
if err != nil {
fmt.Println("error while parsing db data: " + err.Error())
}
result.DarkMode = darkmode != 0
return &result
}

View File

@ -0,0 +1,11 @@
package settings
var tvShowEnabled bool
func TVShowsEnabled() bool {
return tvShowEnabled
}
func SetTVShowEnabled(enabled bool) {
tvShowEnabled = enabled
}

View File

@ -2,4 +2,8 @@ module openmediacenter/apiGo
go 1.16 go 1.16
require github.com/go-sql-driver/mysql v1.5.0 require (
github.com/go-sql-driver/mysql v1.5.0
gopkg.in/oauth2.v3 v3.12.0
nhooyr.io/websocket v1.8.7
)

View File

@ -1,2 +1,114 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0 h1:QnyrPZZvPmR0AtJCxxfCtI1qN+fYpKTKJ/5opWmZ34k=
github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.1.0 h1:H6LzK59KiNjf1nHVPFrYj4Qnl8d8YLBsYamdL8N+Bao=
github.com/tidwall/buntdb v1.1.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
github.com/tidwall/gjson v1.3.2 h1:+7p3qQFaH3fOMXAJSrdZwGKcOO/lYdGS0HqGhPqDdTI=
github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb h1:5NSYaAdrnblKByzd7XByQEJVT8+9v0W/tIY0Oo4OwrE=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2KoJQD9cTQ6dyP2co9q4yzmT9FZo=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/oauth2.v3 v3.12.0 h1:yOffAPoolH/i2JxwmC+pgtnY3362iPahsDpLXfDFvNg=
gopkg.in/oauth2.v3 v3.12.0/go.mod h1:XEYgKqWX095YiPT+Aw5y3tCn+7/FMnlTFKrupgSiJ3I=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

@ -3,12 +3,18 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"log"
"net/http"
"openmediacenter/apiGo/api" "openmediacenter/apiGo/api"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
settings2 "openmediacenter/apiGo/database/settings"
"openmediacenter/apiGo/static"
"openmediacenter/apiGo/videoparser"
) )
func main() { func main() {
fmt.Println("init OpenMediaCenter server") fmt.Println("init OpenMediaCenter server")
port := 8081
db, verbose, pathPrefix := handleCommandLineArguments() db, verbose, pathPrefix := handleCommandLineArguments()
// todo some verbosity logger or sth // todo some verbosity logger or sth
@ -26,8 +32,17 @@ func main() {
api.AddSettingsHandlers() api.AddSettingsHandlers()
api.AddTagHandlers() api.AddTagHandlers()
api.AddActorsHandlers() api.AddActorsHandlers()
api.AddTvshowHandlers()
api.ServerInit(8081) videoparser.SetupSettingsWebsocket()
// add the static files
static.ServeStaticFiles()
api.ServerInit()
fmt.Printf("OpenMediacenter server up and running on port %d\n", port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
} }
func handleCommandLineArguments() (*database.DatabaseConfig, bool, *string) { func handleCommandLineArguments() (*database.DatabaseConfig, bool, *string) {
@ -41,8 +56,12 @@ func handleCommandLineArguments() (*database.DatabaseConfig, bool, *string) {
pathPrefix := flag.String("ReindexPrefix", "/var/www/openmediacenter", "Prefix path for videos to reindex") pathPrefix := flag.String("ReindexPrefix", "/var/www/openmediacenter", "Prefix path for videos to reindex")
disableTVShowSupport := flag.Bool("DisableTVSupport", false, "Disable the TVShow support and pages")
flag.Parse() flag.Parse()
settings2.SetTVShowEnabled(!*disableTVShowSupport)
return &database.DatabaseConfig{ return &database.DatabaseConfig{
DBHost: *dbhostPtr, DBHost: *dbhostPtr,
DBPort: *dbPortPtr, DBPort: *dbPortPtr,

View File

@ -0,0 +1,89 @@
// +build static
package static
import (
"embed"
"fmt"
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"openmediacenter/apiGo/database/settings"
"regexp"
"strings"
)
//go:embed build
var staticFiles embed.FS
func ServeStaticFiles() {
// http.FS can be used to create a http Filesystem
subfs, _ := fs.Sub(staticFiles, "build")
staticFS := http.FS(subfs)
fs := http.FileServer(staticFS)
// Serve static files
http.Handle("/", validatePrefix(fs))
// we need to proxy the videopath to somewhere in a standalone binary
proxyVideoURL()
}
type handler struct {
proxy *httputil.ReverseProxy
}
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.proxy.ServeHTTP(w, r)
}
func proxyVideoURL() {
conf := settings.LoadSettings()
// match base url
regexMatchUrl := regexp.MustCompile("^http(|s):\\/\\/([0-9]){1,3}\\.([0-9]){1,3}\\.([0-9]){1,3}\\.([0-9]){1,3}:[0-9]{1,5}")
var videoUrl *url.URL
if regexMatchUrl.MatchString(conf.VideoPath) {
fmt.Println("matches string...")
var err error
videoUrl, err = url.Parse(regexMatchUrl.FindString(conf.VideoPath))
if err != nil {
panic(err)
}
} else {
videoUrl, _ = url.Parse("http://127.0.0.1:8081")
}
director := func(req *http.Request) {
req.URL.Scheme = videoUrl.Scheme
req.URL.Host = videoUrl.Host
}
serverVideoPath := strings.TrimPrefix(conf.VideoPath, regexMatchUrl.FindString(conf.VideoPath))
reverseProxy := &httputil.ReverseProxy{Director: director}
handler := handler{proxy: reverseProxy}
http.Handle(serverVideoPath, handler)
}
// ValidatePrefix check if requested path is a file -- if not proceed with index.html
func validatePrefix(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
regex := regexp.MustCompile("\\..*$")
matchFile := regex.MatchString(r.URL.Path)
if matchFile {
h.ServeHTTP(w, r)
} else {
r2 := new(http.Request)
*r2 = *r
r2.URL = new(url.URL)
*r2.URL = *r.URL
r2.URL.Path = "/"
r2.URL.RawPath = "/"
h.ServeHTTP(w, r2)
}
})
}

View File

@ -0,0 +1,6 @@
// +build !static
package static
// add nothing on no static build
func ServeStaticFiles() {}

View File

@ -0,0 +1,120 @@
package videoparser
import (
"encoding/base64"
"encoding/json"
"fmt"
"os/exec"
"strconv"
)
func AppendMessage(message string) {
msger := TextMessage{
MessageBase: MessageBase{Action: "message"},
Message: message,
}
marshal, err := json.Marshal(msger)
if err != nil {
return
}
IndexSender.Publish(marshal)
}
func SendEvent(message string) {
msger := ReindexEvent{
MessageBase: MessageBase{Action: "reindexAction"},
Event: message,
}
marshal, err := json.Marshal(msger)
if err != nil {
return
}
IndexSender.Publish(marshal)
}
// ext dependency support check
func checkExtDependencySupport() *ExtDependencySupport {
var extDepsAvailable ExtDependencySupport
extDepsAvailable.FFMpeg = commandExists("ffmpeg")
extDepsAvailable.MediaInfo = commandExists("mediainfo")
return &extDepsAvailable
}
// check if a specific system command is available
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// parse the thumbail picture from video file
func parseFFmpegPic(path string) (*string, error) {
app := "ffmpeg"
cmd := exec.Command(app,
"-hide_banner",
"-loglevel", "panic",
"-ss", "00:04:00",
"-i", path,
"-vframes", "1",
"-q:v", "2",
"-f", "singlejpeg",
"pipe:1")
stdout, err := cmd.Output()
if err != nil {
fmt.Println(err.Error())
fmt.Println(string(err.(*exec.ExitError).Stderr))
return nil, err
}
strEncPic := base64.StdEncoding.EncodeToString(stdout)
if strEncPic == "" {
return nil, nil
}
backpic64 := fmt.Sprintf("data:image/jpeg;base64,%s", strEncPic)
return &backpic64, nil
}
func getVideoAttributes(path string) *VideoAttributes {
app := "mediainfo"
arg0 := path
arg1 := "--Output=JSON"
cmd := exec.Command(app, arg1, "-f", arg0)
stdout, err := cmd.Output()
var t struct {
Media struct {
Track []struct {
Duration string
FileSize string
Width string
}
}
}
err = json.Unmarshal(stdout, &t)
if err != nil {
fmt.Println(err.Error())
return nil
}
duration, err := strconv.ParseFloat(t.Media.Track[0].Duration, 32)
filesize, err := strconv.Atoi(t.Media.Track[0].FileSize)
width, err := strconv.Atoi(t.Media.Track[1].Width)
ret := VideoAttributes{
Duration: float32(duration),
FileSize: uint(filesize),
Width: uint(width),
}
return &ret
}

View File

@ -0,0 +1,138 @@
package videoparser
import (
"fmt"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/videoparser/tmdb"
"regexp"
"strconv"
"strings"
)
func startTVShowReindex(files []Show) {
allTVshows := getAllTVShows()
for _, file := range files {
// insert new TVShow entry if not existing.
insertShowIfNotExisting(file, allTVshows)
AppendMessage("Processing show: " + file.Name)
insertEpisodesIfNotExisting(file)
}
AppendMessage("reindex finished successfully!")
SendEvent("stop")
}
func insertEpisodesIfNotExisting(show Show) {
query := "SELECT tvshow_episodes.name, season, episode FROM tvshow_episodes JOIN tvshow t on t.id = tvshow_episodes.tvshow_id WHERE t.name=?"
rows := database.Query(query, show.Name)
var dbepisodes []string
for rows.Next() {
var epname string
var season int
var episode int
err := rows.Scan(&epname, &season, &episode)
if err != nil {
fmt.Println(err.Error())
}
dbepisodes = append(dbepisodes, fmt.Sprintf("%s S%02dE%02d.mp4", epname, season, episode))
}
// get those episodes that are missing in db
diff := difference(show.files, dbepisodes)
for _, s := range diff {
AppendMessage("Adding Episode: " + s)
insertEpisode(s, show.Name)
}
}
func insertEpisode(path string, ShowName string) {
seasonRegex := regexp.MustCompile("S[0-9][0-9]")
episodeRegex := regexp.MustCompile("E[0-9][0-9]")
matchENDPattern := regexp.MustCompile(" S[0-9][0-9]E[0-9][0-9].+$")
seasonStr := seasonRegex.FindString(path)
episodeStr := episodeRegex.FindString(path)
extString := matchENDPattern.FindString(path)
// handle invalid matches
if len(seasonStr) != 3 || len(episodeStr) != 3 || len(extString) < 8 {
fmt.Printf("Error inserting episode: %s -- %s/%s/%s\n", path, seasonStr, episodeStr, extString)
return
}
name := strings.TrimSuffix(path, extString)
season, err := strconv.ParseInt(seasonStr[1:], 10, 8)
episode, err := strconv.ParseInt(episodeStr[1:], 10, 8)
if err != nil {
fmt.Println(err.Error())
}
query := `
INSERT INTO tvshow_episodes (name, season, poster, tvshow_id, episode, filename)
VALUES (?, ?, ?, (SELECT tvshow.id FROM tvshow WHERE tvshow.name=?), ?, ?)`
err = database.Edit(query, name, season, "", ShowName, episode, path)
if err != nil {
fmt.Println(err.Error())
}
}
// difference returns the elements in `a` that aren't in `b`.
func difference(a, b []string) []string {
mb := make(map[string]struct{}, len(b))
for _, x := range b {
mb[x] = struct{}{}
}
var diff []string
for _, x := range a {
if _, found := mb[x]; !found {
diff = append(diff, x)
}
}
return diff
}
func insertShowIfNotExisting(show Show, allShows *[]string) {
// if show already exists return
fmt.Println(*allShows)
for _, s := range *allShows {
if s == show.Name {
return
}
}
// insert empty thubnail if tmdb fails
thubnail := ""
// load tmdb infos
tmdbInfo := tmdb.SearchTVShow(show.Name)
if tmdbInfo != nil {
thubnail = tmdbInfo.Thumbnail
}
// currently the foldernamme == name which mustn't necessarily be
query := "INSERT INTO tvshow (name, thumbnail, foldername) VALUES (?, ?, ?)"
err := database.Edit(query, show.Name, thubnail, show.Name)
if err != nil {
fmt.Println(err.Error())
}
}
func getAllTVShows() *[]string {
query := "SELECT name FROM tvshow"
rows := database.Query(query)
var res []string
for rows.Next() {
var show string
rows.Scan(&show)
res = append(res, show)
}
return &res
}

View File

@ -0,0 +1,13 @@
package videoparser
import "testing"
func TestDifference(t *testing.T) {
arr1 := []string{"test1", "test2", "test3"}
arr2 := []string{"test1", "test3"}
res := difference(arr1, arr2)
if len(res) != 1 || res[0] != "test2" {
t.Errorf("wrong difference result.")
}
}

View File

@ -2,15 +2,13 @@ package videoparser
import ( import (
"database/sql" "database/sql"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"openmediacenter/apiGo/api/types" "openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
"openmediacenter/apiGo/videoparser/tmdb" "openmediacenter/apiGo/videoparser/tmdb"
"os/exec"
"regexp" "regexp"
"strconv" "strconv"
"strings"
) )
var mSettings types.SettingsType var mSettings types.SettingsType
@ -41,35 +39,71 @@ func ReIndexVideos(path []string, sett types.SettingsType) {
fmt.Printf("FFMPEG support: %t\n", mExtDepsAvailable.FFMpeg) fmt.Printf("FFMPEG support: %t\n", mExtDepsAvailable.FFMpeg)
fmt.Printf("MediaInfo support: %t\n", mExtDepsAvailable.MediaInfo) fmt.Printf("MediaInfo support: %t\n", mExtDepsAvailable.MediaInfo)
for _, s := range path { // filter out those urls which are already existing in db
nonExisting := filterExisting(path)
fmt.Printf("There are %d videos not existing in db.\n", len(*nonExisting))
for _, s := range *nonExisting {
processVideo(s) processVideo(s)
} }
AppendMessageBuffer("reindex finished successfully!") AppendMessage("reindex finished successfully!")
SendEvent("stop")
contentAvailable = false
fmt.Println("Reindexing finished!") fmt.Println("Reindexing finished!")
} }
// filter those entries from array which are already existing!
func filterExisting(paths []string) *[]string {
var nameStr string
// build the query string with files on disk
for i, s := range paths {
// escape ' in url name
s = strings.Replace(s, "'", "\\'", -1)
nameStr += "SELECT '" + s + "' "
// if first index add as url
if i == 0 {
nameStr += "AS url "
}
// if not last index add union all
if i != len(paths)-1 {
nameStr += "UNION ALL "
}
}
query := fmt.Sprintf("SELECT * FROM (%s) urls WHERE urls.url NOT IN(SELECT movie_url FROM videos)", nameStr)
rows := database.Query(query)
var resultarr []string
// parse the result rows into a array
for rows.Next() {
var url string
err := rows.Scan(&url)
if err != nil {
continue
}
resultarr = append(resultarr, url)
}
rows.Close()
return &resultarr
}
func processVideo(fileNameOrig string) { func processVideo(fileNameOrig string) {
fmt.Printf("Processing %s video-", fileNameOrig) fmt.Printf("Processing %s video-", fileNameOrig)
// match the file extension // match the file extension
r, _ := regexp.Compile(`\.[a-zA-Z0-9]+$`) r := regexp.MustCompile(`\.[a-zA-Z0-9]+$`)
fileName := r.ReplaceAllString(fileNameOrig, "") fileName := r.ReplaceAllString(fileNameOrig, "")
// match the year and cut year from name
year, fileName := matchYear(fileName) year, fileName := matchYear(fileName)
// now we should look if this video already exists in db fmt.Printf("The Video %s doesn't exist! Adding it to database.\n", fileName)
query := "SELECT * FROM videos WHERE movie_name = ?" addVideo(fileName, fileNameOrig, year)
err := database.QueryRow(query, fileName).Scan()
if err == sql.ErrNoRows {
fmt.Printf("The Video %s does't exist! Adding it to database.\n", fileName)
addVideo(fileName, fileNameOrig, year)
} else {
fmt.Println(" :existing!")
}
} }
// add a video to the database // add a video to the database
@ -87,7 +121,7 @@ func addVideo(videoName string, fileName string, year int) {
} }
if mExtDepsAvailable.FFMpeg { if mExtDepsAvailable.FFMpeg {
ppic, err = parseFFmpegPic(fileName) ppic, err = parseFFmpegPic(mSettings.VideoPath + fileName)
if err != nil { if err != nil {
fmt.Printf("FFmpeg error occured: %s\n", err.Error()) fmt.Printf("FFmpeg error occured: %s\n", err.Error())
} else { } else {
@ -96,7 +130,7 @@ func addVideo(videoName string, fileName string, year int) {
} }
if mExtDepsAvailable.MediaInfo { if mExtDepsAvailable.MediaInfo {
atr := getVideoAttributes(fileName) atr := getVideoAttributes(mSettings.VideoPath + fileName)
if atr != nil { if atr != nil {
vidAtr = atr vidAtr = atr
} }
@ -130,17 +164,18 @@ func addVideo(videoName string, fileName string, year int) {
insertTMDBTags(tmdbData.GenreIds, insertId) insertTMDBTags(tmdbData.GenreIds, insertId)
} }
AppendMessageBuffer(fmt.Sprintf("%s - added!", videoName)) AppendMessage(fmt.Sprintf("%s - added!", videoName))
} }
func matchYear(fileName string) (int, string) { func matchYear(fileName string) (int, string) {
r, _ := regexp.Compile(`\([0-9]{4}?\)`) r := regexp.MustCompile(`\([0-9]{4}?\)`)
years := r.FindAllString(fileName, -1) years := r.FindAllString(fileName, -1)
if len(years) == 0 { if len(years) == 0 {
return -1, fileName return -1, fileName
} }
yearStr := years[len(years)-1]
year, err := strconv.Atoi(years[len(years)-1]) // get last year occurance and cut first and last char
year, err := strconv.Atoi(yearStr[1 : len(yearStr)-1])
if err != nil { if err != nil {
return -1, fileName return -1, fileName
@ -150,91 +185,6 @@ func matchYear(fileName string) (int, string) {
return year, r.ReplaceAllString(fileName, "") return year, r.ReplaceAllString(fileName, "")
} }
// parse the thumbail picture from video file
func parseFFmpegPic(fileName string) (*string, error) {
app := "ffmpeg"
cmd := exec.Command(app,
"-hide_banner",
"-loglevel", "panic",
"-ss", "00:04:00",
"-i", mSettings.VideoPath+fileName,
"-vframes", "1",
"-q:v", "2",
"-f", "singlejpeg",
"pipe:1")
stdout, err := cmd.Output()
if err != nil {
fmt.Println(err.Error())
fmt.Println(string(err.(*exec.ExitError).Stderr))
return nil, err
}
backpic64 := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(stdout)
return &backpic64, nil
}
func getVideoAttributes(fileName string) *VideoAttributes {
app := "mediainfo"
arg0 := mSettings.VideoPath + fileName
arg1 := "--Output=JSON"
cmd := exec.Command(app, arg1, "-f", arg0)
stdout, err := cmd.Output()
var t struct {
Media struct {
Track []struct {
Duration string
FileSize string
Width string
}
}
}
err = json.Unmarshal(stdout, &t)
if err != nil {
fmt.Println(err.Error())
return nil
}
duration, err := strconv.ParseFloat(t.Media.Track[0].Duration, 32)
filesize, err := strconv.Atoi(t.Media.Track[0].FileSize)
width, err := strconv.Atoi(t.Media.Track[1].Width)
ret := VideoAttributes{
Duration: float32(duration),
FileSize: uint(filesize),
Width: uint(width),
}
return &ret
}
func AppendMessageBuffer(message string) {
messageBuffer = append(messageBuffer, message)
}
// ext dependency support check
func checkExtDependencySupport() *ExtDependencySupport {
var extDepsAvailable ExtDependencySupport
extDepsAvailable.FFMpeg = commandExists("ffmpeg")
extDepsAvailable.MediaInfo = commandExists("mediainfo")
return &extDepsAvailable
}
// check if a specific system command is available
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// insert the default size tags to corresponding video // insert the default size tags to corresponding video
func insertSizeTag(width uint, videoId uint) { func insertSizeTag(width uint, videoId uint) {
var tagType uint var tagType uint

View File

@ -2,33 +2,32 @@ package videoparser
import ( import (
"fmt" "fmt"
"io/ioutil"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
var messageBuffer []string
var contentAvailable = false
type StatusMessage struct { type StatusMessage struct {
Messages []string Messages []string
ContentAvailable bool ContentAvailable bool
} }
func StartReindex() bool { func StartReindex() bool {
messageBuffer = []string{}
contentAvailable = true
fmt.Println("starting reindex..") fmt.Println("starting reindex..")
SendEvent("start")
AppendMessage("starting reindex..")
mSettings := database.GetSettings() mSettings, PathPrefix, _ := database.GetSettings()
// add the path prefix to videopath // add the path prefix to videopath
mSettings.VideoPath = mSettings.PathPrefix + mSettings.VideoPath mSettings.VideoPath = PathPrefix + mSettings.VideoPath
// check if path even exists // check if path even exists
if _, err := os.Stat(mSettings.VideoPath); os.IsNotExist(err) { if _, err := os.Stat(mSettings.VideoPath); os.IsNotExist(err) {
fmt.Println("Reindex path doesn't exist!") fmt.Println("Reindex path doesn't exist!")
AppendMessage(fmt.Sprintf("Reindex path doesn't exist! :%s", mSettings.VideoPath))
SendEvent("stop")
return false return false
} }
@ -49,20 +48,77 @@ func StartReindex() bool {
fmt.Println(err.Error()) fmt.Println(err.Error())
} }
// start reindex process // start reindex process
AppendMessageBuffer("Starting Reindexing!") AppendMessage("Starting Reindexing!")
go ReIndexVideos(files, mSettings) go ReIndexVideos(files, mSettings)
return true return true
} }
func GetStatusMessage() *StatusMessage { type Show struct {
msg := StatusMessage{ Name string
Messages: messageBuffer, files []string
ContentAvailable: contentAvailable, }
// StartTVShowReindex reindex dir walks for TVShow reindex
func StartTVShowReindex() {
fmt.Println("starting tvshow reindex..")
SendEvent("start")
AppendMessage("starting tvshow reindex...")
mSettings, PathPrefix, _ := database.GetSettings()
// add the path prefix to videopath
mSettings.EpisodePath = PathPrefix + mSettings.EpisodePath
// add slash suffix if not existing
if !strings.HasSuffix(mSettings.EpisodePath, "/") {
mSettings.EpisodePath += "/"
} }
messageBuffer = []string{} // check if path even exists
if _, err := os.Stat(mSettings.EpisodePath); os.IsNotExist(err) {
msg := fmt.Sprintf("Reindex path doesn't exist! :%s", mSettings.EpisodePath)
fmt.Println(msg)
AppendMessage(msg)
SendEvent("stop")
return
}
return &msg var files []Show
filess, err := ioutil.ReadDir(mSettings.EpisodePath)
if err != nil {
fmt.Println(err.Error())
}
for _, file := range filess {
if file.IsDir() {
elem := Show{
Name: file.Name(),
files: nil,
}
fmt.Println(file.Name())
episodefiles, err := ioutil.ReadDir(mSettings.EpisodePath + file.Name())
if err != nil {
fmt.Println(err.Error())
}
for _, epfile := range episodefiles {
if strings.HasSuffix(epfile.Name(), ".mp4") {
elem.files = append(elem.files, epfile.Name())
}
}
files = append(files, elem)
}
}
if err != nil {
fmt.Println(err.Error())
}
// start reindex process
AppendMessage("Starting Reindexing!")
go startTVShowReindex(files)
} }
func StartCleanup() { func StartCleanup() {

View File

@ -0,0 +1,135 @@
package videoparser
import (
"context"
"errors"
"fmt"
"net/http"
"nhooyr.io/websocket"
"sync"
"time"
)
// subscriber represents a subscriber.
// Messages are sent on the msgs channel and if the client
// cannot keep up with the messages, closeSlow is called.
type subscriber struct {
msgs chan []byte
closeSlow func()
}
type ChatSender struct {
subscribersMu sync.Mutex
subscribers map[*subscriber]struct{}
}
func newChatSender() *ChatSender {
return &ChatSender{
subscribers: make(map[*subscriber]struct{}),
}
}
func (t *ChatSender) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
fmt.Println(err.Error())
return
}
defer c.Close(websocket.StatusInternalError, "")
err = t.subscribe(r.Context(), c)
if errors.Is(err, context.Canceled) {
return
}
if websocket.CloseStatus(err) == websocket.StatusNormalClosure ||
websocket.CloseStatus(err) == websocket.StatusGoingAway {
return
}
if err != nil {
fmt.Println(err.Error())
return
}
}
func (t *ChatSender) subscribe(ctx context.Context, c *websocket.Conn) error {
ctx = c.CloseRead(ctx)
s := &subscriber{
msgs: make(chan []byte, 16),
closeSlow: func() {
c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
},
}
t.addSubscriber(s)
defer t.deleteSubscriber(s)
for {
select {
case msg := <-s.msgs:
err := writeTimeout(ctx, time.Second*5, c, msg)
if err != nil {
return err
}
case <-ctx.Done():
return ctx.Err()
}
}
}
type MessageBase struct {
Action string
}
type TextMessage struct {
MessageBase
Message string
}
type ReindexEvent struct {
MessageBase
Event string
}
func (t *ChatSender) Publish(msg []byte) {
t.subscribersMu.Lock()
defer t.subscribersMu.Unlock()
for s := range t.subscribers {
select {
case s.msgs <- msg:
default:
go s.closeSlow()
}
}
}
var IndexSender = newChatSender()
func SetupSettingsWebsocket() {
http.Handle("/subscribe", IndexSender)
}
// addSubscriber registers a subscriber.
func (t *ChatSender) addSubscriber(s *subscriber) {
t.subscribersMu.Lock()
t.subscribers[s] = struct{}{}
t.subscribersMu.Unlock()
}
// deleteSubscriber deletes the given subscriber.
func (t *ChatSender) deleteSubscriber(s *subscriber) {
t.subscribersMu.Lock()
delete(t.subscribers, s)
t.subscribersMu.Unlock()
}
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return c.Write(ctx, websocket.MessageText, msg)
}

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"regexp" "regexp"
) )
@ -20,21 +21,43 @@ type VideoTMDB struct {
GenreIds []int GenreIds []int
} }
type TVShowTMDB struct {
Thumbnail string
Overview string
GenreIds []int
}
type tmdbVidResult struct { type tmdbVidResult struct {
Poster_path string PosterPath *string `json:"poster_path"`
Adult bool Adult bool `json:"adult"`
Overview string Overview string `json:"overview"`
Release_date string ReleaseDate string `json:"release_date"`
Genre_ids []int GenreIds []int `json:"genre_ids"`
Id int Id int `json:"id"`
Original_title string OriginalTitle string `json:"original_title"`
Original_language string OriginalLanguage string `json:"original_language"`
Title string Title string `json:"title"`
Backdrop_path string BackdropPath *string `json:"backdrop_path"`
Popularity int Popularity int `json:"popularity"`
Vote_count int VoteCount int `json:"vote_count"`
Video bool Video bool `json:"video"`
Vote_average int VoteAverage int `json:"vote_average"`
}
type tmdbTvResult struct {
PosterPath *string `json:"poster_path"`
Popularity int `json:"popularity"`
Id int `json:"id"`
BackdropPath *string `json:"backdrop_path"`
VoteAverage int `json:"vote_average"`
Overview string `json:"overview"`
FirstAirDate string `json:"first_air_date"`
OriginCountry []string `json:"origin_country"`
GenreIds []int `json:"genre_ids"`
OriginalLanguage string `json:"original_language"`
VoteCount int `json:"vote_count"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
} }
type TMDBGenre struct { type TMDBGenre struct {
@ -43,8 +66,9 @@ type TMDBGenre struct {
} }
func SearchVideo(MovieName string, year int) *VideoTMDB { func SearchVideo(MovieName string, year int) *VideoTMDB {
url := fmt.Sprintf("%ssearch/movie?api_key=%s&query=%s", baseUrl, apiKey, MovieName) fmt.Printf("Searching TMDB for: Moviename: %s, year:%d \n", MovieName, year)
resp, err := http.Get(url) queryURL := fmt.Sprintf("%ssearch/movie?api_key=%s&query=%s", baseUrl, apiKey, url.QueryEscape(MovieName))
resp, err := http.Get(queryURL)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
return nil return nil
@ -63,11 +87,16 @@ func SearchVideo(MovieName string, year int) *VideoTMDB {
fmt.Println(len(t.Results)) fmt.Println(len(t.Results))
// if there was no match with tmdb return 0
if len(t.Results) == 0 {
return nil
}
var tmdbVid tmdbVidResult var tmdbVid tmdbVidResult
if year != -1 { if year != -1 {
for _, result := range t.Results { for _, result := range t.Results {
r, _ := regexp.Compile(fmt.Sprintf(`^%d-[0-9]{2}?-[0-9]{2}?$`, year)) r, _ := regexp.Compile(fmt.Sprintf(`^%d-[0-9]{2}?-[0-9]{2}?$`, year))
if r.MatchString(result.Release_date) { if r.MatchString(result.ReleaseDate) {
tmdbVid = result tmdbVid = result
// continue parsing // continue parsing
goto cont goto cont
@ -82,22 +111,69 @@ func SearchVideo(MovieName string, year int) *VideoTMDB {
// continue label // continue label
cont: cont:
thumbnail := fetchPoster(tmdbVid) var thumbnail = ""
if tmdbVid.PosterPath != nil {
pic := fetchPoster(*tmdbVid.PosterPath)
if pic != nil {
thumbnail = *pic
}
}
result := VideoTMDB{ result := VideoTMDB{
Thumbnail: *thumbnail, Thumbnail: thumbnail,
Overview: tmdbVid.Overview, Overview: tmdbVid.Overview,
Title: tmdbVid.Title, Title: tmdbVid.Title,
GenreIds: tmdbVid.Genre_ids, GenreIds: tmdbVid.GenreIds,
} }
return &result return &result
} }
func fetchPoster(vid tmdbVidResult) *string { func SearchTVShow(Name string) *TVShowTMDB {
url := fmt.Sprintf("%s%s", pictureBase, vid.Poster_path) fmt.Printf("Searching TMDB for: TVShow: %s\n", Name)
queryURL := fmt.Sprintf("%ssearch/tv?api_key=%s&query=%s", baseUrl, apiKey, url.QueryEscape(Name))
resp, err := http.Get(queryURL)
if err != nil {
fmt.Println(err.Error())
return nil
}
resp, err := http.Get(url) body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println(err.Error())
return nil
}
var t struct {
Results []tmdbTvResult `json:"results"`
}
err = json.Unmarshal(body, &t)
fmt.Println(len(t.Results))
if len(t.Results) == 0 {
return nil
}
res := TVShowTMDB{
Thumbnail: "",
Overview: t.Results[0].Overview,
GenreIds: t.Results[0].GenreIds,
}
if t.Results[0].PosterPath != nil {
pic := fetchPoster(*t.Results[0].PosterPath)
if pic != nil {
res.Thumbnail = *pic
}
}
return &res
}
func fetchPoster(posterPath string) *string {
posterURL := fmt.Sprintf("%s%s", pictureBase, posterPath)
resp, err := http.Get(posterURL)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
return nil return nil
@ -116,8 +192,8 @@ func fetchPoster(vid tmdbVidResult) *string {
var tmdbGenres *[]TMDBGenre var tmdbGenres *[]TMDBGenre
func fetchGenres() *[]TMDBGenre { func fetchGenres() *[]TMDBGenre {
url := fmt.Sprintf("%sgenre/movie/list?api_key=%s", baseUrl, apiKey) posterURL := fmt.Sprintf("%sgenre/movie/list?api_key=%s", baseUrl, apiKey)
resp, err := http.Get(url) resp, err := http.Get(posterURL)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
return nil return nil

View File

@ -9,12 +9,12 @@ create table if not exists actors
create table if not exists settings create table if not exists settings
( (
video_path varchar(255) null, video_path varchar(255) null,
episode_path varchar(255) null, episode_path varchar(255) null,
password varchar(32) null, password varchar(32) default '-1' null,
mediacenter_name varchar(32) null, mediacenter_name varchar(32) default 'OpenMediaCenter' null,
TMDB_grabbing tinyint null, TMDB_grabbing tinyint null,
DarkMode tinyint default 0 null DarkMode tinyint default 0 null
); );
create table if not exists tags create table if not exists tags
@ -24,18 +24,41 @@ create table if not exists tags
tag_name varchar(50) null tag_name varchar(50) null
); );
create table if not exists tvshow
(
name varchar(100) null,
thumbnail mediumblob null,
id int auto_increment
primary key,
foldername varchar(100) null
);
create table if not exists tvshow_episodes
(
id int auto_increment
primary key,
name varchar(100) null,
season int null,
poster mediumblob null,
tvshow_id int null,
episode int null,
filename varchar(100) null,
constraint tvshow_episodes_tvshow_id_fk
foreign key (tvshow_id) references tvshow (id)
);
create table if not exists videos create table if not exists videos
( (
movie_id int auto_increment movie_id int auto_increment
primary key, primary key,
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,
likes int default 0 null, poster mediumblob null,
create_date datetime default CURRENT_TIMESTAMP null, likes int default 0 null,
quality int null, quality int null,
length int null comment 'in seconds', length int null comment 'in seconds',
poster mediumblob null create_date datetime default current_timestamp() null
); );
create table if not exists actors_videos create table if not exists actors_videos
@ -48,10 +71,10 @@ create table if not exists actors_videos
foreign key (video_id) references videos (movie_id) foreign key (video_id) references videos (movie_id)
); );
create index actors_videos_actor_id_index create index if not exists actors_videos_actor_id_index
on actors_videos (actor_id); on actors_videos (actor_id);
create index actors_videos_video_id_index create index if not exists actors_videos_video_id_index
on actors_videos (video_id); on actors_videos (video_id);
create table if not exists video_tags create table if not exists video_tags

View File

@ -18,4 +18,4 @@ chown -R www-data:www-data /var/www/openmediacenter
systemctl restart nginx systemctl restart nginx
systemctl enable OpenMediaCenter.service systemctl enable OpenMediaCenter.service
systemctl start OpenMediaCenter.service systemctl restart OpenMediaCenter.service

View File

@ -13,7 +13,14 @@ server {
try_files $uri /index.html; try_files $uri /index.html;
} }
location /api/ { location ~* ^/(api/|token) {
proxy_pass http://127.0.0.1:8081; proxy_pass http://127.0.0.1:8081;
} }
location /subscribe {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
} }

View File

@ -1,8 +1,7 @@
{ {
"name": "openmediacenter", "name": "openmediacenter",
"version": "0.1.2", "version": "0.1.3",
"private": true, "private": true,
"main": "public/electron.js",
"author": { "author": {
"email": "lukas.heiligenbrunner@gmail.com", "email": "lukas.heiligenbrunner@gmail.com",
"name": "Lukas Heiligenbrunner", "name": "Lukas Heiligenbrunner",
@ -13,19 +12,21 @@
"@fortawesome/free-regular-svg-icons": "^5.15.1", "@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1", "@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "^0.1.13", "@fortawesome/react-fontawesome": "^0.1.13",
"bootstrap": "^4.5.3", "bootstrap": "^5.0.2",
"plyr-react": "^3.0.7", "plyr-react": "^3.0.7",
"react": "^17.0.1", "react": "^17.0.1",
"react-bootstrap": "^1.4.0", "react-bootstrap": "^1.4.0",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"typescript": "^4.1.3" "typescript": "^4.3.5"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "CI=false react-scripts build",
"test": "CI=true react-scripts test --reporters=jest-junit --verbose --silent --coverage --reporters=default" "test": "CI=true react-scripts test --reporters=jest-junit --verbose --silent --coverage --reporters=default",
"lint": "eslint --format gitlab src/",
"apidoc": "apidoc -i apiGo/ -o doc/"
}, },
"jest": { "jest": {
"collectCoverageFrom": [ "collectCoverageFrom": [
@ -39,23 +40,6 @@
}, },
"proxy": "http://127.0.0.1:8081", "proxy": "http://127.0.0.1:8081",
"homepage": "/", "homepage": "/",
"eslintConfig": {
"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": [
">0.2%", ">0.2%",
@ -69,18 +53,37 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.11.6", "@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.2.2", "@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^12.6.0", "@testing-library/user-event": "^13.2.1",
"@types/jest": "^26.0.19", "@types/jest": "^26.0.24",
"@types/node": "^12.19.9", "@types/node": "^16.4.7",
"@types/react": "^16.14.2", "@types/react": "^17.0.15",
"@types/react-dom": "^16.9.10", "@types/react-dom": "^17.0.9",
"@types/react-router": "5.1.8", "@types/react-router": "5.1.16",
"@types/react-router-dom": "^5.1.6", "@types/react-router-dom": "^5.1.8",
"@typescript-eslint/eslint-plugin": "^4.28.5",
"@typescript-eslint/parser": "^4.28.5",
"apidoc": "^0.28.1",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.5", "enzyme-adapter-react-16": "^1.15.5",
"eslint": "^7.31.0",
"eslint-config-prettier": "^8.1.0",
"eslint-formatter-gitlab": "^2.2.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-jest": "^24.4.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"jest-junit": "^12.0.0", "jest-junit": "^12.0.0",
"react-scripts": "4.0.1" "prettier": "^2.3.2",
"prettier-config": "^1.0.0",
"react-scripts": "4.0.3"
},
"apidoc": {
"name": "OpenMediaCenter",
"description": "API Documentation of OpenMediaCenter",
"title": "OpenMediaCenter Doc",
"sampleUrl": null
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -2,14 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/logo_circle.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="A Application to run a Mediacenter in your local network" content="A Application to run a Mediacenter in your local network"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo_circle.png" />
<!-- <!--
manifest.json provides metadata used when your web app is installed on a manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/logo_circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -3,19 +3,9 @@
"name": "Create React App Sample", "name": "Create React App Sample",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "logo_circle.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
} }
], ],
"start_url": ".", "start_url": ".",

View File

@ -12,6 +12,7 @@
margin-left: 20px; margin-left: 20px;
opacity: 0.6; opacity: 0.6;
text-transform: capitalize; text-transform: capitalize;
text-decoration: none;
} }
.navitem:hover { .navitem:hover {

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import App from './App'; import App from './App';
import {shallow} from 'enzyme'; import {shallow} from 'enzyme';
import GlobalInfos from "./utils/GlobalInfos";
describe('<App/>', function () { describe('<App/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -10,34 +11,42 @@ describe('<App/>', function () {
it('renders title', () => { it('renders title', () => {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({password: false});
expect(wrapper.find('.navbrand').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/>);
wrapper.setState({password: false});
expect(wrapper.find('.navitem')).toHaveLength(4); expect(wrapper.find('.navitem')).toHaveLength(4);
GlobalInfos.setTVShowsEnabled(true);
wrapper.instance().forceUpdate();
expect(wrapper.find('.navitem')).toHaveLength(5);
}); });
it('test initial fetch from api', done => { it('test initial fetch from api', done => {
global.fetch = global.prepareFetchApi({ callAPIMock({
generalSettingsLoaded: true, MediacenterName: 'testname'
passwordsupport: true, })
mediacentername: 'testname'
}); GlobalInfos.enableDarkTheme = jest.fn((r) => {})
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
const func = jest.fn();
wrapper.instance().setState = func;
expect(global.fetch).toBeCalledTimes(1);
process.nextTick(() => { process.nextTick(() => {
expect(func).toBeCalledTimes(1); expect(document.title).toBe('testname');
global.fetch.mockClear(); global.fetch.mockClear();
done(); done();
}); });
}); });
it('test render of password page', function () {
const wrapper = shallow(<App/>);
wrapper.setState({password: true});
expect(wrapper.find('AuthenticationPage')).toHaveLength(1);
});
}); });

View File

@ -10,19 +10,21 @@ import style from './App.module.css';
import SettingsPage from './pages/SettingsPage/SettingsPage'; import SettingsPage from './pages/SettingsPage/SettingsPage';
import CategoryPage from './pages/CategoryPage/CategoryPage'; import CategoryPage from './pages/CategoryPage/CategoryPage';
import {APINode, callAPI} from './utils/Api'; 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 {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom';
import Player from './pages/Player/Player'; import Player from './pages/Player/Player';
import ActorOverviewPage from './pages/ActorOverviewPage/ActorOverviewPage'; import ActorOverviewPage from './pages/ActorOverviewPage/ActorOverviewPage';
import ActorPage from './pages/ActorPage/ActorPage'; import ActorPage from './pages/ActorPage/ActorPage';
import {SettingsTypes} from './types/ApiTypes'; import {SettingsTypes} from './types/ApiTypes';
import AuthenticationPage from './pages/AuthenticationPage/AuthenticationPage';
import TVShowPage from './pages/TVShowPage/TVShowPage';
import TVPlayer from './pages/TVShowPage/TVPlayer';
import {CookieTokenStore} from './utils/TokenStore/CookieTokenStore';
import {token} from './utils/TokenHandler';
interface state { interface state {
generalSettingsLoaded: boolean; password: boolean | null; // null if uninitialized - true if pwd needed false if not needed
passwordsupport: boolean;
mediacentername: string; mediacentername: string;
onapierror: boolean;
} }
/** /**
@ -31,11 +33,48 @@ interface state {
class App extends React.Component<{}, state> { class App extends React.Component<{}, state> {
constructor(props: {}) { constructor(props: {}) {
super(props); super(props);
token.init(new CookieTokenStore());
let pwdneeded: boolean | null = null;
if (token.apiTokenValid()) {
pwdneeded = false;
} else {
token.refreshAPIToken((err) => {
if (err === 'invalid_client') {
this.setState({password: true});
} else if (err === '') {
this.setState({password: false});
} else {
console.log('unimplemented token error: ' + err);
}
});
}
this.state = { this.state = {
generalSettingsLoaded: false,
passwordsupport: false,
mediacentername: 'OpenMediaCenter', mediacentername: 'OpenMediaCenter',
onapierror: false password: pwdneeded
};
// force an update on theme change
GlobalInfos.onThemeChange(() => {
this.forceUpdate();
});
// set the hook to load passwordfield on global func call
GlobalInfos.loadPasswordPage = (callback?: () => void): void => {
// try refreshing the token
token.refreshAPIToken((err) => {
if (err !== '') {
this.setState({password: true});
} else {
// call callback if request was successful
if (callback) {
callback();
}
}
}, true);
}; };
} }
@ -45,18 +84,15 @@ class App extends React.Component<{}, state> {
// set theme // set theme
GlobalInfos.enableDarkTheme(result.DarkMode); GlobalInfos.enableDarkTheme(result.DarkMode);
GlobalInfos.setVideoPath(result.VideoPath); GlobalInfos.setVideoPaths(result.VideoPath, result.TVShowPath);
GlobalInfos.setTVShowsEnabled(result.TVShowEnabled);
this.setState({ this.setState({
generalSettingsLoaded: true, mediacentername: result.MediacenterName
passwordsupport: result.Password,
mediacentername: result.Mediacenter_name,
onapierror: false
}); });
// set tab title to received mediacenter name // set tab title to received mediacenter name
document.title = result.Mediacenter_name; document.title = result.MediacenterName;
}, error => {
this.setState({onapierror: true});
}); });
} }
@ -64,63 +100,111 @@ class App extends React.Component<{}, state> {
this.initialAPICall(); this.initialAPICall();
} }
render(): JSX.Element { render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
// add the main theme to the page body // add the main theme to the page body
document.body.className = themeStyle.backgroundcolor; document.body.className = GlobalInfos.getThemeStyle().backgroundcolor;
if (this.state.password === true) {
// render authentication page if auth is neccessary
return (
<AuthenticationPage
onSuccessLogin={(): void => {
this.setState({password: false});
// reinit general infos
this.initialAPICall();
}}
/>
);
} else if (this.state.password === false) {
return (
<Router>
<div className={style.app}>
{this.navBar()}
{this.routing()}
</div>
</Router>
);
} else {
return <>still loading...</>;
}
}
/**
* render the top navigation bar
*/
navBar(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<Router> <div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}>
<div className={style.app}> <div className={style.navbrand}>{this.state.mediacentername}</div>
<div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}> <NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/'} activeStyle={{opacity: '0.85'}}>
<div className={style.navbrand}>{this.state.mediacentername}</div> Home
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/'} activeStyle={{opacity: '0.85'}}>Home</NavLink> </NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/random'} activeStyle={{opacity: '0.85'}}>Random <NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/random'} activeStyle={{opacity: '0.85'}}>
Video</NavLink> 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={'/categories'} activeStyle={{opacity: '0.85'}}>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/settings'} activeStyle={{opacity: '0.85'}}>Settings</NavLink> Categories
</div> </NavLink>
{this.routing()}
</div> {GlobalInfos.isTVShowEnabled() ? (
{this.state.onapierror ? this.ApiError() : null} <NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/tvshows'} activeStyle={{opacity: '0.85'}}>
</Router> TV Shows
</NavLink>
) : null}
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/settings'} activeStyle={{opacity: '0.85'}}>
Settings
</NavLink>
</div>
); );
} }
/**
* render the react router elements
*/
routing(): JSX.Element { routing(): JSX.Element {
return ( return (
<Switch> <Switch>
<Route path="/random"> <Route path='/random'>
<RandomPage/> <RandomPage />
</Route> </Route>
<Route path="/categories"> <Route path='/categories'>
<CategoryPage/> <CategoryPage />
</Route> </Route>
<Route path="/settings"> <Route path='/settings'>
<SettingsPage/> <SettingsPage />
</Route> </Route>
<Route exact path="/player/:id"> <Route exact path='/player/:id'>
<Player/> <Player />
</Route> </Route>
<Route exact path="/actors"> <Route exact path='/actors'>
<ActorOverviewPage/> <ActorOverviewPage />
</Route> </Route>
<Route path="/actors/:id"> <Route path='/actors/:id'>
<ActorPage/> <ActorPage />
</Route> </Route>
<Route path="/">
<HomePage/> {GlobalInfos.isTVShowEnabled() ? (
<Route path='/tvshows'>
<TVShowPage />
</Route>
) : null}
{GlobalInfos.isTVShowEnabled() ? (
<Route exact path='/tvplayer/:id'>
<TVPlayer />
</Route>
) : null}
<Route path='/'>
<HomePage />
</Route> </Route>
</Switch> </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; export default App;

View File

@ -12,12 +12,8 @@ describe('<ActorTile/>', function () {
const func = jest.fn((_) => {}); const func = jest.fn((_) => {});
const wrapper = shallow(<ActorTile actor={{Thumbnail: '-1', Name: 'testname', id: 3}} onClick={() => func()}/>); const wrapper = shallow(<ActorTile actor={{Thumbnail: '-1', Name: 'testname', id: 3}} onClick={() => func()}/>);
const func1 = jest.fn();
prepareViewBinding(func1);
wrapper.simulate('click'); wrapper.simulate('click');
expect(func1).toBeCalledTimes(0);
expect(func).toBeCalledTimes(1); expect(func).toBeCalledTimes(1);
}); });
}); });

View File

@ -5,13 +5,13 @@ import React from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {ActorType} from '../../types/VideoTypes'; import {ActorType} from '../../types/VideoTypes';
interface props { interface Props {
actor: ActorType; actor: ActorType;
onClick?: (actor: ActorType) => void onClick?: (actor: ActorType) => void;
} }
class ActorTile extends React.Component<props> { class ActorTile extends React.Component<Props> {
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = {}; this.state = {};
@ -21,12 +21,7 @@ class ActorTile extends React.Component<props> {
if (this.props.onClick) { if (this.props.onClick) {
return this.renderActorTile(this.props.onClick); return this.renderActorTile(this.props.onClick);
} else { } else {
return ( return <Link to={{pathname: '/actors/' + this.props.actor.ActorId}}>{this.renderActorTile(() => {})}</Link>;
<Link to={{pathname: '/actors/' + this.props.actor.ActorId}}>
{this.renderActorTile(() => {
})}
</Link>
);
} }
} }
@ -38,9 +33,19 @@ class ActorTile extends React.Component<props> {
return ( return (
<div className={style.actortile} onClick={(): void => customclickhandler(this.props.actor)}> <div className={style.actortile} onClick={(): void => customclickhandler(this.props.actor)}>
<div className={style.actortile_thumbnail}> <div className={style.actortile_thumbnail}>
{this.props.actor.Thumbnail === '' ? <FontAwesomeIcon style={{ {
lineHeight: '130px' this.props.actor.Thumbnail === '' ? (
}} icon={faUser} size='5x'/> : 'dfdf' /* todo render picture provided here! */} <FontAwesomeIcon
style={{
lineHeight: '130px'
}}
icon={faUser}
size='5x'
/>
) : (
'dfdf'
) /* todo render picture provided here! */
}
</div> </div>
<div className={style.actortile_name}>{this.props.actor.Name}</div> <div className={style.actortile_name}>{this.props.actor.Name}</div>
</div> </div>

View File

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

View File

@ -0,0 +1,49 @@
import {shallow} from 'enzyme';
import React from 'react';
import DynamicContentContainer from './DynamicContentContainer';
describe('<DynamicContentContainer/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<DynamicContentContainer data={[]} renderElement={(el) => (<></>)}/>);
wrapper.unmount();
});
it('inserts tiles correctly if enough available', () => {
const wrapper = shallow(<DynamicContentContainer data={[
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
]} renderElement={(el) => (<a/>)}/>);
expect(wrapper.find('a')).toHaveLength(16);
});
it('inserts tiles correctly if not enough available', () => {
const wrapper = shallow(<DynamicContentContainer data={[
{}, {}, {}, {}
]} renderElement={(el) => (<a/>)}/>);
expect(wrapper.find('a')).toHaveLength(4);
});
it('no items available', () => {
const wrapper = shallow(<DynamicContentContainer data={[]} renderElement={(el) => (<a/>)}/>);
expect(wrapper.find('a')).toHaveLength(0);
expect(wrapper.find('.maincontent').text()).toBe('no items to show!');
});
it('test clean', function () {
const wrapper = shallow(<DynamicContentContainer data={[{}, {}, {}]} renderElement={(el) => (<a/>)}/>);
expect(wrapper.find('a')).toHaveLength(3);
wrapper.instance().clean();
expect(wrapper.find('a')).toHaveLength(0);
});
it('test update', function () {
const wrapper = shallow(<DynamicContentContainer data={[{}, {}, {}]} renderElement={(el) => (<a/>)}/>);
const func = jest.fn();
wrapper.instance().clean = func;
// perform component update
wrapper.setProps({data: [{}, {}]});
expect(func).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,113 @@
import React from 'react';
import style from './DynamicContentContainer.module.css';
interface Props<T> {
renderElement: (elem: T) => JSX.Element;
data: T[];
initialLoadNr?: number;
}
interface state<T> {
loadeditems: T[];
}
/**
* A videocontainer storing lots of Preview elements
* includes scroll handling and loading of preview infos
*/
class DynamicContentContainer<T> extends React.Component<Props<T>, state<T>> {
// stores current index of loaded elements
loadindex: number = 0;
readonly InitialLoadNR = this.props.initialLoadNr
? this.props.initialLoadNr === -1
? this.props.data.length
: this.props.initialLoadNr
: 16;
constructor(props: Props<T>) {
super(props);
this.state = {
loadeditems: []
};
}
componentDidMount(): void {
document.addEventListener('scroll', this.trackScrolling);
this.loadPreviewBlock(this.InitialLoadNR);
}
componentDidUpdate(prevProps: Props<T>): void {
// when source props change force update!
if (
// diff the two arrays
this.props.data
.filter((x) => !prevProps.data.includes(x))
.concat(prevProps.data.filter((x) => !this.props.data.includes(x))).length !== 0
) {
this.clean((): void => {
this.loadPreviewBlock(this.InitialLoadNR);
});
}
}
/**
* clear all elements rendered...
*/
clean(callback: () => void): void {
this.loadindex = 0;
this.setState({loadeditems: []}, callback);
}
render(): JSX.Element {
return (
<div className={style.maincontent}>
{this.state.loadeditems.map((elem) => {
return this.props.renderElement(elem);
})}
{/*todo css for no items to show*/}
{this.state.loadeditems.length === 0 ? 'no items to show!' : null}
{this.props.children}
</div>
);
}
componentWillUnmount(): void {
// 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 {
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 DynamicContentContainer;

View File

@ -0,0 +1,64 @@
import {shallow} from 'enzyme';
import React from 'react';
import FilterButton from './FilterButton';
import RandomPage from "../../pages/RandomPage/RandomPage";
import {callAPI} from "../../utils/Api";
describe('<FilterButton/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<FilterButton onFilterChange={() => {}}/>);
wrapper.unmount();
});
it('test initial render ', function () {
const wrapper = shallow(<FilterButton onFilterChange={() => {}}/>);
expect(wrapper.find('input')).toHaveLength(0);
});
it('test clicking', function () {
const wrapper = shallow(<FilterButton onFilterChange={() => {}}/>);
wrapper.simulate('click');
expect(wrapper.find('input')).toHaveLength(1);
});
it('test call of callback on textfield change', function () {
let val = '';
const func = jest.fn((vali => {val = vali}));
const wrapper = shallow(<FilterButton onFilterChange={func}/>);
wrapper.simulate('click');
wrapper.find('input').simulate('change', {target: {value: 'test'}});
expect(func).toHaveBeenCalledTimes(1);
expect(val).toBe('test')
});
it('test closing on x button click', function () {
const wrapper = shallow(<FilterButton onFilterChange={() => {}}/>);
wrapper.simulate('click');
expect(wrapper.find('input')).toHaveLength(1);
wrapper.find('Button').simulate('click');
expect(wrapper.find('input')).toHaveLength(0);
});
it('test shortkey press', function () {
let events = [];
document.addEventListener = jest.fn((event, cb) => {
events[event] = cb;
});
shallow(<RandomPage/>);
const wrapper = shallow(<FilterButton onFilterChange={() => {}}/>);
expect(wrapper.find('input')).toHaveLength(0);
// trigger the keypress event
events.keyup({key: 'f'});
expect(wrapper.find('input')).toHaveLength(1);
});
});

View File

@ -0,0 +1,121 @@
import React from 'react';
import style from '../Popups/AddActorPopup/AddActorPopup.module.css';
import {Button} from '../GPElements/Button';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faFilter, faTimes} from '@fortawesome/free-solid-svg-icons';
import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler';
interface Props {
onFilterChange: (filter: string) => void;
}
interface state {
filtervisible: boolean;
filter: string;
}
class FilterButton 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 = {
filtervisible: false,
filter: ''
};
this.keypress = this.keypress.bind(this);
this.enableFilterField = this.enableFilterField.bind(this);
}
componentWillUnmount(): void {
removeKeyHandler(this.keypress);
}
componentDidMount(): void {
addKeyHandler(this.keypress);
}
render(): JSX.Element {
if (this.state.filtervisible) {
return (
<>
<input
className={'form-control mr-sm-2 ' + style.searchinput}
type='text'
placeholder='Filter'
value={this.state.filter}
onChange={(e): void => {
this.props.onFilterChange(e.target.value);
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});
}}
/>
</>
);
} else {
return (
<Button
title={
<span>
Filter{' '}
<FontAwesomeIcon
style={{
verticalAlign: 'middle',
lineHeight: '130px'
}}
icon={faFilter}
size='1x'
/>
</span>
}
color={{backgroundColor: 'cornflowerblue', color: 'white'}}
onClick={this.enableFilterField}
/>
);
}
}
/**
* enable filterfield and focus into searchbar
*/
private enableFilterField(): void {
this.setState({filtervisible: true}, () => {
// focus filterfield after state update
this.filterfield?.focus();
});
}
/**
* key event handling
* @param event keyevent
*/
private keypress(event: KeyboardEvent): void {
// hide if escape is pressed
if (event.key === 'f') {
this.enableFilterField();
}
}
}
export default FilterButton;

View File

@ -5,11 +5,11 @@ import {Spinner} from 'react-bootstrap';
import {IconDefinition} from '@fortawesome/fontawesome-common-types'; import {IconDefinition} from '@fortawesome/fontawesome-common-types';
interface props { interface props {
onClick?: () => void onClick?: () => void;
backColor: string backColor: string;
icon: IconDefinition icon: IconDefinition;
text: string | number text: string | number;
subtext: string | number subtext: string | number;
} }
/** /**
@ -18,23 +18,35 @@ interface props {
class InfoHeaderItem extends React.Component<props> { class InfoHeaderItem extends React.Component<props> {
render(): JSX.Element { render(): JSX.Element {
return ( return (
<div onClick={(): void => { <div
// call clicklistener if defined onClick={(): void => {
if (this.props.onClick != null) this.props.onClick(); // call clicklistener if defined
}} className={style.infoheaderitem} style={{backgroundColor: this.props.backColor}}> if (this.props.onClick != null) {
this.props.onClick();
}
}}
className={style.infoheaderitem}
style={{backgroundColor: this.props.backColor}}>
<div className={style.icon}> <div className={style.icon}>
<FontAwesomeIcon style={{ <FontAwesomeIcon
verticalAlign: 'middle', style={{
lineHeight: '130px' verticalAlign: 'middle',
}} icon={this.props.icon} size='5x'/> lineHeight: '130px'
}}
icon={this.props.icon}
size='5x'
/>
</div> </div>
{this.props.text !== null && this.props.text !== undefined ? {this.props.text !== null && this.props.text !== undefined ? (
<> <>
<div className={style.maintext}>{this.props.text}</div> <div className={style.maintext}>{this.props.text}</div>
<div className={style.subtext}>{this.props.subtext}</div> <div className={style.subtext}>{this.props.subtext}</div>
</> </>
: <span className={style.loadAnimation}><Spinner animation='border'/></span> ) : (
} <span className={style.loadAnimation}>
<Spinner animation='border' />
</span>
)}
</div> </div>
); );
} }

View File

@ -17,10 +17,8 @@ class PageTitle extends React.Component<props> {
<div className={style.pageheader + ' ' + themeStyle.backgroundcolor}> <div className={style.pageheader + ' ' + themeStyle.backgroundcolor}>
<span className={style.pageheadertitle + ' ' + themeStyle.textcolor}>{this.props.title}</span> <span className={style.pageheadertitle + ' ' + themeStyle.textcolor}>{this.props.title}</span>
<span className={style.pageheadersubtitle + ' ' + themeStyle.textcolor}>{this.props.subtitle}</span> <span className={style.pageheadersubtitle + ' ' + themeStyle.textcolor}>{this.props.subtitle}</span>
<> <>{this.props.children}</>
{this.props.children} <Line />
</>
<Line/>
</div> </div>
); );
} }
@ -35,7 +33,7 @@ export class Line extends React.Component {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<> <>
<hr className={themeStyle.hrcolor}/> <hr className={themeStyle.hrcolor} />
</> </>
); );
} }

View File

@ -40,7 +40,7 @@ describe('<AddActorPopup/>', function () {
it('simulate actortile click', function () { it('simulate actortile click', function () {
const func = jest.fn(); const func = jest.fn();
const wrapper = shallow(<AddActorPopup onHide={() => {func();}} movie_id={1}/>); const wrapper = shallow(<AddActorPopup onHide={() => {func();}} movieId={1}/>);
global.callAPIMock({result: 'success'}); global.callAPIMock({result: 'success'});

View File

@ -6,53 +6,40 @@ import {NewActorPopupContent} from '../NewActorPopup/NewActorPopup';
import {APINode, callAPI} from '../../../utils/Api'; import {APINode, callAPI} from '../../../utils/Api';
import {ActorType} from '../../../types/VideoTypes'; import {ActorType} from '../../../types/VideoTypes';
import {GeneralSuccess} from '../../../types/GeneralTypes'; import {GeneralSuccess} from '../../../types/GeneralTypes';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import FilterButton from '../../FilterButton/FilterButton';
import {faFilter, faTimes} from '@fortawesome/free-solid-svg-icons';
import {Button} from '../../GPElements/Button';
import {addKeyHandler, removeKeyHandler} from '../../../utils/ShortkeyHandler';
interface props { interface Props {
onHide: () => void; onHide: () => void;
movie_id: number; movieId: number;
} }
interface state { interface state {
contentDefault: boolean; contentDefault: boolean;
actors: ActorType[]; actors: ActorType[];
filter: string; filter: string;
filtervisible: boolean;
} }
/** /**
* Popup for Adding a new Actor to a Video * Popup for Adding a new Actor to a Video
*/ */
class AddActorPopup extends React.Component<props, state> { class AddActorPopup extends React.Component<Props, state> {
// filterfield anchor, needed to focus after filter btn click // filterfield anchor, needed to focus after filter btn click
private filterfield: HTMLInputElement | null | undefined; private filterfield: HTMLInputElement | null | undefined;
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
contentDefault: true, contentDefault: true,
actors: [], actors: [],
filter: '', filter: ''
filtervisible: false
}; };
this.tileClickHandler = this.tileClickHandler.bind(this); this.tileClickHandler = this.tileClickHandler.bind(this);
this.filterSearch = this.filterSearch.bind(this); this.filterSearch = this.filterSearch.bind(this);
this.parentSubmit = this.parentSubmit.bind(this); this.parentSubmit = this.parentSubmit.bind(this);
this.keypress = this.keypress.bind(this);
} }
componentWillUnmount(): void {
removeKeyHandler(this.keypress);
}
componentDidMount(): void { componentDidMount(): void {
addKeyHandler(this.keypress);
// fetch the available actors // fetch the available actors
this.loadActors(); this.loadActors();
} }
@ -61,12 +48,19 @@ class AddActorPopup extends React.Component<props, state> {
return ( return (
<> <>
{/* todo render actor tiles here and add search field*/} {/* todo render actor tiles here and add search field*/}
<PopupBase title='Add new Actor to Video' onHide={this.props.onHide} banner={ <PopupBase
<button title='Add new Actor to Video'
className={style.newactorbutton} onHide={this.props.onHide}
onClick={(): void => { banner={
this.setState({contentDefault: false}); <button
}}>Create new Actor</button>} ParentSubmit={this.parentSubmit}> className={style.newactorbutton}
onClick={(): void => {
this.setState({contentDefault: false});
}}>
Create new Actor
</button>
}
ParentSubmit={this.parentSubmit}>
{this.resolvePage()} {this.resolvePage()}
</PopupBase> </PopupBase>
</> </>
@ -78,11 +72,18 @@ class AddActorPopup extends React.Component<props, state> {
* @returns {JSX.Element} * @returns {JSX.Element}
*/ */
resolvePage(): JSX.Element { resolvePage(): JSX.Element {
if (this.state.contentDefault) return (this.getContent()); if (this.state.contentDefault) {
else return (<NewActorPopupContent onHide={(): void => { return this.getContent();
this.loadActors(); } else {
this.setState({contentDefault: true}); return (
}}/>); <NewActorPopupContent
onHide={(): void => {
this.loadActors();
this.setState({contentDefault: true});
}}
/>
);
}
} }
/** /**
@ -94,36 +95,19 @@ class AddActorPopup extends React.Component<props, state> {
return ( return (
<> <>
<div className={style.searchbar}> <div className={style.searchbar}>
{ <FilterButton
this.state.filtervisible ? onFilterChange={(filter): void => {
<> this.setState({filter: filter});
<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> </div>
{this.state.actors.filter(this.filterSearch).map((el) => (<ActorTile actor={el} onClick={this.tileClickHandler}/>))} {this.state.actors.filter(this.filterSearch).map((el) => (
<ActorTile actor={el} onClick={this.tileClickHandler} />
))}
</> </>
); );
} else { } else {
return (<div>somekind of loading</div>); return <div>somekind of loading</div>;
} }
} }
@ -132,39 +116,33 @@ class AddActorPopup extends React.Component<props, state> {
*/ */
tileClickHandler(actor: ActorType): void { tileClickHandler(actor: ActorType): void {
// fetch the available actors // fetch the available actors
callAPI<GeneralSuccess>(APINode.Actor, { callAPI<GeneralSuccess>(
action: 'addActorToVideo', APINode.Actor,
ActorId: actor.ActorId, {
MovieId: this.props.movie_id action: 'addActorToVideo',
}, result => { ActorId: actor.ActorId,
if (result.result === 'success') { MovieId: this.props.movieId
// return back to player page },
this.props.onHide(); (result) => {
} else { if (result.result === 'success') {
console.error('an error occured while fetching actors: ' + result); // 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 * load the actors from backend and set state
*/ */
loadActors(): void { loadActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, result => { callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, (result) => {
this.setState({actors: 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 * filter the actor array for search matches
* @param actor * @param actor
@ -185,17 +163,6 @@ class AddActorPopup extends React.Component<props, state> {
this.tileClickHandler(filteredList[0]); 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; export default AddActorPopup;

View File

@ -1,26 +1,3 @@
.popup { .actionbar{
border: 3px #3574fe solid; margin-bottom: 15px;
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

@ -25,11 +25,37 @@ describe('<AddTagPopup/>', function () {
const wrapper = shallow(<AddTagPopup submit={jest.fn()} onHide={jest.fn()}/>); const wrapper = shallow(<AddTagPopup submit={jest.fn()} onHide={jest.fn()}/>);
wrapper.setState({ wrapper.setState({
items: [{tag_id: 1, tag_name: 'test'}] items: [{TagId: 1, TagName: 'test'}]
}, () => { }, () => {
wrapper.find('Tag').first().dive().simulate('click'); wrapper.find('Tag').first().dive().simulate('click');
expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(1); expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1); expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
}); });
}); });
it('test parent submit if one item left', function () {
const onhide = jest.fn();
const submit = jest.fn();
const wrapper = shallow(<AddTagPopup submit={submit} onHide={onhide}/>);
wrapper.setState({
items: [{TagId: 1, TagName: 'test'}]
}, () => {
wrapper.instance().parentSubmit();
expect(onhide).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledTimes(1);
wrapper.setState({
items: [{TagId: 1, TagName: 'test'}, {TagId: 3, TagName: 'test3'}]
}, () => {
wrapper.instance().parentSubmit();
// expect no submit if there are more than 1 item left...
expect(onhide).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledTimes(1);
})
});
});
}); });

View File

@ -3,25 +3,31 @@ import Tag from '../../Tag/Tag';
import PopupBase from '../PopupBase'; import PopupBase from '../PopupBase';
import {APINode, callAPI} from '../../../utils/Api'; import {APINode, callAPI} from '../../../utils/Api';
import {TagType} from '../../../types/VideoTypes'; import {TagType} from '../../../types/VideoTypes';
import FilterButton from '../../FilterButton/FilterButton';
import styles from './AddTagPopup.module.css';
interface props { interface Props {
onHide: () => void; onHide: () => void;
submit: (tagId: number, tagName: string) => void; submit: (tagId: number, tagName: string) => void;
movie_id: number;
} }
interface state { interface state {
items: TagType[]; items: TagType[];
filter: string;
} }
/** /**
* component creates overlay to add a new tag to a video * component creates overlay to add a new tag to a video
*/ */
class AddTagPopup extends React.Component<props, state> { class AddTagPopup extends React.Component<Props, state> {
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = {items: []}; this.state = {items: [], filter: ''};
this.tagFilter = this.tagFilter.bind(this);
this.parentSubmit = this.parentSubmit.bind(this);
this.onItemClick = this.onItemClick.bind(this);
} }
componentDidMount(): void { componentDidMount(): void {
@ -34,18 +40,35 @@ class AddTagPopup extends React.Component<props, state> {
render(): JSX.Element { render(): JSX.Element {
return ( return (
<PopupBase title='Add a Tag to this Video:' onHide={this.props.onHide}> <PopupBase title='Add a Tag to this Video:' onHide={this.props.onHide} ParentSubmit={this.parentSubmit}>
{this.state.items ? <div className={styles.actionbar}>
this.state.items.map((i) => ( <FilterButton onFilterChange={(filter): void => this.setState({filter: filter})} />
<Tag tagInfo={i} </div>
onclick={(): void => { {this.state.items
this.props.submit(i.TagId, i.TagName); ? this.state.items.filter(this.tagFilter).map((i) => <Tag tagInfo={i} onclick={(): void => this.onItemClick(i)} />)
this.props.onHide(); : null}
}}/>
)) : null}
</PopupBase> </PopupBase>
); );
} }
private onItemClick(tag: TagType): void {
this.props.submit(tag.TagId, tag.TagName);
this.props.onHide();
}
private tagFilter(tag: TagType): boolean {
return tag.TagName.toLowerCase().includes(this.state.filter.toLowerCase());
}
private parentSubmit(): void {
// allow submit only if one item is left in selection
const filteredList = this.state.items.filter(this.tagFilter);
if (filteredList.length === 1) {
// simulate click if parent submit
this.onItemClick(filteredList[0]);
}
}
} }
export default AddTagPopup; export default AddTagPopup;

View File

@ -15,7 +15,7 @@ class NewActorPopup extends React.Component<NewActorPopupProps> {
render(): JSX.Element { render(): JSX.Element {
return ( return (
<PopupBase title='Add new Tag' onHide={this.props.onHide} height='200px' width='400px'> <PopupBase title='Add new Tag' onHide={this.props.onHide} height='200px' width='400px'>
<NewActorPopupContent onHide={this.props.onHide}/> <NewActorPopupContent onHide={this.props.onHide} />
</PopupBase> </PopupBase>
); );
} }
@ -28,10 +28,17 @@ export class NewActorPopupContent extends React.Component<NewActorPopupProps> {
return ( return (
<> <>
<div> <div>
<input type='text' placeholder='Actor Name' onChange={(v): void => { <input
this.value = v.target.value; type='text'
}}/></div> placeholder='Actor Name'
<button className={style.savebtn} onClick={(): void => this.storeselection()}>Save</button> onChange={(v): void => {
this.value = v.target.value;
}}
/>
</div>
<button className={style.savebtn} onClick={(): void => this.storeselection()}>
Save
</button>
</> </>
); );
} }
@ -41,7 +48,9 @@ export class NewActorPopupContent extends React.Component<NewActorPopupProps> {
*/ */
storeselection(): void { storeselection(): void {
// check if user typed in name // check if user typed in name
if (this.value === '' || this.value === undefined) return; if (this.value === '' || this.value === undefined) {
return;
}
callAPI(APINode.Actor, {action: 'createActor', actorname: this.value}, (result: GeneralSuccess) => { callAPI(APINode.Actor, {action: 'createActor', actorname: this.value}, (result: GeneralSuccess) => {
if (result.result !== 'success') { if (result.result !== 'success') {

View File

@ -5,7 +5,7 @@ import {APINode, callAPI} from '../../../utils/Api';
import {GeneralSuccess} from '../../../types/GeneralTypes'; import {GeneralSuccess} from '../../../types/GeneralTypes';
interface props { interface props {
onHide: () => void onHide: () => void;
} }
/** /**
@ -16,11 +16,24 @@ class NewTagPopup extends React.Component<props> {
render(): JSX.Element { render(): JSX.Element {
return ( return (
<PopupBase title='Add new Tag' onHide={this.props.onHide} height='200px' width='400px' ParentSubmit={(): void => this.storeselection()}> <PopupBase
<div><input type='text' placeholder='Tagname' onChange={(v): void => { title='Add new Tag'
this.value = v.target.value; onHide={this.props.onHide}
}}/></div> height='200px'
<button className={style.savebtn} onClick={(): void => this.storeselection()}>Save</button> 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> </PopupBase>
); );
} }

View File

@ -1,29 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -8,18 +8,11 @@ describe('<PopupBase/>', function () {
wrapper.unmount(); wrapper.unmount();
}); });
let events;
function mockKeyPress() {
events = [];
document.addEventListener = jest.fn((event, cb) => {
events[event] = cb;
});
}
it('simulate keypress', function () { it('simulate keypress', function () {
mockKeyPress(); mockKeyPress();
const func = jest.fn(); const func = jest.fn();
const events = mockKeyPress();
shallow(<PopupBase onHide={() => func()}/>); shallow(<PopupBase onHide={() => func()}/>);
// trigger the keypress event // trigger the keypress event
@ -29,7 +22,7 @@ describe('<PopupBase/>', function () {
}); });
it('test an Enter sumit', function () { it('test an Enter sumit', function () {
mockKeyPress(); const events = mockKeyPress();
const func = jest.fn(); const func = jest.fn();
shallow(<PopupBase ParentSubmit={() => func()}/>); shallow(<PopupBase ParentSubmit={() => func()}/>);

View File

@ -4,7 +4,7 @@ import {Line} from '../PageTitle/PageTitle';
import React, {RefObject} from 'react'; import React, {RefObject} from 'react';
import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler'; import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler';
interface props { interface Props {
width?: string; width?: string;
height?: string; height?: string;
banner?: JSX.Element; banner?: JSX.Element;
@ -16,11 +16,11 @@ interface props {
/** /**
* wrapper class for generic types of popups * wrapper class for generic types of popups
*/ */
class PopupBase extends React.Component<props> { class PopupBase extends React.Component<Props> {
private wrapperRef: RefObject<HTMLDivElement>; private wrapperRef: RefObject<HTMLDivElement>;
private framedimensions: { minHeight: string | undefined; width: string | undefined; height: string | undefined }; private framedimensions: {minHeight: string | undefined; width: string | undefined; height: string | undefined};
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = {items: []}; this.state = {items: []};
@ -32,9 +32,9 @@ class PopupBase extends React.Component<props> {
// parse style props // parse style props
this.framedimensions = { this.framedimensions = {
width: (this.props.width ? this.props.width : undefined), width: this.props.width ? this.props.width : undefined,
height: (this.props.height ? this.props.height : undefined), height: this.props.height ? this.props.height : undefined,
minHeight: (this.props.height ? this.props.height : undefined) minHeight: this.props.height ? this.props.height : undefined
}; };
} }
@ -63,10 +63,8 @@ class PopupBase extends React.Component<props> {
<div className={style.banner}>{this.props.banner}</div> <div className={style.banner}>{this.props.banner}</div>
</div> </div>
<Line/> <Line />
<div className={style.content}> <div className={style.content}>{this.props.children}</div>
{this.props.children}
</div>
</div> </div>
); );
} }
@ -90,7 +88,9 @@ class PopupBase extends React.Component<props> {
this.props.onHide(); this.props.onHide();
} else if (event.key === 'Enter') { } else if (event.key === 'Enter') {
// call a parentsubmit if defined // call a parentsubmit if defined
if (this.props.ParentSubmit) this.props.ParentSubmit(); if (this.props.ParentSubmit) {
this.props.ParentSubmit();
}
} }
} }
@ -98,15 +98,19 @@ class PopupBase extends React.Component<props> {
* make the element drag and droppable * make the element drag and droppable
*/ */
dragElement(): void { dragElement(): void {
let xOld = 0, yOld = 0; let xOld = 0,
yOld = 0;
const elmnt = this.wrapperRef.current; const elmnt = this.wrapperRef.current;
if (elmnt === null) return; if (elmnt === null) {
if (elmnt.firstChild === null) return; return;
}
if (elmnt.firstChild === null) {
return;
}
(elmnt.firstChild as HTMLDivElement).onmousedown = dragMouseDown; (elmnt.firstChild as HTMLDivElement).onmousedown = dragMouseDown;
function dragMouseDown(e: MouseEvent): void { function dragMouseDown(e: MouseEvent): void {
e.preventDefault(); e.preventDefault();
// get the mouse cursor position at startup: // get the mouse cursor position at startup:
@ -125,9 +129,11 @@ class PopupBase extends React.Component<props> {
xOld = e.clientX; xOld = e.clientX;
yOld = e.clientY; yOld = e.clientY;
// set the element's new position: // set the element's new position:
if (elmnt === null) return; if (elmnt === null) {
elmnt.style.top = (elmnt.offsetTop - dy) + 'px'; return;
elmnt.style.left = (elmnt.offsetLeft - dx) + 'px'; }
elmnt.style.top = elmnt.offsetTop - dy + 'px';
elmnt.style.left = elmnt.offsetLeft - dx + 'px';
} }
function closeDragElement(): void { function closeDragElement(): void {

View File

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

View File

@ -3,6 +3,8 @@ import style from './Preview.module.css';
import {Spinner} from 'react-bootstrap'; import {Spinner} from 'react-bootstrap';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import GlobalInfos from '../../utils/GlobalInfos'; import GlobalInfos from '../../utils/GlobalInfos';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {faEllipsisV} from '@fortawesome/free-solid-svg-icons'; import {faEllipsisV} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import QuickActionPop from '../QuickActionPop/QuickActionPop'; import QuickActionPop from '../QuickActionPop/QuickActionPop';
@ -10,11 +12,13 @@ import {APINode, callAPIPlain} from '../../utils/Api';
interface PreviewProps { interface PreviewProps {
name: string; name: string;
movie_id: number; picLoader: (callback: (pic: string) => void) => void;
linkPath?: string;
onClick?: () => void;
} }
interface PreviewState { interface PreviewState {
previewpicture: string | null; picLoaded: boolean | null;
optionsvisible: boolean; optionsvisible: boolean;
} }
@ -23,27 +27,41 @@ interface PreviewState {
* floating side by side * floating side by side
*/ */
class Preview extends React.Component<PreviewProps, PreviewState> { class Preview extends React.Component<PreviewProps, PreviewState> {
// store the picture to display
pic?: string;
constructor(props: PreviewProps) { constructor(props: PreviewProps) {
super(props); super(props);
this.state = { this.state = {
previewpicture: null, picLoaded: null,
optionsvisible: false optionsvisible: false
}; };
} }
componentDidMount(): void { componentDidMount(): void {
callAPIPlain(APINode.Video, {action: 'readThumbnail', movieid: this.props.movie_id}, (result) => { this.props.picLoader((result) => {
this.pic = result;
this.setState({ this.setState({
previewpicture: result picLoaded: result !== ''
}); });
}); });
} }
render(): JSX.Element { render(): JSX.Element {
if (this.props.linkPath !== undefined) {
return <Link to={this.props.linkPath}>{this.content()}</Link>;
} else {
return this.content();
}
}
content(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<div className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}> <div
className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
onClick={this.props.onClick}>
<div className={style.quickactions} onClick={(): void => this.setState({optionsvisible: true})}> <div className={style.quickactions} onClick={(): void => this.setState({optionsvisible: true})}>
<FontAwesomeIcon style={{ <FontAwesomeIcon style={{
verticalAlign: 'middle', verticalAlign: 'middle',
@ -52,24 +70,29 @@ class Preview extends React.Component<PreviewProps, PreviewState> {
</div> </div>
{this.popupvisible()} {this.popupvisible()}
<div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div> <div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div>
<Link to={'/player/' + this.props.movie_id}> <div className={style.previewpic}>
<div className={style.previewpic}> {this.state.picLoaded === false ? (
{this.state.previewpicture !== null ? <FontAwesomeIcon
<img className={style.previewimage} style={{
src={this.state.previewpicture} color: 'white',
alt='Pic loading.'/> : marginTop: '55px'
<span className={style.loadAnimation}><Spinner animation='border'/></span>} }}
icon={faPhotoVideo}
</div> size='5x'
</Link> />
<div className={style.previewbottom}> ) : this.state.picLoaded === null ? (
<span className={style.loadAnimation}>
<Spinner animation='border' />
</span>
) : (
<img className={style.previewimage} src={this.pic} alt='Pic loading.' />
)}
</div> </div>
<div className={style.previewbottom} />
</div> </div>
); );
} }
popupvisible(): JSX.Element { popupvisible(): JSX.Element {
if (this.state.optionsvisible) if (this.state.optionsvisible)
return (<QuickActionPop position={{x: 50, y: 50}} onHide={(): void => this.setState({optionsvisible: false})}>heeyyho</QuickActionPop>); return (<QuickActionPop position={{x: 50, y: 50}} onHide={(): void => this.setState({optionsvisible: false})}>heeyyho</QuickActionPop>);
@ -81,15 +104,12 @@ class Preview extends React.Component<PreviewProps, PreviewState> {
/** /**
* Component for a Tag-name tile (used in category page) * Component for a Tag-name tile (used in category page)
*/ */
export class TagPreview extends React.Component<{ name: string }> { export class TagPreview extends React.Component<{name: string}> {
render(): JSX.Element { render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<div <div className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}>
className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}> <div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div>
<div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>
{this.props.name}
</div>
</div> </div>
); );
} }

View File

@ -5,37 +5,29 @@ 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 movie_id={1}/>); const wrapper = shallow(<Preview movieId={1} name='test' picLoader={callback => callback('')}/>);
wrapper.unmount(); wrapper.unmount();
}); });
it('picture rendered correctly', done => { it('picture rendered correctly', () => {
const mockSuccessResponse = 'testsrc'; const func = jest.fn();
const mockJsonPromise = Promise.resolve(mockSuccessResponse); const wrapper = shallow(<Preview movieId={1} name='test' picLoader={callback => {
const mockFetchPromise = Promise.resolve({ func();
text: () => mockJsonPromise callback('42');
}); }}/>);
global.fetch = jest.fn().mockImplementation(() => mockFetchPromise);
const wrapper = shallow(<Preview name='test' movie_id={1}/>); // expect picloader tobe called once
expect(func).toHaveBeenCalledTimes(1)
// now called 1 times
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
// received picture should be rendered into wrapper
expect(wrapper.find('.previewimage').props().src).not.toBeNull();
// check if preview title renders correctly
expect(wrapper.find('.previewtitle').text()).toBe('test');
global.fetch.mockClear();
done();
});
// received picture should be rendered into wrapper
expect(wrapper.find('.previewimage').props().src).toBe('42');
// check if preview title renders correctly
expect(wrapper.find('.previewtitle').text()).toBe('test');
}); });
it('spinner loads correctly', function () { it('spinner loads correctly', function () {
const wrapper = shallow(<Preview movie_id={1}/>); // if callback is never called --> infinite spinner
const wrapper = shallow(<Preview movieId={1} name='test' picLoader={callback => {}}/>);
// expect load animation to be visible // expect load animation to be visible
expect(wrapper.find('.loadAnimation')).toHaveLength(1); expect(wrapper.find('.loadAnimation')).toHaveLength(1);

View File

@ -13,11 +13,16 @@ interface SideBarProps {
class SideBar extends React.Component<SideBarProps> { class SideBar extends React.Component<SideBarProps> {
render(): JSX.Element { render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
const classnn = style.sideinfogeometry + ' ' + (this.props.hiddenFrame === undefined ? style.sideinfo + ' ' + themeStyle.secbackground : ''); const classnn =
style.sideinfogeometry +
' ' +
(this.props.hiddenFrame === undefined ? style.sideinfo + ' ' + themeStyle.secbackground : '');
return (<div className={classnn} style={{width: this.props.width}}> return (
{this.props.children} <div className={classnn} style={{width: this.props.width}}>
</div>); {this.props.children}
</div>
);
} }
} }
@ -27,9 +32,7 @@ class SideBar extends React.Component<SideBarProps> {
export class SideBarTitle extends React.Component { export class SideBarTitle extends React.Component {
render(): JSX.Element { render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
return ( return <div className={style.sidebartitle + ' ' + themeStyle.subtextcolor}>{this.props.children}</div>;
<div className={style.sidebartitle + ' ' + themeStyle.subtextcolor}>{this.props.children}</div>
);
} }
} }
@ -40,8 +43,9 @@ export class SideBarItem extends React.Component {
render(): JSX.Element { render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<div <div className={style.sidebarinfo + ' ' + themeStyle.thirdbackground + ' ' + themeStyle.lighttextcolor}>
className={style.sidebarinfo + ' ' + themeStyle.thirdbackground + ' ' + themeStyle.lighttextcolor}>{this.props.children}</div> {this.props.children}
</div>
); );
} }
} }

View File

@ -1,4 +1,4 @@
import React, {SyntheticEvent} from 'react'; import React from 'react';
import styles from './Tag.module.css'; import styles from './Tag.module.css';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
@ -7,11 +7,11 @@ import {TagType} from '../../types/VideoTypes';
interface props { interface props {
onclick?: (_: string) => void; onclick?: (_: string) => void;
tagInfo: TagType; tagInfo: TagType;
onContextMenu?: (pos: {x: number, y: number}) => void onContextMenu?: (pos: {x: number, y: number}) => void;
} }
interface state { interface state {
contextVisible: boolean contextVisible: boolean;
} }
/** /**
@ -32,20 +32,15 @@ class Tag extends React.Component<props, state> {
if (this.props.onclick) { if (this.props.onclick) {
return this.renderButton(); return this.renderButton();
} else { } else {
return ( return <Link to={'/categories/' + this.props.tagInfo.TagId}>{this.renderButton()}</Link>;
<Link to={'/categories/' + this.props.tagInfo.TagId}>
{this.renderButton()}
</Link>
);
} }
} }
renderButton(): JSX.Element { renderButton(): JSX.Element {
return ( return (
<button className={styles.tagbtn} <button className={styles.tagbtn} onClick={(): void => this.TagClick()} data-testid='Test-Tag' onContextMenu={this.contextmenu}>
onClick={(): void => this.TagClick()} {this.props.tagInfo.TagName}
onContextMenu={this.contextmenu} </button>
data-testid='Test-Tag'>{this.props.tagInfo.TagName}</button>
); );
} }

View File

@ -7,24 +7,4 @@ describe('<VideoContainer/>', function () {
const wrapper = shallow(<VideoContainer data={[]}/>); const wrapper = shallow(<VideoContainer data={[]}/>);
wrapper.unmount(); wrapper.unmount();
}); });
it('inserts tiles correctly if enough available', () => {
const wrapper = shallow(<VideoContainer data={[
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
]}/>);
expect(wrapper.find('Preview')).toHaveLength(16);
});
it('inserts tiles correctly if not enough available', () => {
const wrapper = shallow(<VideoContainer data={[
{}, {}, {}, {}
]}/>);
expect(wrapper.find('Preview')).toHaveLength(4);
});
it('no items available', () => {
const wrapper = shallow(<VideoContainer data={[]}/>);
expect(wrapper.find('Preview')).toHaveLength(0);
expect(wrapper.find('.maincontent').text()).toBe('no items to show!');
});
}); });

View File

@ -1,97 +1,38 @@
import React from 'react'; import React from 'react';
import Preview from '../Preview/Preview'; import Preview from '../Preview/Preview';
import style from './VideoContainer.module.css';
import {VideoTypes} from '../../types/ApiTypes'; import {VideoTypes} from '../../types/ApiTypes';
import DynamicContentContainer from '../DynamicContentContainer/DynamicContentContainer';
import {APINode, callAPIPlain} from '../../utils/Api';
interface props { interface Props {
data: VideoTypes.VideoUnloadedType[] data: VideoTypes.VideoUnloadedType[];
children?: JSX.Element;
} }
interface state { const VideoContainer = (props: Props): JSX.Element => {
loadeditems: VideoTypes.VideoUnloadedType[]; return (
selectionnr: number; <DynamicContentContainer
} renderElement={(el): JSX.Element => (
<Preview
/** key={el.MovieId}
* A videocontainer storing lots of Preview elements picLoader={(callback: (pic: string) => void): void => {
* includes scroll handling and loading of preview infos callAPIPlain(
*/ APINode.Video,
class VideoContainer extends React.Component<props, state> { {
// stores current index of loaded elements action: 'readThumbnail',
loadindex: number = 0; Movieid: el.MovieId
},
constructor(props: props) { (result) => callback(result)
super(props); );
}}
this.state = { name={el.MovieName}
loadeditems: [], linkPath={'/player/' + el.MovieId}
selectionnr: 0 />
}; )}
} data={props.data}>
{props.children}
componentDidMount(): void { </DynamicContentContainer>
document.addEventListener('scroll', this.trackScrolling); );
};
this.loadPreviewBlock(16);
}
render(): JSX.Element {
return (
<div className={style.maincontent}>
{this.state.loadeditems.map(elem => (
<Preview
key={elem.MovieId}
name={elem.MovieName}
movie_id={elem.MovieId}/>
))}
{/*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; export default VideoContainer;

View File

@ -3,11 +3,11 @@ import ReactDOM from 'react-dom';
import App from './App'; import App from './App';
// don't allow console logs within production env // don't allow console logs within production env
global.console.log = process.env.NODE_ENV !== 'development' ? (s: string | number | boolean): void => {} : global.console.log; global.console.log = process.env.NODE_ENV !== 'development' ? (_: string | number | boolean): void => {} : global.console.log;
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<App/> <App />
</React.StrictMode>, </React.StrictMode>,
document.getElementById('root') document.getElementById('root')
); );

View File

@ -8,20 +8,6 @@ describe('<ActorOverviewPage/>', function () {
wrapper.unmount(); 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 () { it('test newtagpopup visibility', function () {
const wrapper = shallow(<ActorOverviewPage/>); const wrapper = shallow(<ActorOverviewPage/>);

View File

@ -4,20 +4,20 @@ import {ActorType} from '../../types/VideoTypes';
import ActorTile from '../../elements/ActorTile/ActorTile'; import ActorTile from '../../elements/ActorTile/ActorTile';
import PageTitle from '../../elements/PageTitle/PageTitle'; import PageTitle from '../../elements/PageTitle/PageTitle';
import SideBar from '../../elements/SideBar/SideBar'; import SideBar from '../../elements/SideBar/SideBar';
import style from './ActorOverviewPage.module.css'; // import style from './ActorOverviewPage.module.css';
import {Button} from '../../elements/GPElements/Button'; import {Button} from '../../elements/GPElements/Button';
import NewActorPopup from '../../elements/Popups/NewActorPopup/NewActorPopup'; import NewActorPopup from '../../elements/Popups/NewActorPopup/NewActorPopup';
import DynamicContentContainer from '../../elements/DynamicContentContainer/DynamicContentContainer';
interface props { interface Props {}
}
interface state { interface state {
actors: ActorType[]; actors: ActorType[];
NActorPopupVisible: boolean NActorPopupVisible: boolean;
} }
class ActorOverviewPage extends React.Component<props, state> { class ActorOverviewPage extends React.Component<Props, state> {
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
@ -33,24 +33,30 @@ class ActorOverviewPage extends React.Component<props, state> {
render(): JSX.Element { render(): JSX.Element {
return ( return (
<> <>
<PageTitle title='Actors' subtitle={this.state.actors.length + ' Actors'}/> <PageTitle title='Actors' subtitle={this.state.actors.length + ' Actors'} />
<SideBar> <SideBar>
<Button title='Add Actor' onClick={(): void => this.setState({NActorPopupVisible: true})}/> <Button title='Add Actor' onClick={(): void => this.setState({NActorPopupVisible: true})} />
</SideBar> </SideBar>
<div className={style.container}> <DynamicContentContainer
{this.state.actors.map((el) => (<ActorTile key={el.ActorId} actor={el}/>))} renderElement={(el): JSX.Element => <ActorTile key={el.ActorId} actor={el} />}
</div> data={this.state.actors}
{this.state.NActorPopupVisible ? initialLoadNr={36}
<NewActorPopup onHide={(): void => { />
this.setState({NActorPopupVisible: false});
this.fetchAvailableActors(); // refetch actors {this.state.NActorPopupVisible ? (
}}/> : null} <NewActorPopup
onHide={(): void => {
this.setState({NActorPopupVisible: false});
this.fetchAvailableActors(); // refetch actors
}}
/>
) : null}
</> </>
); );
} }
fetchAvailableActors(): void { fetchAvailableActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, result => { callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, (result) => {
this.setState({actors: result}); this.setState({actors: result});
}); });
} }

View File

@ -13,22 +13,20 @@ import {Button} from '../../elements/GPElements/Button';
import {ActorTypes, VideoTypes} from '../../types/ApiTypes'; import {ActorTypes, VideoTypes} from '../../types/ApiTypes';
interface state { interface state {
data: VideoTypes.VideoUnloadedType[], data: VideoTypes.VideoUnloadedType[];
actor: ActorType actor: ActorType;
} }
/** /**
* empty default props with id in url * empty default props with id in url
*/ */
interface props extends RouteComponentProps<{ id: string }> { interface Props extends RouteComponentProps<{id: string}> {}
}
/** /**
* info page about a specific actor and a list of all its videos * info page about a specific actor and a list of all its videos
*/ */
export class ActorPage extends React.Component<props, state> { export class ActorPage extends React.Component<Props, state> {
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = {data: [], actor: {ActorId: 0, Name: '', Thumbnail: ''}}; this.state = {data: [], actor: {ActorId: 0, Name: '', Thumbnail: ''}};
@ -40,20 +38,17 @@ export class ActorPage extends React.Component<props, state> {
<PageTitle title={this.state.actor.Name} subtitle={this.state.data ? this.state.data.length + ' videos' : null}> <PageTitle title={this.state.actor.Name} subtitle={this.state.data ? this.state.data.length + ' videos' : null}>
<span className={style.overviewbutton}> <span className={style.overviewbutton}>
<Link to='/actors'> <Link to='/actors'>
<Button onClick={(): void => {}} title='Go to Actor overview'/> <Button onClick={(): void => {}} title='Go to Actor overview' />
</Link> </Link>
</span> </span>
</PageTitle> </PageTitle>
<SideBar> <SideBar>
<div className={style.pic}> <div className={style.pic}>
<FontAwesomeIcon style={{color: 'white'}} icon={faUser} size='10x'/> <FontAwesomeIcon style={{color: 'white'}} icon={faUser} size='10x' />
</div> </div>
<SideBarTitle>Attention: This is an early preview!</SideBarTitle> <SideBarTitle>Attention: This is an early preview!</SideBarTitle>
</SideBar> </SideBar>
{this.state.data.length !== 0 ? <VideoContainer data={this.state.data} />
<VideoContainer
data={this.state.data}/> :
<div>No Data found!</div>}
</> </>
); );
} }
@ -66,15 +61,23 @@ export class ActorPage extends React.Component<props, state> {
* request more actor info from backend * request more actor info from backend
*/ */
getActorInfo(): void { getActorInfo(): void {
callAPI(APINode.Actor, { callAPI(
action: 'getActorInfo', APINode.Actor,
ActorId: parseInt(this.props.match.params.id) {
}, (result: ActorTypes.videofetchresult) => { action: 'getActorInfo',
this.setState({ ActorId: parseInt(this.props.match.params.id, 10)
data: result.Videos ? result.Videos : [], },
actor: result.Info (result: ActorTypes.videofetchresult) => {
}); this.setState({
}); data: result.Videos ? result.Videos : [],
actor: result.Info
});
},
(_) => {
// if there is an load error redirect to home page
this.props.history.push('/');
}
);
} }
} }

View File

@ -0,0 +1,56 @@
.main {
background-color: #00b3ff;
margin-left: calc(50% - 125px);
margin-top: 5%;
padding-bottom: 15px;
width: 250px;
text-align: center;
border-radius: 10px;
}
.loginText {
font-size: xx-large;
text-align: center;
margin-bottom: 15px;
font-weight: bolder;
}
.openmediacenterlabel {
margin-top: 5%;
text-align: center;
font-size: xxx-large;
font-weight: bold;
text-transform: capitalize;
color: white;
}
.input {
margin-left: 10px;
margin-right: 10px;
width: calc(100% - 20px);
background: transparent;
border-width: 0 0 1px 0;
color: #505050;
border-color: #505050;
text-align: center;
margin-bottom: 25px;
font-size: larger;
}
.submitbtn {
margin-top: 10px;
}
::placeholder {
color: #505050;
opacity: 1;
}
*:focus {
outline: none;
}
.input:focus {
color: black;
border-color: black;
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import AuthenticationPage from './AuthenticationPage';
import {shallow} from 'enzyme';
import {token} from "../../utils/TokenHandler";
describe('<AuthenticationPage/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AuthenticationPage submit={() => {
}}/>);
wrapper.unmount();
});
it('test button click', function () {
const func = jest.fn();
const wrapper = shallow(<AuthenticationPage onSuccessLogin={func}/>);
wrapper.instance().authenticate = jest.fn(() => {
wrapper.instance().props.onSuccessLogin()
});
wrapper.setState({pwdText: 'testpwd'});
wrapper.find('Button').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
it('test fail authenticate', function () {
const events = mockKeyPress();
token.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => {
callback('there was an error')
});
const wrapper = shallow(<AuthenticationPage/>);
events.keyup({key: 'Enter'});
expect(wrapper.state().wrongPWDInfo).toBe(true);
});
it('test success authenticate', function () {
const events = mockKeyPress();
const func = jest.fn()
token.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => {
callback('')
});
const wrapper = shallow(<AuthenticationPage onSuccessLogin={func}/>);
events.keyup({key: 'Enter'});
expect(wrapper.state().wrongPWDInfo).toBe(false);
expect(func).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,110 @@
import React from 'react';
import {Button} from '../../elements/GPElements/Button';
import style from './AuthenticationPage.module.css';
import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler';
import {token} from '../../utils/TokenHandler';
import {faTimes} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
interface state {
pwdText: string;
wrongPWDInfo: boolean;
}
interface Props {
onSuccessLogin: () => void;
}
class AuthenticationPage extends React.Component<Props, state> {
constructor(props: Props) {
super(props);
this.state = {
pwdText: '',
wrongPWDInfo: false
};
this.keypress = this.keypress.bind(this);
this.authenticate = this.authenticate.bind(this);
}
componentDidMount(): void {
addKeyHandler(this.keypress);
}
componentWillUnmount(): void {
removeKeyHandler(this.keypress);
}
render(): JSX.Element {
return (
<>
<div className={style.openmediacenterlabel}>OpenMediaCenter</div>
<div className={style.main}>
<div className={style.loginText}>Login</div>
<div>
<input
className={style.input}
placeholder='Password'
type='password'
onChange={(ch): void => this.setState({pwdText: ch.target.value})}
value={this.state.pwdText}
/>
{this.state.wrongPWDInfo ? (
<div>
<FontAwesomeIcon
style={{
color: 'red',
marginRight: '7px'
}}
icon={faTimes}
size='1x'
/>
wrong password!
</div>
) : null}
</div>
<div className={style.submitbtn}>
<Button title='Submit' onClick={this.authenticate} />
</div>
</div>
</>
);
}
/**
* request a new token and check if pwd was valid
*/
authenticate(): void {
token.refreshAPIToken(
(error) => {
if (error !== '') {
this.setState({wrongPWDInfo: true});
// set timeout to make the info auto-disappearing
setTimeout(() => {
this.setState({wrongPWDInfo: false});
}, 2000);
} else {
this.props.onSuccessLogin();
}
},
true,
this.state.pwdText
);
}
/**
* key event handling
* @param event keyevent
*/
keypress(event: KeyboardEvent): void {
// hide if escape is pressed
if (event.key === 'Enter') {
// call submit
this.authenticate();
}
}
}
export default AuthenticationPage;

View File

@ -12,10 +12,10 @@ class CategoryPage extends React.Component {
return ( return (
<Switch> <Switch>
<Route path='/categories/:id'> <Route path='/categories/:id'>
<CategoryViewWR/> <CategoryViewWR />
</Route> </Route>
<Route path='/categories'> <Route path='/categories'>
<TagView/> <TagView />
</Route> </Route>
</Switch> </Switch>
); );

View File

@ -4,7 +4,9 @@ import {CategoryView} from './CategoryView';
describe('<CategoryView/>', function () { describe('<CategoryView/>', function () {
function instance() { function instance() {
return shallow(<CategoryView match={{params: {id: 10}}} history={{push: jest.fn()}}/>); const inst = shallow(<CategoryView match={{params: {id: 10}}} history={{push: jest.fn()}}/>);
inst.setState({loaded: true});
return inst;
} }
it('renders without crashing ', function () { it('renders without crashing ', function () {

View File

@ -10,12 +10,14 @@ import Tag from '../../elements/Tag/Tag';
import {DefaultTags, GeneralSuccess} from '../../types/GeneralTypes'; import {DefaultTags, GeneralSuccess} from '../../types/GeneralTypes';
import {Button} from '../../elements/GPElements/Button'; import {Button} from '../../elements/GPElements/Button';
import SubmitPopup from '../../elements/Popups/SubmitPopup/SubmitPopup'; import SubmitPopup from '../../elements/Popups/SubmitPopup/SubmitPopup';
import {Spinner} from 'react-bootstrap';
interface CategoryViewProps extends RouteComponentProps<{ id: string }> {} interface CategoryViewProps extends RouteComponentProps<{id: string}> {}
interface CategoryViewState { interface CategoryViewState {
loaded: boolean; loaded: boolean;
submitForceDelete: boolean; submitForceDelete: boolean;
TagName: string;
} }
/** /**
@ -29,47 +31,61 @@ export class CategoryView extends React.Component<CategoryViewProps, CategoryVie
this.state = { this.state = {
loaded: false, loaded: false,
submitForceDelete: false submitForceDelete: false,
TagName: ''
}; };
} }
componentDidMount(): void { componentDidMount(): void {
this.fetchVideoData(parseInt(this.props.match.params.id)); this.fetchVideoData(parseInt(this.props.match.params.id, 10));
} }
componentDidUpdate(prevProps: Readonly<CategoryViewProps>, prevState: Readonly<CategoryViewState>): void { componentDidUpdate(prevProps: Readonly<CategoryViewProps>): void {
// trigger video refresh if id changed // trigger video refresh if id changed
if (prevProps.match.params.id !== this.props.match.params.id) { if (prevProps.match.params.id !== this.props.match.params.id) {
this.setState({loaded: false}); this.reloadVideoData();
this.fetchVideoData(parseInt(this.props.match.params.id));
} }
} }
reloadVideoData(): void {
this.setState({loaded: false});
this.fetchVideoData(parseInt(this.props.match.params.id, 10));
}
render(): JSX.Element { render(): JSX.Element {
if (!this.state.loaded) {
return <Spinner animation='border' />;
}
return ( return (
<> <>
<PageTitle <PageTitle title={this.state.TagName} subtitle={this.videodata.length + ' Videos'} />
title='Categories'
subtitle={this.videodata.length + ' Videos'}/>
<SideBar> <SideBar>
<SideBarTitle>Default Tags:</SideBarTitle> <SideBarTitle>Default Tags:</SideBarTitle>
<Tag tagInfo={DefaultTags.all}/> <Tag tagInfo={DefaultTags.all} />
<Tag tagInfo={DefaultTags.fullhd}/> <Tag tagInfo={DefaultTags.fullhd} />
<Tag tagInfo={DefaultTags.hd}/> <Tag tagInfo={DefaultTags.hd} />
<Tag tagInfo={DefaultTags.lowq}/> <Tag tagInfo={DefaultTags.lowq} />
<Line/> <Line />
<Button title='Delete Tag' onClick={(): void => {this.deleteTag(false);}} color={{backgroundColor: 'red'}}/> <Button
</SideBar> title='Delete Tag'
{this.state.loaded ?
<VideoContainer
data={this.videodata}/> : null}
<button data-testid='backbtn' className='btn btn-success'
onClick={(): void => { onClick={(): void => {
this.props.history.push('/categories'); this.deleteTag(false);
}}>Back to Categories }}
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> </button>
{this.handlePopups()} {this.handlePopups()}
</> </>
@ -78,9 +94,14 @@ export class CategoryView extends React.Component<CategoryViewProps, CategoryVie
private handlePopups(): JSX.Element { private handlePopups(): JSX.Element {
if (this.state.submitForceDelete) { if (this.state.submitForceDelete) {
return (<SubmitPopup return (
onHide={(): void => this.setState({submitForceDelete: false})} <SubmitPopup
submit={(): void => {this.deleteTag(true);}}/>); onHide={(): void => this.setState({submitForceDelete: false})}
submit={(): void => {
this.deleteTag(true);
}}
/>
);
} else { } else {
return <></>; return <></>;
} }
@ -91,29 +112,41 @@ export class CategoryView extends React.Component<CategoryViewProps, CategoryVie
* @param id tagid * @param id tagid
*/ */
private fetchVideoData(id: number): void { private fetchVideoData(id: number): void {
callAPI<VideoTypes.VideoUnloadedType[]>(APINode.Video, {action: 'getMovies', tag: id}, result => { callAPI(
this.videodata = result; APINode.Video,
this.setState({loaded: true}); {action: 'getMovies', Tag: id},
}); (result: {Videos: VideoTypes.VideoUnloadedType[]; TagName: string}) => {
this.videodata = result.Videos;
this.setState({loaded: true, TagName: result.TagName});
},
(_) => {
// if there is an load error redirect to home page
this.props.history.push('/');
}
);
} }
/** /**
* delete the current tag * delete the current tag
*/ */
private deleteTag(force: boolean): void { private deleteTag(force: boolean): void {
callAPI<GeneralSuccess>(APINode.Tags, { callAPI<GeneralSuccess>(
action: 'deleteTag', APINode.Tags,
TagId: parseInt(this.props.match.params.id), {
Force: force action: 'deleteTag',
}, result => { TagId: parseInt(this.props.match.params.id, 10),
console.log(result.result); Force: force
if (result.result === 'success') { },
this.props.history.push('/categories'); (result) => {
} else if (result.result === 'not empty tag') { console.log(result.result);
// show submisison tag to ask if really delete if (result.result === 'success') {
this.setState({submitForceDelete: true}); this.props.history.push('/categories');
} else if (result.result === 'not empty tag') {
// show submisison tag to ask if really delete
this.setState({submitForceDelete: true});
}
} }
}); );
} }
} }

View File

@ -8,13 +8,6 @@ describe('<TagView/>', function () {
wrapper.unmount(); 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 () { it('test new tag popup', function () {
const wrapper = shallow(<TagView/>); const wrapper = shallow(<TagView/>);

View File

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

View File

@ -7,3 +7,61 @@
float: right; float: right;
margin-top: 25px; margin-top: 25px;
} }
.sortbyLabel {
color: grey;
margin-right: 5px;
margin-left: 25px;
}
/* Style The Dropdown Button */
.dropbtn {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
height: 100%;
cursor: pointer;
color: white;
text-align: center;
}
/* The container <div> - needed to position the dropdown content */
.dropdown {
position: relative;
display: inline-block;
}
/* Dropdown Content (Hidden by Default) */
.dropdownContent {
display: none;
position: absolute;
background-color: #f9f9f9;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}
/* Links inside the dropdown */
.dropdownContent span {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
cursor: pointer;
}
/* Change color of dropdown links on hover */
.dropdownContent span:hover {
background-color: #f1f1f1;
}
/* Show the dropdown menu on hover */
.dropdown:hover .dropdownContent {
display: block;
}
/* Change the background color of the dropdown button when the dropdown content is shown */
.dropdown:hover .dropbtn {
background-color: #3574fe;
}

View File

@ -1,8 +1,10 @@
import {shallow} from 'enzyme'; import {shallow} from 'enzyme';
import React from 'react'; import React from 'react';
import {HomePage} from './HomePage'; import {HomePage, SortBy} from './HomePage';
import VideoContainer from '../../elements/VideoContainer/VideoContainer'; import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import {SearchHandling} from './SearchHandling'; import {SearchHandling} from './SearchHandling';
import exp from "constants";
import {DefaultTags} from "../../types/GeneralTypes";
describe('<HomePage/>', function () { describe('<HomePage/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -10,21 +12,6 @@ describe('<HomePage/>', function () {
wrapper.unmount(); wrapper.unmount();
}); });
it('test data insertion', function () {
const wrapper = shallow(<HomePage/>);
expect(wrapper.find('VideoContainer')).toHaveLength(0);
wrapper.setState({
data: [
{}, {}
]
});
// there shoud be loaded the Videocontainer element into dom after fetching videos correctly
expect(wrapper.find('VideoContainer')).toHaveLength(1);
});
it('test title and nr insertions', function () { it('test title and nr insertions', function () {
const wrapper = shallow(<HomePage/>); const wrapper = shallow(<HomePage/>);
@ -38,23 +25,6 @@ describe('<HomePage/>', function () {
expect(wrapper.find('PageTitle').props().subtitle).toBe('testsubtitle - 42'); expect(wrapper.find('PageTitle').props().subtitle).toBe('testsubtitle - 42');
}); });
it('test search field', done => {
global.fetch = global.prepareFetchApi([{}, {}]);
const wrapper = shallow(<HomePage/>);
wrapper.find('[data-testid="searchtextfield"]').simulate('change', {target: {value: 'testvalue'}});
wrapper.find('[data-testid="searchbtnsubmit"]').simulate('click');
process.nextTick(() => {
// state to be set correctly with response
expect(wrapper.state().selectionnr).toBe(2);
global.fetch.mockClear();
done();
});
});
it('test form submit', () => { it('test form submit', () => {
const func = jest.fn(); const func = jest.fn();
const wrapper = shallow(<HomePage/>); const wrapper = shallow(<HomePage/>);
@ -87,7 +57,7 @@ describe('<HomePage/>', function () {
}); });
it('test tag click', done => { it('test tag click', done => {
global.fetch = prepareFetchApi(['test1', 'test2']); global.fetch = prepareFetchApi({Videos: ['test1', 'test2'], TagName: 'all'});
const wrapper = shallow(<HomePage/>); const wrapper = shallow(<HomePage/>);
@ -115,6 +85,20 @@ describe('<HomePage/>', function () {
testBtn(tags.first()); testBtn(tags.first());
}); });
it('test sortby type change', function () {
const wrapper = shallow(<HomePage/>);
// expect those default values
expect(wrapper.state().sortby).toBe('Date Added');
expect(wrapper.instance().sortState).toBe(SortBy.date);
expect(wrapper.instance().tagState).toBe(DefaultTags.all);
wrapper.instance().onDropDownItemClick(SortBy.name, 'namesort');
expect(wrapper.state().sortby).toBe('namesort');
expect(wrapper.instance().sortState).toBe(SortBy.name);
});
}); });
describe('<SearchHandling/>', () => { describe('<SearchHandling/>', () => {

View File

@ -10,25 +10,37 @@ import {Route, Switch, withRouter} from 'react-router-dom';
import {RouteComponentProps} from 'react-router'; import {RouteComponentProps} from 'react-router';
import SearchHandling from './SearchHandling'; import SearchHandling from './SearchHandling';
import {VideoTypes} from '../../types/ApiTypes'; import {VideoTypes} from '../../types/ApiTypes';
import {DefaultTags} from "../../types/GeneralTypes"; import {DefaultTags} from '../../types/GeneralTypes';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSortDown} from '@fortawesome/free-solid-svg-icons';
interface props extends RouteComponentProps {} // eslint-disable-next-line no-shadow
export enum SortBy {
date,
likes,
random,
name,
length
}
interface Props extends RouteComponentProps {}
interface state { interface state {
sideinfo: VideoTypes.startDataType sideinfo: VideoTypes.startDataType;
subtitle: string, subtitle: string;
data: VideoTypes.VideoUnloadedType[], data: VideoTypes.VideoUnloadedType[];
selectionnr: number selectionnr: number;
sortby: string;
} }
/** /**
* The home page component showing on the initial pageload * The home page component showing on the initial pageload
*/ */
export class HomePage extends React.Component<props, state> { export class HomePage extends React.Component<Props, state> {
/** keyword variable needed temporary store search keyword */ /** keyword variable needed temporary store search keyword */
keyword = ''; keyword = '';
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
@ -38,17 +50,21 @@ export class HomePage extends React.Component<props, state> {
HDNr: 0, HDNr: 0,
SDNr: 0, SDNr: 0,
DifferentTags: 0, DifferentTags: 0,
Tagged: 0, Tagged: 0
}, },
subtitle: 'All Videos', subtitle: 'All Videos',
data: [], data: [],
selectionnr: 0 selectionnr: 0,
sortby: 'Date Added'
}; };
} }
sortState = SortBy.date;
tagState = DefaultTags.all;
componentDidMount(): void { componentDidMount(): void {
// initial get of all videos // initial get of all videos
this.fetchVideoData(DefaultTags.all.TagId); this.fetchVideoData();
this.fetchStartData(); this.fetchStartData();
} }
@ -58,16 +74,17 @@ export class HomePage extends React.Component<props, state> {
* *
* @param tag tag to fetch videos * @param tag tag to fetch videos
*/ */
fetchVideoData(tag: number): void { fetchVideoData(): void {
callAPI(APINode.Video, {action: 'getMovies', tag: tag}, (result: VideoTypes.VideoUnloadedType[]) => { callAPI(
this.setState({ APINode.Video,
data: [] {action: 'getMovies', Tag: this.tagState.TagId, Sort: this.sortState},
}); (result: {Videos: VideoTypes.VideoUnloadedType[]; TagName: string}) => {
this.setState({ this.setState({
data: result, data: result.Videos,
selectionnr: result.length, selectionnr: result.Videos.length
}); });
}); }
);
} }
/** /**
@ -79,70 +96,123 @@ export class HomePage extends React.Component<props, state> {
}); });
} }
render(): JSX.Element { render(): JSX.Element {
return ( return (
<> <>
<Switch> <Switch>
<Route path='/search/:name'> <Route path='/search/:name'>
<SearchHandling/> <SearchHandling />
</Route> </Route>
<Route path='/'> <Route path='/'>
<PageTitle <PageTitle title='Home Page' subtitle={this.state.subtitle + ' - ' + this.state.selectionnr}>
title='Home Page' <form
subtitle={this.state.subtitle + ' - ' + this.state.selectionnr}> className={'form-inline ' + style.searchform}
<form className={'form-inline ' + style.searchform} onSubmit={(e): void => { onSubmit={(e): void => {
e.preventDefault(); e.preventDefault();
this.props.history.push('/search/' + this.keyword); this.props.history.push('/search/' + this.keyword);
}}> }}>
<input data-testid='searchtextfield' className='form-control mr-sm-2' <input
type='text' placeholder='Search' data-testid='searchtextfield'
onChange={(e): void => { className='form-control mr-sm-2'
this.keyword = e.target.value; type='text'
}}/> placeholder='Search'
<button data-testid='searchbtnsubmit' className='btn btn-success' type='submit'>Search</button> onChange={(e): void => {
this.keyword = e.target.value;
}}
/>
<button data-testid='searchbtnsubmit' className='btn btn-success' type='submit'>
Search
</button>
</form> </form>
</PageTitle> </PageTitle>
<SideBar> <SideBar>
<SideBarTitle>Infos:</SideBarTitle> <SideBarTitle>Infos:</SideBarTitle>
<Line/> <Line />
<SideBarItem><b>{this.state.sideinfo.VideoNr}</b> Videos Total!</SideBarItem> <SideBarItem>
<SideBarItem><b>{this.state.sideinfo.FullHdNr}</b> FULL-HD Videos!</SideBarItem> <b>{this.state.sideinfo.VideoNr}</b> Videos Total!
<SideBarItem><b>{this.state.sideinfo.HDNr}</b> HD Videos!</SideBarItem> </SideBarItem>
<SideBarItem><b>{this.state.sideinfo.SDNr}</b> SD Videos!</SideBarItem> <SideBarItem>
<SideBarItem><b>{this.state.sideinfo.DifferentTags}</b> different Tags!</SideBarItem> <b>{this.state.sideinfo.FullHdNr}</b> FULL-HD Videos!
<Line/> </SideBarItem>
<SideBarItem>
<b>{this.state.sideinfo.HDNr}</b> HD Videos!
</SideBarItem>
<SideBarItem>
<b>{this.state.sideinfo.SDNr}</b> SD Videos!
</SideBarItem>
<SideBarItem>
<b>{this.state.sideinfo.DifferentTags}</b> different Tags!
</SideBarItem>
<Line />
<SideBarTitle>Default Tags:</SideBarTitle> <SideBarTitle>Default Tags:</SideBarTitle>
<Tag tagInfo={{TagName: 'All', TagId: DefaultTags.all.TagId}} onclick={(): void => { <Tag
this.fetchVideoData(DefaultTags.all.TagId); tagInfo={{TagName: 'All', TagId: DefaultTags.all.TagId}}
this.setState({subtitle: `All Videos`}); onclick={(): void => {
}}/> this.tagState = DefaultTags.all;
<Tag tagInfo={{TagName: 'Full Hd', TagId: DefaultTags.fullhd.TagId}} onclick={(): void => { this.fetchVideoData();
this.fetchVideoData(DefaultTags.fullhd.TagId); this.setState({subtitle: 'All Videos'});
this.setState({subtitle: `Full Hd Videos`}); }}
}}/> />
<Tag tagInfo={{TagName: 'Low Quality', TagId: DefaultTags.lowq.TagId}} <Tag
onclick={(): void => { tagInfo={{TagName: 'Full Hd', TagId: DefaultTags.fullhd.TagId}}
this.fetchVideoData(DefaultTags.lowq.TagId); onclick={(): void => {
this.setState({subtitle: `Low Quality Videos`}); this.tagState = DefaultTags.fullhd;
}}/> this.fetchVideoData();
<Tag tagInfo={{TagName: 'HD', TagId: DefaultTags.hd.TagId}} onclick={(): void => { this.setState({subtitle: 'Full Hd Videos'});
this.fetchVideoData(DefaultTags.hd.TagId); }}
this.setState({subtitle: `HD Videos`}); />
}}/> <Tag
tagInfo={{TagName: 'Low Quality', TagId: DefaultTags.lowq.TagId}}
onclick={(): void => {
this.tagState = DefaultTags.lowq;
this.fetchVideoData();
this.setState({subtitle: 'Low Quality Videos'});
}}
/>
<Tag
tagInfo={{TagName: 'HD', TagId: DefaultTags.hd.TagId}}
onclick={(): void => {
this.tagState = DefaultTags.hd;
this.fetchVideoData();
this.setState({subtitle: 'HD Videos'});
}}
/>
</SideBar> </SideBar>
{this.state.data.length !== 0 ? <div>
<VideoContainer <span className={style.sortbyLabel}>Sort By: </span>
data={this.state.data}/> : <div className={style.dropdown}>
<div>No Data found!</div>} <span className={style.dropbtn}>
<div className={style.rightinfo}> <span>{this.state.sortby}</span>
<FontAwesomeIcon style={{marginLeft: 3, paddingBottom: 3}} icon={faSortDown} size='1x' />
</span>
<div className={style.dropdownContent}>
<span onClick={(): void => this.onDropDownItemClick(SortBy.date, 'Date Added')}>Date Added</span>
<span onClick={(): void => this.onDropDownItemClick(SortBy.likes, 'Most likes')}>Most likes</span>
<span onClick={(): void => this.onDropDownItemClick(SortBy.random, 'Random')}>Random</span>
<span onClick={(): void => this.onDropDownItemClick(SortBy.name, 'Name')}>Name</span>
<span onClick={(): void => this.onDropDownItemClick(SortBy.length, 'Length')}>Length</span>
</div>
</div>
</div> </div>
<VideoContainer data={this.state.data} />
<div className={style.rightinfo} />
</Route> </Route>
</Switch> </Switch>
</> </>
); );
} }
/**
* click handler for sortby dropdown item click
* @param type type of sort action
* @param name new header title
*/
onDropDownItemClick(type: SortBy, name: string): void {
this.sortState = type;
this.setState({sortby: name});
this.fetchVideoData();
}
} }
export default withRouter(HomePage); export default withRouter(HomePage);

View File

@ -11,14 +11,14 @@ interface params {
name: string; name: string;
} }
interface props extends RouteComponentProps<params> {} interface Props extends RouteComponentProps<params> {}
interface state { interface state {
data: VideoTypes.VideoUnloadedType[]; data: VideoTypes.VideoUnloadedType[];
} }
export class SearchHandling extends React.Component<props, state> { export class SearchHandling extends React.Component<Props, state> {
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
@ -33,8 +33,8 @@ export class SearchHandling extends React.Component<props, state> {
render(): JSX.Element { render(): JSX.Element {
return ( return (
<> <>
<PageTitle title='Search' subtitle={this.props.match.params.name + ': ' + this.state.data.length}/> <PageTitle title='Search' subtitle={this.props.match.params.name + ': ' + this.state.data.length} />
<SideBar hiddenFrame/> <SideBar hiddenFrame />
{this.getVideoData()} {this.getVideoData()}
</> </>
); );
@ -45,11 +45,9 @@ export class SearchHandling extends React.Component<props, state> {
*/ */
getVideoData(): JSX.Element { getVideoData(): JSX.Element {
if (this.state.data.length !== 0) { if (this.state.data.length !== 0) {
return ( return <VideoContainer data={this.state.data} />;
<VideoContainer data={this.state.data}/>
);
} else { } else {
return (<div>No Data found!</div>); return <div>No Data found!</div>;
} }
} }
@ -59,7 +57,7 @@ export class SearchHandling extends React.Component<props, state> {
* @param keyword The keyword to search for * @param keyword The keyword to search for
*/ */
searchVideos(keyword: string): void { searchVideos(keyword: string): void {
callAPI(APINode.Video, {action: 'getSearchKeyWord', keyword: keyword}, (result: VideoTypes.VideoUnloadedType[]) => { callAPI(APINode.Video, {action: 'getSearchKeyWord', KeyWord: keyword}, (result: VideoTypes.VideoUnloadedType[]) => {
this.setState({ this.setState({
data: result data: result
}); });

View File

@ -1,74 +1,60 @@
import React from 'react'; import React from 'react';
import style from './Player.module.css'; import style from './Player.module.css';
import plyrstyle from 'plyr-react/dist/plyr.css'; import plyrstyle from 'plyr-react/dist/plyr.css';
import {Plyr} from 'plyr-react'; import {Plyr} from 'plyr-react';
import PlyrJS from 'plyr';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPlusCircle} from '@fortawesome/free-solid-svg-icons';
import SideBar, {SideBarItem, SideBarTitle} from '../../elements/SideBar/SideBar'; import SideBar, {SideBarItem, SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag'; import Tag from '../../elements/Tag/Tag';
import AddTagPopup from '../../elements/Popups/AddTagPopup/AddTagPopup'; import AddTagPopup from '../../elements/Popups/AddTagPopup/AddTagPopup';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle'; import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPlusCircle} from '@fortawesome/free-solid-svg-icons';
import AddActorPopup from '../../elements/Popups/AddActorPopup/AddActorPopup'; import AddActorPopup from '../../elements/Popups/AddActorPopup/AddActorPopup';
import ActorTile from '../../elements/ActorTile/ActorTile'; import ActorTile from '../../elements/ActorTile/ActorTile';
import {withRouter} from 'react-router-dom'; import {withRouter} from 'react-router-dom';
import {callAPI, getBackendDomain, APINode} from '../../utils/Api'; import {APINode, callAPI} from '../../utils/Api';
import {RouteComponentProps} from 'react-router'; import {RouteComponentProps} from 'react-router';
import {GeneralSuccess} from '../../types/GeneralTypes'; import {DefaultPlyrOptions, GeneralSuccess} from '../../types/GeneralTypes';
import {ActorType, TagType} from '../../types/VideoTypes'; import {ActorType, TagType} from '../../types/VideoTypes';
import PlyrJS from 'plyr';
import {Button} from '../../elements/GPElements/Button'; import {Button} from '../../elements/GPElements/Button';
import {VideoTypes} from '../../types/ApiTypes'; import {VideoTypes} from '../../types/ApiTypes';
import GlobalInfos from "../../utils/GlobalInfos"; import GlobalInfos from "../../utils/GlobalInfos";
import QuickActionPop, {ContextItem} from '../../elements/QuickActionPop/QuickActionPop'; import QuickActionPop, {ContextItem} from '../../elements/QuickActionPop/QuickActionPop';
import GlobalInfos from '../../utils/GlobalInfos';
interface myprops extends RouteComponentProps<{ id: string }> {} interface Props extends RouteComponentProps<{id: string}> {}
interface mystate { interface mystate {
sources?: PlyrJS.SourceInfo, sources?: PlyrJS.SourceInfo;
movie_id: number, movieId: number;
movie_name: string, movieName: string;
likes: number, likes: number;
quality: number, quality: number;
length: number, length: number;
tags: TagType[], tags: TagType[];
suggesttag: TagType[], suggesttag: TagType[];
popupvisible: boolean, popupvisible: boolean;
actorpopupvisible: boolean, actorpopupvisible: boolean;
actors: ActorType[], actors: ActorType[];
tagContextMenu: boolean tagContextMenu: boolean;
} }
/** /**
* Player page loads when a video is selected to play and handles the video view * Player page loads when a video is selected to play and handles the video view
* and actions such as tag adding and liking * and actions such as tag adding and liking
*/ */
export class Player extends React.Component<myprops, mystate> { export class Player extends React.Component<Props, mystate> {
private options: PlyrJS.Options = {
controls: [
'play-large', // The large play button in the center
'play', // Play/pause playback
'progress', // The progress bar and scrubber for playback and buffering
'current-time', // The current time of playback
'duration', // The full duration of the media
'mute', // Toggle mute
'volume', // Volume control
'captions', // Toggle captions
'settings', // Settings menu
'airplay', // Airplay (currently Safari only)
'download', // Show a download button with a link to either the current source or a custom URL you specify in your options
'fullscreen' // Toggle fullscreen
]
};
private contextpos = {x: 0, y: 0, tagid: -1}; private contextpos = {x: 0, y: 0, tagid: -1};
constructor(props: myprops) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
movieId: -1,
movieName: '',
tagContextMenu: false, tagContextMenu: false,
movie_id: -1,
movie_name: '',
likes: 0, likes: 0,
quality: 0, quality: 0,
length: 0, length: 0,
@ -91,27 +77,37 @@ export class Player extends React.Component<myprops, mystate> {
render(): JSX.Element { render(): JSX.Element {
return ( return (
<div id='videocontainer'> <div id='videocontainer'>
<PageTitle <PageTitle title='Watch' subtitle={this.state.movieName} />
title='Watch'
subtitle={this.state.movie_name}/>
{this.assembleSideBar()} {this.assembleSideBar()}
<div className={style.videowrapper}> <div className={style.videowrapper}>
{/* video component is added here */} {/* video component is added here */}
{this.state.sources ? <Plyr {this.state.sources ? (
style={plyrstyle} <Plyr style={plyrstyle} source={this.state.sources} options={DefaultPlyrOptions} />
source={this.state.sources} ) : (
options={this.options}/> : <div>not loaded yet</div>
<div>not loaded yet</div>} )}
<div className={style.videoactions}> <div className={style.videoactions}>
<Button onClick={(): void => this.likebtn()} title='Like this Video!' color={{backgroundColor: 'green'}}/> <Button onClick={(): void => this.likebtn()} title='Like this Video!' color={{backgroundColor: 'green'}} />
<Button onClick={(): void => this.setState({popupvisible: true})} title='Give this Video a Tag' color={{backgroundColor: '#3574fe'}}/> <Button
<Button title='Delete Video' onClick={(): void => {this.deleteVideo();}} color={{backgroundColor: 'red'}}/> onClick={(): void => this.setState({popupvisible: true})}
title='Give this Video a Tag'
color={{backgroundColor: '#3574fe'}}
/>
<Button
title='Delete Video'
onClick={(): void => {
this.deleteVideo();
}}
color={{backgroundColor: 'red'}}
/>
</div> </div>
{this.assembleActorTiles()} {this.assembleActorTiles()}
</div> </div>
<button className={style.closebutton} onClick={(): void => this.closebtn()}>Close</button> <button className={style.closebutton} onClick={(): void => this.closebtn()}>
Close
</button>
{ {
// handle the popovers switched on and off according to state changes // handle the popovers switched on and off according to state changes
this.handlePopOvers() this.handlePopOvers()
@ -127,13 +123,21 @@ export class Player extends React.Component<myprops, mystate> {
return ( return (
<SideBar> <SideBar>
<SideBarTitle>Infos:</SideBarTitle> <SideBarTitle>Infos:</SideBarTitle>
<Line/> <Line />
<SideBarItem><b>{this.state.likes}</b> Likes!</SideBarItem> <SideBarItem>
{this.state.quality !== 0 ? <b>{this.state.likes}</b> Likes!
<SideBarItem><b>{this.state.quality}p</b> Quality!</SideBarItem> : null} </SideBarItem>
{this.state.length !== 0 ? {this.state.quality !== 0 ? (
<SideBarItem><b>{Math.round(this.state.length / 60)}</b> Minutes of length!</SideBarItem> : null} <SideBarItem>
<Line/> <b>{this.state.quality}p</b> Quality!
</SideBarItem>
) : null}
{this.state.length !== 0 ? (
<SideBarItem>
<b>{Math.round(this.state.length / 60)}</b> Minutes of length!
</SideBarItem>
) : null}
<Line />
<SideBarTitle>Tags:</SideBarTitle> <SideBarTitle>Tags:</SideBarTitle>
{this.state.tags.map((m: TagType) => ( {this.state.tags.map((m: TagType) => (
<Tag key={m.TagId} tagInfo={m} onContextMenu={(pos): void => { <Tag key={m.TagId} tagInfo={m} onContextMenu={(pos): void => {
@ -141,7 +145,7 @@ export class Player extends React.Component<myprops, mystate> {
this.contextpos = {...pos, tagid: m.TagId}; this.contextpos = {...pos, tagid: m.TagId};
}}/> }}/>
))} ))}
<Line/> <Line />
<SideBarTitle>Tag Quickadd:</SideBarTitle> <SideBarTitle>Tag Quickadd:</SideBarTitle>
{this.state.suggesttag.map((m: TagType) => ( {this.state.suggesttag.map((m: TagType) => (
<Tag <Tag
@ -149,7 +153,8 @@ export class Player extends React.Component<myprops, mystate> {
key={m.TagName} key={m.TagName}
onclick={(): void => { onclick={(): void => {
this.quickAddTag(m.TagId, m.TagName); this.quickAddTag(m.TagId, m.TagName);
}}/> }}
/>
))} ))}
</SideBar> </SideBar>
); );
@ -161,18 +166,20 @@ export class Player extends React.Component<myprops, mystate> {
private assembleActorTiles(): JSX.Element { private assembleActorTiles(): JSX.Element {
return ( return (
<div className={style.actorcontainer}> <div className={style.actorcontainer}>
{this.state.actors ? {this.state.actors ? this.state.actors.map((actr: ActorType) => <ActorTile key={actr.ActorId} actor={actr} />) : <></>}
this.state.actors.map((actr: ActorType) => ( <div
<ActorTile key={actr.ActorId} actor={actr}/> className={style.actorAddTile}
)) : <></> onClick={(): void => {
} this.addActor();
<div className={style.actorAddTile} onClick={(): void => { }}>
this.addActor();
}}>
<div className={style.actorAddTile_thumbnail}> <div className={style.actorAddTile_thumbnail}>
<FontAwesomeIcon style={{ <FontAwesomeIcon
lineHeight: '130px' style={{
}} icon={faPlusCircle} size='5x'/> lineHeight: '130px'
}}
icon={faPlusCircle}
size='5x'
/>
</div> </div>
<div className={style.actorAddTile_name}>Add Actor</div> <div className={style.actorAddTile_name}>Add Actor</div>
</div> </div>
@ -180,7 +187,6 @@ export class Player extends React.Component<myprops, mystate> {
); );
} }
/** /**
* handle the popovers generated according to state changes * handle the popovers generated according to state changes
* @returns {JSX.Element} * @returns {JSX.Element}
@ -188,21 +194,18 @@ export class Player extends React.Component<myprops, mystate> {
handlePopOvers(): JSX.Element { handlePopOvers(): JSX.Element {
return ( return (
<> <>
{this.state.popupvisible ? {this.state.popupvisible ? (
<AddTagPopup onHide={(): void => { <AddTagPopup onHide={(): void => this.setState({popupvisible: false})} submit={this.quickAddTag} />
this.setState({popupvisible: false}); ) : null}
}} {this.state.actorpopupvisible ? (
submit={this.quickAddTag} <AddActorPopup
movie_id={this.state.movie_id}/> : onHide={(): void => {
null
}
{
this.state.actorpopupvisible ?
<AddActorPopup onHide={(): void => {
this.refetchActors(); this.refetchActors();
this.setState({actorpopupvisible: false}); this.setState({actorpopupvisible: false});
}} movie_id={this.state.movie_id}/> : null }}
} movieId={this.state.movieId}
/>
) : null}
{this.renderContextMenu()} {this.renderContextMenu()}
</> </>
); );
@ -214,74 +217,92 @@ export class Player extends React.Component<myprops, mystate> {
* @param tagName name of tag to add * @param tagName name of tag to add
*/ */
quickAddTag(tagId: number, tagName: string): void { quickAddTag(tagId: number, tagName: string): void {
callAPI(APINode.Tags, { callAPI(
action: 'addTag', APINode.Tags,
TagId: tagId, {
MovieId: parseInt(this.props.match.params.id) action: 'addTag',
}, (result: GeneralSuccess) => { TagId: tagId,
if (result.result !== 'success') { MovieId: parseInt(this.props.match.params.id, 10)
console.error('error occured while writing to db -- todo error handling'); },
console.error(result.result); (result: GeneralSuccess) => {
} else { if (result.result !== 'success') {
// check if tag has already been added console.error('error occured while writing to db -- todo error handling');
const tagIndex = this.state.tags.map(function (e: TagType) { console.error(result.result);
return e.TagName; } else {
}).indexOf(tagName); // check if tag has already been added
const tagIndex = this.state.tags
.map(function (e: TagType) {
return e.TagName;
})
.indexOf(tagName);
// only add tag if it isn't already there // only add tag if it isn't already there
if (tagIndex === -1) { if (tagIndex === -1) {
// update tags if successful // update tags if successful
let array = [...this.state.suggesttag]; // make a separate copy of the array (because of setState) let array = [...this.state.suggesttag]; // make a separate copy of the array (because of setState)
const quickaddindex = this.state.suggesttag.map(function (e: TagType) { const quickaddindex = this.state.suggesttag
return e.TagId; .map(function (e: TagType) {
}).indexOf(tagId); return e.TagId;
})
.indexOf(tagId);
// check if tag is available in quickadds // check if tag is available in quickadds
if (quickaddindex !== -1) { if (quickaddindex !== -1) {
array.splice(quickaddindex, 1); array.splice(quickaddindex, 1);
this.setState({ this.setState({
tags: [...this.state.tags, {TagName: tagName, TagId: tagId}], tags: [...this.state.tags, {TagName: tagName, TagId: tagId}],
suggesttag: array suggesttag: array
}); });
} else { } else {
this.setState({ this.setState({
tags: [...this.state.tags, {TagName: tagName, TagId: tagId}] tags: [...this.state.tags, {TagName: tagName, TagId: tagId}]
}); });
}
} }
} }
} }
}); );
} }
/** /**
* fetch all the required infos of a video from backend * fetch all the required infos of a video from backend
*/ */
fetchMovieData(): void { fetchMovieData(): void {
callAPI(APINode.Video, {action: 'loadVideo', MovieId: parseInt(this.props.match.params.id)}, (result: VideoTypes.loadVideoType) => { callAPI(
console.log(result) APINode.Video,
this.setState({ {action: 'loadVideo', MovieId: parseInt(this.props.match.params.id, 10)},
sources: { (result: VideoTypes.loadVideoType) => {
type: 'video', this.setState({
sources: [ sources: {
{ type: 'video',
src: getBackendDomain() + GlobalInfos.getVideoPath() + result.MovieUrl, sources: [
type: 'video/mp4', {
size: 1080 src:
} (process.env.REACT_APP_CUST_BACK_DOMAIN ? process.env.REACT_APP_CUST_BACK_DOMAIN : '') +
], GlobalInfos.getVideoPath() +
poster: result.Poster result.MovieUrl,
}, type: 'video/mp4',
movie_id: result.MovieId, size: 1080
movie_name: result.MovieName, }
likes: result.Likes, ],
quality: result.Quality, poster: result.Poster
length: result.Length, },
tags: result.Tags, movieId: result.MovieId,
suggesttag: result.SuggestedTag, movieName: result.MovieName,
actors: result.Actors likes: result.Likes,
}); quality: result.Quality,
}); length: result.Length,
tags: result.Tags,
suggesttag: result.SuggestedTag,
actors: result.Actors
});
},
(_) => {
// if there is an load error redirect to home page
this.props.history.push('/');
}
);
} }
@ -289,7 +310,7 @@ export class Player extends React.Component<myprops, mystate> {
* click handler for the like btn * click handler for the like btn
*/ */
likebtn(): void { likebtn(): void {
callAPI(APINode.Video, {action: 'addLike', MovieId: parseInt(this.props.match.params.id)}, (result: GeneralSuccess) => { callAPI(APINode.Video, {action: 'addLike', MovieId: parseInt(this.props.match.params.id, 10)}, (result: GeneralSuccess) => {
if (result.result === 'success') { if (result.result === 'success') {
// likes +1 --> avoid reload of all data // likes +1 --> avoid reload of all data
this.setState({likes: this.state.likes + 1}); this.setState({likes: this.state.likes + 1});
@ -312,15 +333,19 @@ export class Player extends React.Component<myprops, mystate> {
* delete the current video and return to last page * delete the current video and return to last page
*/ */
deleteVideo(): void { deleteVideo(): void {
callAPI(APINode.Video, {action: 'deleteVideo', MovieId: parseInt(this.props.match.params.id)}, (result: GeneralSuccess) => { callAPI(
if (result.result === 'success') { APINode.Video,
// return to last element if successful {action: 'deleteVideo', MovieId: parseInt(this.props.match.params.id, 10)},
this.props.history.goBack(); (result: GeneralSuccess) => {
} else { if (result.result === 'success') {
console.error('an error occured while liking'); // return to last element if successful
console.error(result); this.props.history.goBack();
} else {
console.error('an error occured while liking');
console.error(result);
}
} }
}); );
} }
/** /**
@ -334,9 +359,13 @@ export class Player extends React.Component<myprops, mystate> {
* fetch the available video actors again * fetch the available video actors again
*/ */
refetchActors(): void { refetchActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getActorsOfVideo', videoid: this.props.match.params.id}, result => { callAPI<ActorType[]>(
this.setState({actors: result}); APINode.Actor,
}); {action: 'getActorsOfVideo', MovieId: parseInt(this.props.match.params.id, 10)},
(result) => {
this.setState({actors: result});
}
);
} }
/** /**

View File

@ -23,6 +23,8 @@ interface GetRandomMoviesType {
* Randompage shuffles random viedeopreviews and provides a shuffle btn * Randompage shuffles random viedeopreviews and provides a shuffle btn
*/ */
class RandomPage extends React.Component<{}, state> { class RandomPage extends React.Component<{}, state> {
readonly LoadNR = 3;
constructor(props: {}) { constructor(props: {}) {
super(props); super(props);
@ -37,7 +39,7 @@ class RandomPage extends React.Component<{}, state> {
componentDidMount(): void { componentDidMount(): void {
addKeyHandler(this.keypress); addKeyHandler(this.keypress);
this.loadShuffledvideos(4); this.loadShuffledvideos(this.LoadNR);
} }
componentWillUnmount(): void { componentWillUnmount(): void {
@ -47,26 +49,26 @@ class RandomPage extends React.Component<{}, state> {
render(): JSX.Element { render(): JSX.Element {
return ( return (
<div> <div>
<PageTitle title='Random Videos' <PageTitle title='Random Videos' subtitle='4pc' />
subtitle='4pc'/>
<SideBar> <SideBar>
<SideBarTitle>Visible Tags:</SideBarTitle> <SideBarTitle>Visible Tags:</SideBarTitle>
{this.state.tags.map((m) => ( {this.state.tags.map((m) => (
<Tag key={m.TagId} tagInfo={m}/> <Tag key={m.TagId} tagInfo={m} />
))} ))}
</SideBar> </SideBar>
{this.state.videos.length !== 0 ? {this.state.videos.length !== 0 ? (
<VideoContainer <VideoContainer data={this.state.videos}>
data={this.state.videos}>
<div className={style.Shufflebutton}> <div className={style.Shufflebutton}>
<button onClick={(): void => this.shuffleclick()} className={style.btnshuffle}>Shuffle</button> <button onClick={(): void => this.shuffleclick()} className={style.btnshuffle}>
Shuffle
</button>
</div> </div>
</VideoContainer> </VideoContainer>
: ) : (
<div>No Data found!</div>} <div>No Data found!</div>
)}
</div> </div>
); );
} }
@ -75,7 +77,7 @@ class RandomPage extends React.Component<{}, state> {
* click handler for shuffle btn * click handler for shuffle btn
*/ */
shuffleclick(): void { shuffleclick(): void {
this.loadShuffledvideos(4); this.loadShuffledvideos(this.LoadNR);
} }
/** /**
@ -83,8 +85,7 @@ class RandomPage extends React.Component<{}, state> {
* @param nr number of videos to load * @param nr number of videos to load
*/ */
loadShuffledvideos(nr: number): void { loadShuffledvideos(nr: number): void {
callAPI<GetRandomMoviesType>(APINode.Video, {action: 'getRandomMovies', number: nr}, result => { callAPI<GetRandomMoviesType>(APINode.Video, {action: 'getRandomMovies', Number: nr}, (result) => {
console.log(result)
this.setState({videos: []}); // needed to trigger rerender of main videoview this.setState({videos: []}); // needed to trigger rerender of main videoview
this.setState({ this.setState({
videos: result.Videos, videos: result.Videos,

View File

@ -6,42 +6,40 @@ import InfoHeaderItem from '../../elements/InfoHeaderItem/InfoHeaderItem';
import {faArchive, faBalanceScaleLeft, faRulerVertical} from '@fortawesome/free-solid-svg-icons'; import {faArchive, faBalanceScaleLeft, faRulerVertical} from '@fortawesome/free-solid-svg-icons';
import {faAddressCard} from '@fortawesome/free-regular-svg-icons'; import {faAddressCard} from '@fortawesome/free-regular-svg-icons';
import {version} from '../../../package.json'; import {version} from '../../../package.json';
import {APINode, callAPI, setCustomBackendDomain} from '../../utils/Api'; import {APINode, callAPI} from '../../utils/Api';
import {SettingsTypes} from '../../types/ApiTypes'; import {SettingsTypes} from '../../types/ApiTypes';
import {GeneralSuccess} from '../../types/GeneralTypes'; import {GeneralSuccess} from '../../types/GeneralTypes';
interface state { interface state {
customapi: boolean generalSettings: SettingsTypes.SettingsType;
apipath: string sizes: SettingsTypes.SizesType;
generalSettings: SettingsTypes.loadGeneralSettingsType
} }
interface props { interface Props {}
}
/** /**
* Component for Generalsettings tag on Settingspage * Component for Generalsettings tag on Settingspage
* handles general settings of mediacenter which concerns to all pages * handles general settings of mediacenter which concerns to all pages
*/ */
class GeneralSettings extends React.Component<props, state> { class GeneralSettings extends React.Component<Props, state> {
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
customapi: false,
apipath: '',
generalSettings: { generalSettings: {
DarkMode: true, DarkMode: true,
EpisodePath: '',
MediacenterName: '',
Password: '',
PasswordEnabled: false,
TMDBGrabbing: false,
VideoPath: ''
},
sizes: {
DBSize: 0, DBSize: 0,
DifferentTags: 0, DifferentTags: 0,
EpisodePath: "",
MediacenterName: "",
Password: "",
PasswordEnabled: false,
TagsAdded: 0, TagsAdded: 0,
TMDBGrabbing: false, VideoNr: 0
VideoNr: 0,
VideoPath: ""
} }
}; };
} }
@ -55,79 +53,73 @@ class GeneralSettings extends React.Component<props, state> {
return ( return (
<> <>
<div className={style.infoheader}> <div className={style.infoheader}>
<InfoHeaderItem backColor='lightblue' <InfoHeaderItem
text={this.state.generalSettings.VideoNr} backColor='lightblue'
subtext='Videos in Gravity' text={this.state.sizes.VideoNr}
icon={faArchive}/> subtext='Videos in Gravity'
<InfoHeaderItem backColor='yellow' icon={faArchive}
text={this.state.generalSettings.DBSize + ' MB'} />
subtext='Database size' <InfoHeaderItem
icon={faRulerVertical}/> backColor='yellow'
<InfoHeaderItem backColor='green' text={this.state.sizes.DBSize + ' MB'}
text={this.state.generalSettings.DifferentTags} subtext='Database size'
subtext='different Tags' icon={faRulerVertical}
icon={faAddressCard}/> />
<InfoHeaderItem backColor='orange' <InfoHeaderItem
text={this.state.generalSettings.TagsAdded} backColor='green'
subtext='tags added' text={this.state.sizes.DifferentTags}
icon={faBalanceScaleLeft}/> subtext='different Tags'
icon={faAddressCard}
/>
<InfoHeaderItem
backColor='orange'
text={this.state.sizes.TagsAdded}
subtext='tags added'
icon={faBalanceScaleLeft}
/>
</div> </div>
<div className={style.GeneralForm + ' ' + themeStyle.subtextcolor}> <div className={style.GeneralForm + ' ' + themeStyle.subtextcolor}>
<Form data-testid='mainformsettings' onSubmit={(e): void => { <Form
e.preventDefault(); data-testid='mainformsettings'
this.saveSettings(); onSubmit={(e): void => {
}}> e.preventDefault();
this.saveSettings();
}}>
<Form.Row> <Form.Row>
<Form.Group as={Col} data-testid='videpathform'> <Form.Group as={Col} data-testid='videpathform'>
<Form.Label>Video Path</Form.Label> <Form.Label>Video Path</Form.Label>
<Form.Control type='text' placeholder='/var/www/html/video' <Form.Control
value={this.state.generalSettings.VideoPath} type='text'
onChange={(ee): void => this.setState({ placeholder='/var/www/html/video'
generalSettings: { value={this.state.generalSettings.VideoPath}
...this.state.generalSettings, onChange={(ee): void =>
VideoPath: ee.target.value this.setState({
} generalSettings: {
})}/> ...this.state.generalSettings,
VideoPath: ee.target.value
}
})
}
/>
</Form.Group> </Form.Group>
<Form.Group as={Col} data-testid='tvshowpath'> <Form.Group as={Col} data-testid='tvshowpath'>
<Form.Label>TV Show Path</Form.Label> <Form.Label>TV Show Path</Form.Label>
<Form.Control type='text' placeholder='/var/www/html/tvshow' <Form.Control
value={this.state.generalSettings.EpisodePath} type='text'
onChange={(e): void => this.setState({ placeholder='/var/www/html/tvshow'
generalSettings: { value={this.state.generalSettings.EpisodePath}
...this.state.generalSettings, onChange={(e): void =>
EpisodePath: e.target.value this.setState({
} generalSettings: {
})}/> ...this.state.generalSettings,
EpisodePath: e.target.value
}
})
}
/>
</Form.Group> </Form.Group>
</Form.Row> </Form.Row>
<Form.Check
type='switch'
id='custom-switch-api'
label='Use custom API url'
checked={this.state.customapi}
onChange={(): void => {
if (this.state.customapi) {
setCustomBackendDomain('');
}
this.setState({customapi: !this.state.customapi});
}}
/>
{this.state.customapi ?
<Form.Group className={style.customapiform} data-testid='apipath'>
<Form.Label>API Backend url</Form.Label>
<Form.Control type='text' placeholder='https://127.0.0.1'
value={this.state.apipath}
onChange={(e): void => {
this.setState({apipath: e.target.value});
setCustomBackendDomain(e.target.value);
}}/>
</Form.Group> : null}
<Form.Check <Form.Check
type='switch' type='switch'
id='custom-switch' id='custom-switch'
@ -144,19 +136,24 @@ class GeneralSettings extends React.Component<props, state> {
}} }}
/> />
{this.state.generalSettings.PasswordEnabled ? {this.state.generalSettings.PasswordEnabled ? (
<Form.Group data-testid='passwordfield'> <Form.Group data-testid='passwordfield'>
<Form.Label>Password</Form.Label> <Form.Label>Password</Form.Label>
<Form.Control type='password' placeholder='**********' <Form.Control
value={this.state.generalSettings.Password} type='password'
onChange={(e): void => this.setState({ placeholder='**********'
generalSettings: { value={this.state.generalSettings.Password}
...this.state.generalSettings, onChange={(e): void =>
Password: e.target.value this.setState({
} generalSettings: {
})}/> ...this.state.generalSettings,
</Form.Group> : null Password: e.target.value
} }
})
}
/>
</Form.Group>
) : null}
<Form.Check <Form.Check
type='switch' type='switch'
@ -182,21 +179,24 @@ class GeneralSettings extends React.Component<props, state> {
checked={GlobalInfos.isDarkTheme()} checked={GlobalInfos.isDarkTheme()}
onChange={(): void => { onChange={(): void => {
GlobalInfos.enableDarkTheme(!GlobalInfos.isDarkTheme()); GlobalInfos.enableDarkTheme(!GlobalInfos.isDarkTheme());
this.forceUpdate();
// todo initiate rerender
}} }}
/> />
<Form.Group className={style.mediacenternameform} data-testid='nameform'> <Form.Group className={style.mediacenternameform} data-testid='nameform'>
<Form.Label>The name of the Mediacenter</Form.Label> <Form.Label>The name of the Mediacenter</Form.Label>
<Form.Control type='text' placeholder='Mediacentername' <Form.Control
value={this.state.generalSettings.MediacenterName} type='text'
onChange={(e): void => this.setState({ placeholder='Mediacentername'
generalSettings: { value={this.state.generalSettings.MediacenterName}
...this.state.generalSettings, onChange={(e): void =>
MediacenterName: e.target.value this.setState({
} generalSettings: {
})}/> ...this.state.generalSettings,
MediacenterName: e.target.value
}
})
}
/>
</Form.Group> </Form.Group>
<Button variant='primary' type='submit'> <Button variant='primary' type='submit'>
@ -204,9 +204,7 @@ class GeneralSettings extends React.Component<props, state> {
</Button> </Button>
</Form> </Form>
</div> </div>
<div className={style.footer}> <div className={style.footer}>Version: {version}</div>
Version: {version}
</div>
</> </>
); );
} }
@ -215,8 +213,16 @@ class GeneralSettings extends React.Component<props, state> {
* inital load of already specified settings from backend * inital load of already specified settings from backend
*/ */
loadSettings(): void { loadSettings(): void {
callAPI(APINode.Settings, {action: 'loadGeneralSettings'}, (result: SettingsTypes.loadGeneralSettingsType) => { interface SettingsResponseType {
this.setState({generalSettings: result}); Settings: SettingsTypes.SettingsType;
Sizes: SettingsTypes.SizesType;
}
callAPI(APINode.Settings, {action: 'loadGeneralSettings'}, (result: SettingsResponseType) => {
this.setState({
generalSettings: result.Settings,
sizes: result.Sizes
});
}); });
} }
@ -225,23 +231,28 @@ class GeneralSettings extends React.Component<props, state> {
*/ */
saveSettings(): void { saveSettings(): void {
let settings = this.state.generalSettings; let settings = this.state.generalSettings;
if(!this.state.generalSettings.PasswordEnabled){ if (!this.state.generalSettings.PasswordEnabled) {
settings.Password = '-1'; settings.Password = '-1';
} }
settings.DarkMode = GlobalInfos.isDarkTheme() settings.DarkMode = GlobalInfos.isDarkTheme();
callAPI(APINode.Settings, { console.log(settings);
action: 'saveGeneralSettings', callAPI(
Settings: settings APINode.Settings,
}, (result: GeneralSuccess) => { {
if (result.result) { action: 'saveGeneralSettings',
console.log('successfully saved settings'); ...settings
// todo 2020-07-10: popup success },
} else { (result: GeneralSuccess) => {
console.log('failed to save settings'); if (result.result) {
// todo 2020-07-10: popup error console.log('successfully saved settings');
// todo 2020-07-10: popup success
} else {
console.log('failed to save settings');
// todo 2020-07-10: popup error
}
} }
}); );
} }
} }

View File

@ -1,7 +1,7 @@
import {shallow} from 'enzyme'; import {shallow} from 'enzyme';
import React from 'react'; import React from 'react';
import MovieSettings from './MovieSettings'; import MovieSettings from './MovieSettings';
import {callAPI} from "../../utils/Api"; import {callAPI} from '../../utils/Api';
describe('<MovieSettings/>', function () { describe('<MovieSettings/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -23,106 +23,55 @@ describe('<MovieSettings/>', function () {
}); });
it('test simulate reindex', function () { it('test simulate reindex', function () {
global.fetch = global.prepareFetchApi({success: true}); callAPIMock({success: true})
const wrapper = shallow(<MovieSettings/>); const wrapper = shallow(<MovieSettings/>);
wrapper.find('button').findWhere(e => e.text() === 'Reindex Movie' && e.type() === 'button').simulate('click'); wrapper.find('button').findWhere(e => e.text() === 'Reindex Movie' && e.type() === 'button').simulate('click');
// initial send of reindex request to server
expect(global.fetch).toBeCalledTimes(1);
});
it('test failing reindex start', done => {
global.fetch = global.prepareFetchApi({success: false});
const wrapper = shallow(<MovieSettings/>);
wrapper.find('button').findWhere(e => e.text() === 'Reindex Movie' && e.type() === 'button').simulate('click');
// initial send of reindex request to server
expect(global.fetch).toBeCalledTimes(1);
process.nextTick(() => {
// reindex already running --> so disable startbdn
expect(wrapper.state()).toMatchObject({startbtnDisabled: true});
global.fetch.mockClear();
done();
});
});
it('content available received and in state', () => {
const wrapper = shallow(<MovieSettings/>);
callAPIMock({
ContentAvailable: true,
Messages: ['firstline', 'secondline']
})
wrapper.instance().updateStatus();
expect(wrapper.state()).toMatchObject({
text: [
'firstline',
'secondline'
]
});
});
it('test reindex with no content available', () => {
callAPIMock({
Messages: [],
ContentAvailable: false
})
global.clearInterval = jest.fn();
const wrapper = shallow(<MovieSettings/>);
wrapper.instance().updateStatus();
// expect the refresh interval to be cleared
expect(global.clearInterval).toBeCalledTimes(1);
// expect startbtn to be reenabled
expect(wrapper.state()).toMatchObject({startbtnDisabled: false});
});
it('test simulate gravity cleanup', () => {
// global.fetch = global.prepareFetchApi('mmi');
callAPIMock({})
const wrapper = shallow(<MovieSettings/>);
wrapper.instance().setState = jest.fn();
wrapper.find('button').findWhere(e => e.text() === 'Cleanup Gravity' && e.type() === 'button').simulate('click');
// initial send of reindex request to server // initial send of reindex request to server
expect(callAPI).toBeCalledTimes(1); expect(callAPI).toBeCalledTimes(1);
expect(wrapper.instance().setState).toBeCalledTimes(1);
}); });
it('expect insertion before existing ones', function () { it('test simulate tvshow reindex', function () {
callAPIMock({success: true})
const wrapper = shallow(<MovieSettings/>); const wrapper = shallow(<MovieSettings/>);
callAPIMock({ wrapper.find('button').findWhere(e => e.text() === 'TVShow Reindex' && e.type() === 'button').simulate('click');
ContentAvailable: true,
Messages: ['test']
})
wrapper.instance().updateStatus(); // initial send of reindex request to server
expect(callAPI).toBeCalledTimes(1);
});
expect(wrapper.state()).toMatchObject({ it('test handlemessage ', function () {
text: ['test'] const wrapper = shallow(<MovieSettings/>);
}); const func = jest.fn((str) => {})
wrapper.instance().appendLog = func
wrapper.instance().handleMessage('{"Action":"message", "Message":"testmsg"}')
// expect an untouched state if we try to add an empty string... expect(func).toHaveBeenCalledTimes(1);
callAPIMock({ expect(func).toHaveBeenLastCalledWith('testmsg')
ContentAvailable: true,
Messages: ['']
})
wrapper.instance().updateStatus(); wrapper.setState({startbtnDisabled: false});
expect(wrapper.state()).toMatchObject({ // expect button to get disabled!
text: ['', 'test'] wrapper.instance().handleMessage('{"Action":"reindexAction", "Event":"start"}');
}); expect(wrapper.state().startbtnDisabled).toBeTruthy()
// expect button to get enabled
wrapper.instance().handleMessage('{"Action":"reindexAction", "Event":"stop"}');
expect(wrapper.state().startbtnDisabled).not.toBeTruthy()
});
it('test appendlog', function () {
const wrapper = shallow(<MovieSettings/>);
wrapper.instance().appendLog("testmsg");
expect(wrapper.state().text).toHaveLength(1)
expect(wrapper.state().text[0]).toBe('testmsg')
wrapper.instance().appendLog("testmsg2");
expect(wrapper.state().text).toHaveLength(2)
expect(wrapper.state().text[0]).toBe('testmsg2')
expect(wrapper.state().text[1]).toBe('testmsg')
}); });
}); });

View File

@ -2,23 +2,32 @@ import React from 'react';
import style from './MovieSettings.module.css'; import style from './MovieSettings.module.css';
import {APINode, callAPI} from '../../utils/Api'; import {APINode, callAPI} from '../../utils/Api';
import {GeneralSuccess} from '../../types/GeneralTypes'; import {GeneralSuccess} from '../../types/GeneralTypes';
import {SettingsTypes} from '../../types/ApiTypes';
interface state { interface state {
text: string[] text: string[];
startbtnDisabled: boolean startbtnDisabled: boolean;
} }
interface props {} interface Props {}
interface MessageBase {
Action: string;
}
interface TextMessage extends MessageBase {
Message: string;
}
interface ReindexEvent extends MessageBase {
Event: string;
}
/** /**
* Component for MovieSettings on Settingspage * Component for MovieSettings on Settingspage
* handles settings concerning to movies in general * handles settings concerning to movies in general
*/ */
class MovieSettings extends React.Component<props, state> { class MovieSettings extends React.Component<Props, state> {
myinterval: number = -1; constructor(props: Props) {
constructor(props: props) {
super(props); super(props);
this.state = { this.state = {
@ -28,27 +37,92 @@ class MovieSettings extends React.Component<props, state> {
} }
componentDidMount(): void { componentDidMount(): void {
this.myinterval = window.setInterval(this.updateStatus, 1000); // expectingMessage is set to true
this.dial();
} }
componentWillUnmount(): void { dial(): void {
if (this.myinterval !== -1) console.log('trying to connect...');
clearInterval(this.myinterval); // check which ws protocol we need
const wsProt = window.location.protocol === 'http:' ? 'ws' : 'wss';
const conn = new WebSocket(`${wsProt}://${window.location.host}/subscribe`);
conn.addEventListener('close', (ev) => {
this.appendLog(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`, true);
if (ev.code !== 1001) {
this.appendLog('Reconnecting in 1s', true);
setTimeout((): void => this.dial(), 1000);
}
});
conn.addEventListener('open', (_ev) => {
console.info('websocket connected');
});
// This is where we handle messages received.
conn.addEventListener('message', (ev) => {
if (typeof ev.data !== 'string') {
console.error('unexpected message type', typeof ev.data);
return;
}
this.handleMessage(ev.data);
});
}
handleMessage(message: string): void {
const obj: MessageBase = JSON.parse(message);
if (obj.Action === 'message') {
const msg = obj as TextMessage;
this.appendLog(msg.Message);
} else if (obj.Action === 'reindexAction') {
const msg = obj as ReindexEvent;
if (msg.Event === 'start') {
this.setState({startbtnDisabled: true});
} else if (msg.Event === 'stop') {
this.setState({startbtnDisabled: false});
}
} else {
console.error('unexpected response from server: ' + message);
}
}
// appendLog appends the passed text to messageLog.
appendLog(text: string, error?: boolean): void {
this.setState({
// insert a string for each line
text: [text, ...this.state.text]
});
if (error) {
console.log('heyy err');
}
} }
render(): JSX.Element { render(): JSX.Element {
return ( return (
<> <>
<button disabled={this.state.startbtnDisabled} <button
className='btn btn-success' disabled={this.state.startbtnDisabled}
onClick={(): void => {this.startReindex();}}>Reindex Movie className='btn btn-success'
onClick={(): void => {
this.startReindex();
}}>
Reindex Movie
</button> </button>
<button className='btn btn-warning' <button
onClick={(): void => {this.cleanupGravity();}}>Cleanup Gravity className='btn btn-warning'
onClick={(): void => {
this.startTVShowReindex();
}}>
TVShow Reindex
</button> </button>
<div className={style.indextextarea}>{this.state.text.map(m => ( <div className={style.indextextarea}>
<div key={m} className='textarea-element'>{m}</div> {this.state.text.map((m) => (
))}</div> <div key={m} className='textarea-element'>
{m}
</div>
))}
</div>
</> </>
); );
} }
@ -57,54 +131,34 @@ class MovieSettings extends React.Component<props, state> {
* starts the reindex process of the videos in the specified folder * starts the reindex process of the videos in the specified folder
*/ */
startReindex(): void { startReindex(): void {
this.setState({text: []});
// clear output text before start // clear output text before start
this.setState({text: [], startbtnDisabled: true});
callAPI(APINode.Settings, {action: 'startReindex'}, (result: GeneralSuccess): void => { callAPI(APINode.Settings, {action: 'startReindex'}, (result: GeneralSuccess): void => {
console.log(result);
if (result.result === 'success') { if (result.result === 'success') {
console.log('started successfully'); console.log('started successfully');
} else {
console.log('error, reindex already running');
this.setState({startbtnDisabled: true});
} }
}); });
if (this.myinterval !== -1) {
clearInterval(this.myinterval);
}
this.myinterval = window.setInterval(this.updateStatus, 1000);
} }
/**
* This interval function reloads the current status of reindexing from backend
*/
updateStatus = (): void => {
callAPI(APINode.Settings, {action: 'getStatusMessage'}, (result: SettingsTypes.getStatusMessageType) => {
this.setState({
// insert a string for each line
text: [...result.Messages, ...this.state.text]
});
// todo 2020-07-4: scroll to bottom of div here
if (!result.ContentAvailable) {
// clear refresh interval if no content available
clearInterval(this.myinterval);
this.setState({startbtnDisabled: false});
}
});
};
/** /**
* send request to cleanup db gravity * send request to cleanup db gravity
*/ */
cleanupGravity(): void { cleanupGravity(): void {
callAPI(APINode.Settings, {action: 'cleanupGravity'}, (result) => { callAPI(APINode.Settings, {action: 'cleanupGravity'}, () => {
this.setState({ this.setState({
text: ['successfully cleaned up gravity!'] text: ['successfully cleaned up gravity!']
}); });
}); });
} }
private startTVShowReindex(): void {
this.setState({text: []});
callAPI(APINode.Settings, {action: 'startTVShowReindex'}, (result: GeneralSuccess): void => {
if (result.result === 'success') {
console.log('started successfully');
}
});
}
} }
export default MovieSettings; export default MovieSettings;

View File

@ -22,23 +22,27 @@ class SettingsPage extends React.Component {
<NavLink to='/settings/movies'> <NavLink to='/settings/movies'>
<div className={style.SettingSidebarElement}>Movies</div> <div className={style.SettingSidebarElement}>Movies</div>
</NavLink> </NavLink>
<NavLink to='/settings/tv'> {GlobalInfos.isTVShowEnabled() ? (
<div className={style.SettingSidebarElement}>TV Shows</div> <NavLink to='/settings/tv'>
</NavLink> <div className={style.SettingSidebarElement}>TV Shows</div>
</NavLink>
) : null}
</div> </div>
<div className={style.SettingsContent}> <div className={style.SettingsContent}>
<Switch> <Switch>
<Route path="/settings/general"> <Route path='/settings/general'>
<GeneralSettings/> <GeneralSettings />
</Route> </Route>
<Route path="/settings/movies"> <Route path='/settings/movies'>
<MovieSettings/> <MovieSettings />
</Route> </Route>
<Route path="/settings/tv"> {GlobalInfos.isTVShowEnabled() ? (
<span/> <Route path='/settings/tv'>
</Route> <span />
<Route path="/settings"> </Route>
<Redirect to='/settings/general'/> ) : null}
<Route path='/settings'>
<Redirect to='/settings/general' />
</Route> </Route>
</Switch> </Switch>
</div> </div>

View File

@ -0,0 +1,43 @@
import {shallow} from 'enzyme';
import React from 'react';
import {EpisodePage, EpisodeTile} from './EpisodePage';
describe('<EpisodePage/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<EpisodePage history={{}} location={{}} match={{params: {id: 42}}}/>);
wrapper.unmount();
});
it('content showing when loaded', function () {
const wrapper = shallow(<EpisodePage history={{}} location={{}} match={{params: {id: 42}}}/>);
expect(wrapper.find('DynamicContentContainer')).toHaveLength(0)
wrapper.setState({loaded: true});
expect(wrapper.find('DynamicContentContainer')).toHaveLength(1)
});
});
describe('<EpisodeTile/>', () => {
it('renders without crashing', function () {
const wrapper = shallow(<EpisodeTile episode={{
ID: 0,
Name: 'testname',
Season: 0,
Episode: 0
}}/>);
wrapper.unmount();
});
it('renders text', function () {
const wrapper = shallow(<EpisodeTile episode={{
ID: 0,
Name: 'testname',
Season: 0,
Episode: 0
}}/>);
expect(wrapper.findWhere(e => e.text() === 'testname')).toHaveLength(1)
});
})

View File

@ -0,0 +1,84 @@
import * as React from 'react';
import {RouteComponentProps} from 'react-router';
import {withRouter} from 'react-router-dom';
import {APINode, callAPI} from '../../utils/Api';
import {Link} from 'react-router-dom';
import DynamicContentContainer from '../../elements/DynamicContentContainer/DynamicContentContainer';
import tileStyle from './EpisodeTile.module.css';
import GlobalInfos from '../../utils/GlobalInfos';
import {faPlay} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import SideBar, {SideBarItem, SideBarTitle} from '../../elements/SideBar/SideBar';
interface Props extends RouteComponentProps<{id: string}> {}
interface State {
loaded: boolean;
}
interface Episode {
ID: number;
Name: string;
Season: number;
Episode: number;
}
export class EpisodePage extends React.Component<Props, State> {
episodes: Episode[] = [];
state = {
loaded: false
};
componentDidMount(): void {
callAPI(APINode.TVShow, {action: 'getEpisodes', ShowID: parseInt(this.props.match.params.id, 10)}, (episodes: Episode[]) => {
this.episodes = episodes;
this.setState({loaded: true});
});
}
render(): JSX.Element {
if (!this.state.loaded) {
return <>loading...</>;
}
return (
<>
<PageTitle title='TV Shows' subtitle='' />
<SideBar>
<SideBarTitle>Infos:</SideBarTitle>
<Line />
<SideBarItem>
<b>{this.episodes.length}</b> Episodes Total!
</SideBarItem>
</SideBar>
<DynamicContentContainer
renderElement={(el): JSX.Element => <EpisodeTile key={el.ID} episode={el} />}
data={this.episodes}
initialLoadNr={-1}
/>
</>
);
}
}
export const EpisodeTile = (props: {episode: Episode}): JSX.Element => {
const themestyle = GlobalInfos.getThemeStyle();
return (
<Link to={'/tvplayer/' + props.episode.ID}>
<div className={tileStyle.tile + ' ' + themestyle.secbackground + ' ' + themestyle.textcolor}>
<FontAwesomeIcon
style={{
marginRight: '10px'
}}
icon={faPlay}
size='1x'
/>
Season: {props.episode.Season} Episode: {props.episode.Episode} {props.episode.Name}
</div>
</Link>
);
};
export default withRouter(EpisodePage);

View File

@ -0,0 +1,15 @@
.tile {
margin: 15px;
padding-top: 15px;
padding-bottom: 15px;
width: 50%;
padding-left: 15px;
}
.tile:hover {
opacity: 0.7;
}
.tile:hover svg {
color: dodgerblue;
}

View File

@ -0,0 +1,10 @@
import {shallow} from 'enzyme';
import {TVPlayer} from './TVPlayer';
import React from 'react';
describe('<TVPlayer/>', () => {
it('renders without crashing', function () {
const wrapper = shallow(<TVPlayer match={{params: {id: 42}}}/>);
wrapper.unmount();
});
})

View File

@ -0,0 +1,101 @@
import * as React from 'react';
import {RouteComponentProps} from 'react-router';
import {withRouter} from 'react-router-dom';
import PageTitle from '../../elements/PageTitle/PageTitle';
import style from '../Player/Player.module.css';
import {Plyr} from 'plyr-react';
import plyrstyle from 'plyr-react/dist/plyr.css';
import {DefaultPlyrOptions} from '../../types/GeneralTypes';
import {APINode, callAPI} from '../../utils/Api';
import GlobalInfos from '../../utils/GlobalInfos';
import PlyrJS from 'plyr';
interface Props extends RouteComponentProps<{id: string}> {}
interface State {
loaded: boolean;
}
interface EpisodeData {
Name: string;
Season: number;
Episode: number;
TVShowID: number;
Path: string;
}
export class TVPlayer extends React.Component<Props, State> {
state = {
loaded: false
};
data: EpisodeData | null = null;
componentDidMount(): void {
this.loadVideo();
}
loadVideo(): void {
callAPI(
APINode.TVShow,
{
action: 'loadEpisode',
ID: parseInt(this.props.match.params.id, 10)
},
(data: EpisodeData) => {
console.log(data);
this.data = data;
this.setState({loaded: true});
},
(_) => {
// if there is an load error redirect to home page
this.props.history.push('/');
}
);
}
assemblePlyrObject(): JSX.Element {
if (this.state.loaded && this.data !== null) {
const sources: PlyrJS.SourceInfo = {
type: 'video',
sources: [
{
src:
(process.env.REACT_APP_CUST_BACK_DOMAIN ? process.env.REACT_APP_CUST_BACK_DOMAIN : '') +
GlobalInfos.getTVShowPath() +
this.data.Path,
type: 'video/mp4',
size: 1080
}
],
poster: ''
};
return <Plyr style={plyrstyle} source={sources} options={DefaultPlyrOptions} />;
} else {
return <div>not loaded yet</div>;
}
}
render(): JSX.Element {
return (
<div id='videocontainer'>
<PageTitle title='Watch' subtitle='todo' />
<div className={style.videowrapper}>
{/* video component is added here */}
{this.assemblePlyrObject()}
</div>
<button className={style.closebutton} onClick={(): void => this.closebtn()}>
Close
</button>
</div>
);
}
private closebtn(): void {
this.props.history.goBack();
}
}
export default withRouter(TVPlayer);

View File

@ -0,0 +1,10 @@
import {shallow} from 'enzyme';
import {TVShowPage} from './TVShowPage';
import React from 'react';
describe('<TVShowPage/>', () => {
it('renders without crashing', function () {
const wrapper = shallow(<TVShowPage />);
wrapper.unmount();
});
})

View File

@ -0,0 +1,83 @@
import React from 'react';
import Preview from '../../elements/Preview/Preview';
import {APINode, callAPI, callAPIPlain} from '../../utils/Api';
import {TVShow} from '../../types/ApiTypes';
import DynamicContentContainer from '../../elements/DynamicContentContainer/DynamicContentContainer';
import {Route, Switch, useRouteMatch} from 'react-router-dom';
import EpisodePage from './EpisodePage';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import SideBar, {SideBarItem, SideBarTitle} from '../../elements/SideBar/SideBar';
interface State {
loading: boolean;
}
interface Props {}
export class TVShowPage extends React.Component<Props, State> {
state = {
loading: true
};
data: TVShow.TVshowType[] = [];
componentDidMount(): void {
callAPI(APINode.TVShow, {action: 'getTVShows'}, (resp: TVShow.TVshowType[]) => {
this.data = resp;
this.setState({loading: false});
});
}
render(): JSX.Element {
return (
<>
<PageTitle title='TV Shows' subtitle='' />
<SideBar>
<SideBarTitle>Infos:</SideBarTitle>
<Line />
<SideBarItem>
<b>{this.data.length}</b> TV-Shows Total!
</SideBarItem>
</SideBar>
<div>
<DynamicContentContainer
renderElement={(elem): JSX.Element => (
<Preview
key={elem.Id}
name={elem.Name}
picLoader={(callback: (pic: string) => void): void => {
callAPIPlain(
APINode.TVShow,
{
action: 'readThumbnail',
Id: elem.Id
},
(result) => callback(result)
);
}}
linkPath={'/tvshows/' + elem.Id}
/>
)}
data={this.state.loading ? [] : this.data}
initialLoadNr={20}
/>
</div>
</>
);
}
}
export default function (): JSX.Element {
let match = useRouteMatch();
return (
<Switch>
<Route path={`${match.path}/:id`}>
<EpisodePage />
</Route>
<Route path={match.path}>
<TVShowPage />
</Route>
</Switch>
);
}

View File

@ -6,7 +6,8 @@ import '@testing-library/jest-dom/extend-expect';
import {configure} from 'enzyme'; import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16'; import Adapter from 'enzyme-adapter-react-16';
import GlobalInfos from './utils/GlobalInfos'; import {CookieTokenStore} from "./utils/TokenStore/CookieTokenStore";
import {token} from "./utils/TokenHandler";
configure({adapter: new Adapter()}); configure({adapter: new Adapter()});
@ -19,7 +20,8 @@ global.prepareFetchApi = (response) => {
const mockJsonPromise = Promise.resolve(response); const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({ const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise, json: () => mockJsonPromise,
text: () => mockJsonPromise text: () => mockJsonPromise,
status: 200
}); });
return (jest.fn().mockImplementation(() => mockFetchPromise)); return (jest.fn().mockImplementation(() => mockFetchPromise));
}; };
@ -33,28 +35,17 @@ global.prepareFailingFetchApi = () => {
return (jest.fn().mockImplementation(() => mockFetchPromise)); return (jest.fn().mockImplementation(() => mockFetchPromise));
}; };
/**
* prepares a viewbinding instance
* @param func a mock function to be called
*/
global.prepareViewBinding = (func) => {
GlobalInfos.getViewBinding = () => {
return {
changeRootElement: func,
returnToLastElement: func
};
};
};
global.callAPIMock = (resonse) => { global.callAPIMock = (resonse) => {
const helpers = require('./utils/Api'); const helpers = require('./utils/Api');
helpers.callAPI = jest.fn().mockImplementation((_, __, func1) => {func1(resonse);}); helpers.callAPI = jest.fn().mockImplementation((_, __, func1) => {func1(resonse);});
helpers.callApiUnsafe = jest.fn().mockImplementation((_, __, func1) => {func1(resonse);});
}; };
// code to run before each test // code to run before each test
global.beforeEach(() => { global.beforeEach(() => {
// empty fetch response implementation for each test // empty fetch response implementation for each test
global.fetch = prepareFetchApi({}); global.fetch = prepareFetchApi({});
token.init(new CookieTokenStore());
// todo with callAPIMock // todo with callAPIMock
}); });
@ -63,3 +54,12 @@ global.afterEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
global.mockKeyPress = () => {
let events = [];
document.addEventListener = jest.fn((event, cb) => {
events[event] = cb;
});
return events;
}

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