84 Commits

Author SHA1 Message Date
cfcfde6e42 error handling if a requested tvshow item doesn't exist 2021-05-22 13:14:03 +02:00
da07dc04bd load videopath from backend
correctly redirect to login page if login fails
2021-05-09 19:38:59 +02:00
f7705aef98 fix failing tests 2021-05-08 16:38:01 +02:00
b13a10f37b outsource Token namespace in seperate file to release dependency to GlobalInfos 2021-05-08 15:19:13 +02:00
0797632c44 abstract tokenstore to support different storage methods of tokenstore 2021-05-07 17:31:35 +02:00
ab02a49b8f fix wrong ws protocol on https 2021-05-01 17:38:12 +02:00
7f89392b30 Merge branch 'tvshowspage' into 'master'
new tvshowspage

Closes #37, #23, and #17

See merge request lukas/openmediacenter!45
2021-05-01 14:38:58 +00:00
5295f0b182 add go unit test
send status messages when tvshow reindexing
2021-05-01 16:25:32 +02:00
d6fd2cbd9c add some MovieSettings unit tests 2021-05-01 15:57:58 +02:00
5fac3a0780 add some unit tests 2021-05-01 15:18:38 +02:00
4ae9f27902 add some unit tests
pretify episodepage
2021-04-28 17:31:38 +02:00
8c44a931de some reformattings
remove test code from main
2021-04-23 21:37:57 +02:00
d952538f0a implement thubnail loading from tmdb
fix lots of errors if ' char occurs in path strings
correct reverse proxy for websocket
2021-04-23 21:23:51 +02:00
f72a3e5fb4 add a new TVPlayer component,
add tv episode path to db
2021-04-22 20:31:36 +02:00
c30c193ce0 fix failing tests / remove obsolente ones
add basic structure of episode page
2021-04-20 21:17:34 +02:00
6d41b86120 new logo,
partly implement tvshow reindex
* add tvshow to db
* add episodes to db
new route switcher for tvshows
2021-04-19 20:31:56 +02:00
5656428de7 implement websocket to send reindex messages 2021-04-18 21:16:38 +02:00
32c7e8a01b rename file 2021-04-16 22:48:41 +02:00
4539147208 add tvshow syntax to db
basic tvshow api request to show available tvshows
limit randompage videos to 3
improve settings object to remove one useless copy
2021-04-16 22:44:56 +02:00
fdcecb0a75 * no unauthorized init
* more dynamic Preview element
2021-04-16 21:12:56 +02:00
57a7a9a827 abstract dynamic video tile load within new dynamicloader class to allow to load other elements dynamically. 2021-04-16 18:20:39 +02:00
4465840657 fix non support of <() syntax within sh shell 2021-04-09 15:03:21 +02:00
7aeb14c120 use new ssh docker image 2021-04-09 14:28:08 +02:00
dfd92b1730 new tvshowspage 2021-04-09 12:59:28 +02:00
d9875a11d5 Merge branch 'include_webpage_in_binary' into 'master'
Optional include of webpage into golang binary

Closes #40

See merge request lukas/openmediacenter!44
2021-04-02 17:04:16 +00:00
d9d6907745 delete useless custombackend popup
correct load of subpage when standalone binary
ability to set external videourl when using standalone binary
2021-04-02 17:04:15 +00:00
c0405cd79a Release version v0.1.3 2021-03-22 20:24:10 +01:00
533e319ea0 Merge branch 'error_wrong_pwd_rm_map_files' into 'master'
Error message when password wrong

Closes #66 and #46

See merge request lukas/openmediacenter!42
2021-03-22 19:07:32 +00:00
a2ac188423 remove .map files from production build
show error message if wrong password was entered.
2021-03-22 19:07:32 +00:00
fe349b1fd2 use creat if not exists syntax also on index entries 2021-03-22 18:39:34 +01:00
973a469410 restart service on debian package update instead of only starting it 2021-03-22 17:55:03 +01:00
da04c30148 Merge branch 'reindex_performance_improvement' into 'master'
Reindex Performance Imprvement

Closes #42

See merge request lukas/openmediacenter!43
2021-03-22 16:37:08 +00:00
137d7ed49d use single db query to check whether a url exists already in db or not 2021-03-22 17:22:34 +01:00
f2ec5b644d no implicit any - force return
add some comments
2021-03-19 19:10:13 +01:00
5dbbd34d3a add environment variable to set custom movie backend url
edit readme
handle enter event on AuthenticationPage
2021-03-16 20:44:32 +01:00
e412f699f7 integrate automatic token refresh if current one is invalid
--> load login page if token can't be refreshed
2021-03-16 20:13:12 +01:00
a51176fb93 fix filenames with special characters aren't urlencoded
year parsing does not remove parentheses
2021-03-14 19:01:40 +01:00
b2b0e3a917 fix lukas/openmediacenter#63 2021-03-14 17:29:33 +01:00
e4990bd24e fix not deletable video if a actor is defined
fix wrongly displaying icon if no one exists --> new icon
2021-03-14 16:56:26 +01:00
283d3dc59b Merge branch 'eslint' into 'master'
Eslint

See merge request lukas/openmediacenter!41
2021-03-14 14:51:53 +00:00
1fc67365f0 use eslint to lint project
drop code quality job
2021-03-14 14:51:53 +00:00
ba2704b285 Merge branch 'passwordpage' into 'master'
PasswordPage if enabled in settings

See merge request lukas/openmediacenter!40
2021-03-14 12:49:25 +00:00
059b0af6e7 fix incorrect gui refresh if theme is changed
implement custom clientstore
add new Password page
if password is set force entering password to successfully receive the token
add a new unsafe api call for init call only
2021-03-14 12:49:24 +00:00
be40475615 use correct regex for the reverse proxy 2021-03-09 19:31:56 +01:00
b625a4d958 Merge branch 'Oauth_auth' into 'master'
OAuth2

See merge request lukas/openmediacenter!37
2021-03-09 12:56:54 +00:00
c24c2ac2d8 add secure requests with tokens.
generate new token on every new page load
2021-03-09 12:56:53 +00:00
162b4efd0e Merge branch 'addtag_filterablesearch' into 'master'
Filterbutton on addtag pupup

See merge request lukas/openmediacenter!38
2021-03-08 14:11:26 +00:00
db0edf7a80 outsource filterbutton to new file
make addtag popup filterable
2021-03-08 14:11:26 +00:00
488354bc28 fix lukas/openmediacenter#64 2021-03-07 19:49:40 +01:00
f42f2d4915 update @type dependencies 2021-03-05 21:10:10 +01:00
8d97ab85a9 Merge branch 'golang_backend' into 'master'
Fully use GO for backend

See merge request lukas/openmediacenter!35
2021-02-23 16:01:30 +00:00
f2b5fb6587 implement full load of videos and startdata
modify api where necessary
2021-02-23 16:01:29 +00:00
2967aee16d Merge branch 'keypropwarnings' into 'master'
fix some required key warnings

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

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

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

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

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

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

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

Closes #50

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

See merge request lukas/openmediacenter!26
2020-11-27 22:43:12 +00:00
2d8bb06852 update all but react-scripts (wrong execution of unit tests so they are failing)
fix failing tests because of missing implementation of mount() in enzyme for react 17
2020-11-27 22:43:12 +00:00
2ae00f8af0 release version 0.1.2 2020-11-20 22:00:09 +01:00
169 changed files with 20464 additions and 3558 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,62 +1,92 @@
image: node:14
stages:
- prepare
- build
- build_frontend
- build_backend
- test
- packaging
- deploy
cache:
paths:
- node_modules/
include:
- template: Code-Quality.gitlab-ci.yml
variables:
SAST_DISABLE_DIND: "true"
prepare:
stage: prepare
Minimize_Frontend:
stage: build_frontend
before_script:
- yarn install --cache-folder .yarn
script:
- npm install --progress=false
build:
stage: build
script:
- npm run build
- yarn run build
- rm build/*/*/*.map
artifacts:
expire_in: 7 days
expire_in: 2 days
paths:
- build/
needs: ["prepare"]
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .yarn/
- node_modules/
test:
stage: test
Build_Backend:
image: golang:latest
stage: build_backend
script:
- CI=true npm run test
- cd apiGo
- go build -v -o openmediacenter
- 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:
expire_in: 2 days
paths:
- "./apiGo/openmediacenter*"
Frontend_Tests:
stage: test
before_script:
- yarn install --cache-folder .yarn
script:
- yarn run test
needs: []
artifacts:
reports:
junit:
- ./junit.xml
needs: ["prepare"]
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .yarn/
- node_modules/
code_quality:
tags:
- dind
coverage:
Backend_Tests:
image: golang:latest
stage: test
script:
- CI=true npm run coverage
- cd apiGo
- go get -u github.com/jstemmer/go-junit-report
- go test -v ./... 2>&1 | go-junit-report -set-exit-code > report.xml
needs: []
artifacts:
when: always
reports:
junit: ./apiGo/report.xml
lint:
stage: test
before_script:
- yarn install --cache-folder .yarn
script:
- yarn run lint
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .yarn/
- ./node_modules/
artifacts:
reports:
cobertura:
- ./coverage/cobertura-coverage.xml
needs: ["prepare"]
codequality: gl-codequality.json
needs: []
package_debian:
Debian_Server:
stage: packaging
image: debian
script:
@ -64,29 +94,34 @@ package_debian:
- cd deb
- mkdir -p "./OpenMediaCenter/var/www/openmediacenter/videos/"
- mkdir -p "./OpenMediaCenter/tmp/"
- mkdir -p "./OpenMediaCenter/usr/bin/"
- cp -r ../build/* ./OpenMediaCenter/var/www/openmediacenter/
- cp -r ../api ./OpenMediaCenter/var/www/openmediacenter/
- cp ../apiGo/openmediacenter ./OpenMediaCenter/usr/bin/
- cp ../database.sql ./OpenMediaCenter/tmp/openmediacenter.sql
- 'echo "Version: ${vers}" >> ./OpenMediaCenter/DEBIAN/control'
- chmod -R 0775 *
- dpkg-deb --build OpenMediaCenter
- mv OpenMediaCenter.deb OpenMediaCenter-${vers}_amd64.deb
artifacts:
expire_in: 7 days
paths:
- deb/OpenMediaCenter-*.deb
needs: ["build"]
deploy_test1:
stage: deploy
image: luki42/alpineopenssh:latest
needs:
- test
- package_debian
- Minimize_Frontend
- Build_Backend
Test_Server:
stage: deploy
image: luki42/ssh:latest
needs:
- Frontend_Tests
- Backend_Tests
- Debian_Server
only:
- master
script:
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY")
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- scp deb/OpenMediaCenter-*.deb root@192.168.0.42:/tmp/

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

@ -8,7 +8,7 @@ Feel free to contribute or open an issue here: https://gitlab.heili.eu/lukas/ope
## What is this?
Open Media Center is an open source solution for a mediacenter in your home network.
Transform your webserver into a mediaserver.
It's based on Reactjs and PHP is used for backend.
It's based on Reactjs and golang is used for backend.
It is optimized for general videos as well as for movies.
For grabbing movie data TMDB is used.
With the help of tags you can organize your video gravity.
@ -22,24 +22,30 @@ and in dark mode:
![](https://i.ibb.co/xzhdsbJ/Screenshot-20200812-172926.png)
## Installation
First of all clone the repository.
`git clone https://gitlab.heili.eu/lukas/openmediacenter.git`
Then build a production build via npm.
`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.
Download the latest release .deb file from the Releases page and install it via `apt install ./OpenMediaCenter-0.1.x_amd64.deb`
Now you could optionally check if the service is up and running: `systemctl status OpenMediaCenter`
## 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.
## 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
Any contribution is appreciated.
Feel free to contact me (lukas.heiligenbrunner@gmail.com), open an issue or request a new feature.

View File

@ -1,18 +0,0 @@
<?php
require_once __DIR__ . '/src/Database.php';
require_once __DIR__ . '/src/TMDBMovie.php';
require_once __DIR__ . '/src/SSettings.php';
require_once __DIR__ . '/src/VideoParser.php';
// allow UTF8 characters
setlocale(LC_ALL, 'en_US.UTF-8');
set_time_limit(3600);
$vp = new VideoParser();
$vp->writeLog("starting extraction!!\n");
$sett = new SSettings();
// load video path from settings
$scandir = __DIR__ . "/../" . $sett->getVideoPath();
$vp->extractVideos($scandir);

View File

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

View File

@ -1,57 +0,0 @@
<?php
/**
* Class Database
*
* Class with all neccessary stuff for the Database connections.
*/
class Database {
private static $instance = null;
private $conn;
private $servername = "127.0.0.1";
private $username = "mediacenteruser";
private $password = "mediapassword";
private $dbname = "mediacenter";
// The db connection is established in the private constructor.
private function __construct() {
// Create connection
$this->conn = new mysqli($this->servername, $this->username, $this->password, $this->dbname);
if ($this->conn->connect_errno) {
echo "connecton failed... nr: " . $this->conn->connect_errno . " -- " . $this->conn->connect_error;
}
}
/**
* get an instance of this database class
* (only possible way to retrieve an object)
*
* @return Database dbobject
*/
public static function getInstance() {
if (!self::$instance) {
self::$instance = new Database();
}
return self::$instance;
}
/**
* get a connection instance of the database
*
* @return mysqli mysqli instance
*/
public function getConnection() {
return $this->conn;
}
/**
* get name of current active database
* @return string name
*/
public function getDatabaseName() {
return $this->dbname;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,160 +0,0 @@
<?php
require_once 'RequestBase.php';
require_once __DIR__ . '/../VideoParser.php';
/**
* Class Settings
* Backend for the Settings page
*/
class Settings extends RequestBase {
function initHandlers() {
$this->getFromDB();
$this->saveToDB();
$this->reIndexHandling();
}
/**
* handle settings stuff to load from db
*/
private function getFromDB() {
/**
* load currently set settings form db for init of settings page
*/
$this->addActionHandler("loadGeneralSettings", function () {
// query settings and infotile values
$query = "
SELECT (
SELECT COUNT(*)
FROM videos
) AS videonr,
(
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS Size
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '" . Database::getInstance()->getDatabaseName() . "'
GROUP BY table_schema
) AS dbsize,
(
SELECT COUNT(*)
FROM tags
) AS difftagnr,
(
SELECT COUNT(*)
FROM video_tags
) AS tagsadded,
settings.*
FROM settings
LIMIT 1
";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
// booleans need to be set manually
$r['passwordEnabled'] = $r['password'] != "-1";
$r['TMDB_grabbing'] = ($r['TMDB_grabbing'] != '0');
echo json_encode($r);
});
/**
* load initial data for home page load to check if pwd is set
*/
$this->addActionHandler("loadInitialData", function () {
$query = "SELECT * from settings";
$result = $this->conn->query($query);
$r = mysqli_fetch_assoc($result);
$r['passwordEnabled'] = $r['password'] != "-1";
unset($r['password']);
$r['DarkMode'] = (bool)($r['DarkMode'] != '0');
$this->commitMessage(json_encode($r));
});
}
/**
* handle setting stuff to save to db
*/
private function saveToDB() {
/**
* save changed settings to db
*/
$this->addActionHandler("saveGeneralSettings", function () {
$mediacentername = $_POST['mediacentername'];
$password = $_POST['password'];
$videopath = $_POST['videopath'];
$tvshowpath = $_POST['tvshowpath'];
$tmdbsupport = $_POST['tmdbsupport'];
$darkmodeenabled = $_POST['darkmodeenabled'];
$query = "UPDATE settings SET
video_path='$videopath',
episode_path='$tvshowpath',
password='$password',
mediacenter_name='$mediacentername',
TMDB_grabbing=$tmdbsupport,
DarkMode=$darkmodeenabled
WHERE 1";
if ($this->conn->query($query) === true) {
$this->commitMessage('{"success": true}');
} else {
$this->commitMessage('{"success": true}');
}
});
}
/**
* methods for handling reindexing and cleanup of db gravity
*/
private function reIndexHandling() {
$this->addActionHandler("startReindex", function () {
$indexrunning = false;
if (file_exists("/tmp/output.log")) {
$out = file_get_contents("/tmp/output.log");
if (substr($out, -strlen("-42")) == "-42") {
unlink("/tmp/output.log");
} else {
$indexrunning = true;
}
}
if (!$indexrunning) {
// start extraction of video previews in background
$cmd = 'php extractvideopreviews.php';
exec(sprintf("%s > %s 2>&1 & echo $! >> %s", $cmd, '/dev/zero', '/tmp/openmediacenterpid'));
$this->commitMessage('{"success": true}');
} else {
$this->commitMessage('{"success": false}');
}
});
$this->addActionHandler("cleanupGravity", function () {
$vp = new VideoParser();
$vp->cleanUpGravity();
});
$this->addActionHandler("getStatusMessage", function () {
$return = new stdClass();
if (file_exists("/tmp/output.log")) {
$out = file_get_contents("/tmp/output.log");
// clear log file
file_put_contents("/tmp/output.log", "");
$return->message = $out;
$return->contentAvailable = true;
if (substr($out, -strlen("-42")) == "-42") {
unlink("/tmp/output.log");
}
} else {
$return->contentAvailable = false;
}
$this->commitMessage(json_encode($return));
});
}
}

View File

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

View File

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

View File

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

View File

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

72
apiGo/api/Actors.go Normal file
View File

@ -0,0 +1,72 @@
package api
import (
"fmt"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database"
)
func AddActorsHandlers() {
saveActorsToDB()
getActorsFromDB()
}
func getActorsFromDB() {
AddHandler("getAllActors", ActorNode, nil, func() []byte {
query := "SELECT actor_id, name, thumbnail FROM actors"
return jsonify(readActorsFromResultset(database.Query(query)))
})
var gaov struct {
MovieId int
}
AddHandler("getActorsOfVideo", ActorNode, &gaov, func() []byte {
query := fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=%d`, gaov.MovieId)
return jsonify(readActorsFromResultset(database.Query(query)))
})
var gai struct {
ActorId int
}
AddHandler("getActorInfo", ActorNode, &gai, func() []byte {
query := fmt.Sprintf(`SELECT movie_id, movie_name FROM actors_videos
JOIN videos v on v.movie_id = actors_videos.video_id
WHERE actors_videos.actor_id=%d`, gai.ActorId)
videos := readVideosFromResultset(database.Query(query))
query = fmt.Sprintf("SELECT actor_id, name, thumbnail FROM actors WHERE actor_id=%d", gai.ActorId)
actor := readActorsFromResultset(database.Query(query))[0]
var result = struct {
Videos []types.VideoUnloadedType
Info types.Actor
}{
Videos: videos,
Info: actor,
}
return jsonify(result)
})
}
func saveActorsToDB() {
var ca struct {
ActorName string
}
AddHandler("createActor", ActorNode, &ca, func() []byte {
query := "INSERT IGNORE INTO actors (name) VALUES (?)"
return database.SuccessQuery(query, ca.ActorName)
})
var aatv struct {
ActorId int
MovieId int
}
AddHandler("addActorToVideo", ActorNode, &aatv, func() []byte {
query := fmt.Sprintf("INSERT IGNORE INTO actors_videos (actor_id, video_id) VALUES (%d,%d)", aatv.ActorId, aatv.MovieId)
return database.SuccessQuery(query)
})
}

87
apiGo/api/ApiBase.go Normal file
View File

@ -0,0 +1,87 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"openmediacenter/apiGo/api/oauth"
)
const APIPREFIX = "/api"
const (
VideoNode = iota
TagNode = iota
SettingsNode = iota
ActorNode = iota
TVShowNode = iota
)
type actionStruct struct {
Action string
}
type Handler struct {
action string
handler func() []byte
arguments interface{}
apiNode int
}
var handlers []Handler
func AddHandler(action string, apiNode int, n interface{}, h func() []byte) {
// append new handler to the handlers
handlers = append(handlers, Handler{action, h, n, apiNode})
}
func ServerInit() {
http.Handle(APIPREFIX+"/video", oauth.ValidateToken(handlefunc, VideoNode))
http.Handle(APIPREFIX+"/tags", oauth.ValidateToken(handlefunc, TagNode))
http.Handle(APIPREFIX+"/settings", oauth.ValidateToken(handlefunc, SettingsNode))
http.Handle(APIPREFIX+"/actor", oauth.ValidateToken(handlefunc, ActorNode))
http.Handle(APIPREFIX+"/tvshow", oauth.ValidateToken(handlefunc, TVShowNode))
// initialize oauth service and add corresponding auth routes
oauth.InitOAuth()
}
func handleAPICall(action string, requestBody string, apiNode int) []byte {
for i := range handlers {
if handlers[i].action == action && handlers[i].apiNode == apiNode {
// call the handler and return
if handlers[i].arguments != 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
}
func handlefunc(rw http.ResponseWriter, req *http.Request, node int) {
// only allow post requests
if req.Method != "POST" {
return
}
buf := new(bytes.Buffer)
buf.ReadFrom(req.Body)
body := buf.String()
var t actionStruct
err := json.Unmarshal([]byte(body), &t)
if err != nil {
fmt.Println("failed to read action from request! :: " + body)
}
rw.Write(handleAPICall(t.Action, body, node))
}

66
apiGo/api/ApiBase_test.go Normal file
View File

@ -0,0 +1,66 @@
package api
import (
"testing"
)
func cleanUp() {
handlers = nil
}
func TestAddHandler(t *testing.T) {
cleanUp()
AddHandler("test", ActorNode, nil, func() []byte {
return nil
})
if len(handlers) != 1 {
t.Errorf("Handler insertion failed, got: %d handlers, want: %d.", len(handlers), 1)
}
}
func TestCallOfHandler(t *testing.T) {
cleanUp()
i := 0
AddHandler("test", ActorNode, nil, func() []byte {
i++
return nil
})
// simulate the call of the api
handleAPICall("test", "", ActorNode)
if i != 1 {
t.Errorf("Unexpected number of Lambda calls : %d/1", i)
}
}
func TestDecodingOfArguments(t *testing.T) {
cleanUp()
var myvar struct {
Test string
TestInt int
}
AddHandler("test", ActorNode, &myvar, func() []byte {
return nil
})
// simulate the call of the api
handleAPICall("test", `{"Test":"myString","TestInt":42}`, ActorNode)
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) {
cleanUp()
ret := handleAPICall("test", "", ActorNode)
if ret != nil {
t.Error("Expect nil return within unhandled api action")
}
}

87
apiGo/api/Helpers.go Normal file
View File

@ -0,0 +1,87 @@
package api
import (
"database/sql"
"encoding/json"
"fmt"
"openmediacenter/apiGo/api/types"
)
// MovieId - MovieName : pay attention to the order!
func readVideosFromResultset(rows *sql.Rows) []types.VideoUnloadedType {
result := []types.VideoUnloadedType{}
for rows.Next() {
var vid types.VideoUnloadedType
err := rows.Scan(&vid.MovieId, &vid.MovieName)
if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app
}
result = append(result, vid)
}
rows.Close()
return result
}
// TagID - TagName : pay attention to the order!
func readTagsFromResultset(rows *sql.Rows) []types.Tag {
// initialize with empty array!
result := []types.Tag{}
for rows.Next() {
var tag types.Tag
err := rows.Scan(&tag.TagId, &tag.TagName)
if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app
}
result = append(result, tag)
}
rows.Close()
return result
}
// ActorId - ActorName - Thumbnail : pay attention to the order!
func readActorsFromResultset(rows *sql.Rows) []types.Actor {
var result []types.Actor
for rows.Next() {
var actor types.Actor
var thumbnail []byte
err := rows.Scan(&actor.ActorId, &actor.Name, &thumbnail)
if len(thumbnail) != 0 {
actor.Thumbnail = string(thumbnail)
}
if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app
}
result = append(result, actor)
}
rows.Close()
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 {
// jsonify results
str, err := json.Marshal(v)
if err != nil {
fmt.Println("Error while Jsonifying return object: " + err.Error())
}
return str
}

90
apiGo/api/Settings.go Normal file
View File

@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/database/settings"
"openmediacenter/apiGo/videoparser"
"regexp"
"strings"
)
func AddSettingsHandlers() {
saveSettingsToDB()
getSettingsFromDB()
reIndexHandling()
}
func getSettingsFromDB() {
AddHandler("loadGeneralSettings", SettingsNode, nil, func() []byte {
result := database.GetSettings()
return jsonify(result)
})
AddHandler("loadInitialData", SettingsNode, nil, func() []byte {
sett := settings.LoadSettings()
type InitialDataTypeResponse struct {
DarkMode bool
Pasword bool
MediacenterName string
VideoPath string
TVShowPath string
}
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{
DarkMode: sett.DarkMode,
Pasword: sett.Pasword != "-1",
MediacenterName: sett.MediacenterName,
VideoPath: serverVideoPath,
TVShowPath: serverTVShowPath,
}
str, _ := json.Marshal(res)
return str
})
}
func saveSettingsToDB() {
var sgs struct {
Settings types.SettingsType
}
AddHandler("saveGeneralSettings", SettingsNode, &sgs, func() []byte {
query := `
UPDATE settings SET
video_path=?,
episode_path=?,
password=?,
mediacenter_name=?,
TMDB_grabbing=?,
DarkMode=?
WHERE 1`
return database.SuccessQuery(query,
sgs.Settings.VideoPath, sgs.Settings.EpisodePath, sgs.Settings.Password,
sgs.Settings.MediacenterName, sgs.Settings.TMDBGrabbing, sgs.Settings.DarkMode)
})
}
// methods for handling reindexing and cleanup of db gravity
func reIndexHandling() {
AddHandler("startReindex", SettingsNode, nil, func() []byte {
videoparser.StartReindex()
return database.ManualSuccessResponse(nil)
})
AddHandler("startTVShowReindex", SettingsNode, nil, func() []byte {
videoparser.StartTVShowReindex()
return database.ManualSuccessResponse(nil)
})
AddHandler("cleanupGravity", SettingsNode, nil, func() []byte {
videoparser.StartCleanup()
return nil
})
}

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

@ -0,0 +1,92 @@
package api
import (
"fmt"
"openmediacenter/apiGo/database"
)
func AddTvshowHandlers() {
AddHandler("getTVShows", TVShowNode, nil, func() []byte {
query := "SELECT id, name FROM tvshow"
rows := database.Query(query)
return jsonify(readTVshowsFromResultset(rows))
})
var ge struct {
ShowID uint32
}
AddHandler("getEpisodes", TVShowNode, &ge, func() []byte {
query := fmt.Sprintf("SELECT id, name, season, episode FROM tvshow_episodes WHERE tvshow_id=%d", ge.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)
})
var le struct {
ID uint32
}
AddHandler("loadEpisode", TVShowNode, &le, func() []byte {
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`, le.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)
})
var rtn struct {
Id int
}
AddHandler("readThumbnail", TVShowNode, &rtn, func() []byte {
var pic []byte
query := fmt.Sprintf("SELECT thumbnail FROM tvshow WHERE id=%d", rtn.Id)
err := database.QueryRow(query).Scan(&pic)
if err != nil {
fmt.Printf("the thumbnail of movie id %d couldn't be found", rtn.Id)
return nil
}
return pic
})
}

74
apiGo/api/Tags.go Normal file
View File

@ -0,0 +1,74 @@
package api
import (
"fmt"
"openmediacenter/apiGo/database"
"regexp"
)
func AddTagHandlers() {
getFromDB()
addToDB()
deleteFromDB()
}
func deleteFromDB() {
var dT struct {
TagId int
Force bool
}
AddHandler("deleteTag", TagNode, &dT, func() []byte {
// delete key constraints first
if dT.Force {
query := fmt.Sprintf("DELETE FROM video_tags WHERE tag_id=%d", dT.TagId)
err := database.Edit(query)
// respond only if result not successful
if err != nil {
return database.ManualSuccessResponse(err)
}
}
query := fmt.Sprintf("DELETE FROM tags WHERE tag_id=%d", dT.TagId)
err := database.Edit(query)
if err == nil {
// return if successful
return database.ManualSuccessResponse(err)
} else {
// check with regex if its the key constraint error
r := regexp.MustCompile("^.*a foreign key constraint fails.*$")
if r.MatchString(err.Error()) {
return []byte(`{"result":"not empty tag"}`)
} else {
return database.ManualSuccessResponse(err)
}
}
})
}
func getFromDB() {
AddHandler("getAllTags", TagNode, nil, func() []byte {
query := "SELECT tag_id,tag_name from tags"
return jsonify(readTagsFromResultset(database.Query(query)))
})
}
func addToDB() {
var ct struct {
TagName string
}
AddHandler("createTag", TagNode, &ct, func() []byte {
query := "INSERT IGNORE INTO tags (tag_name) VALUES (?)"
return database.SuccessQuery(query, ct.TagName)
})
var at struct {
MovieId int
TagId int
}
AddHandler("addTag", TagNode, &at, func() []byte {
query := "INSERT IGNORE INTO video_tags(tag_id, video_id) VALUES (?,?)"
return database.SuccessQuery(query, at.TagId, at.MovieId)
})
}

246
apiGo/api/Video.go Normal file
View File

@ -0,0 +1,246 @@
package api
import (
"encoding/json"
"fmt"
"net/url"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database"
"strconv"
)
func AddVideoHandlers() {
getVideoHandlers()
loadVideosHandlers()
addToVideoHandlers()
}
func getVideoHandlers() {
var mrq struct {
Tag int
}
AddHandler("getMovies", VideoNode, &mrq, func() []byte {
var query string
// 1 is the id of the ALL tag
if mrq.Tag != 1 {
query = fmt.Sprintf(`SELECT movie_id,movie_name FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_id = '%d'
ORDER BY likes DESC, create_date, movie_name`, mrq.Tag)
} else {
query = "SELECT movie_id,movie_name FROM videos ORDER BY create_date DESC, movie_name"
}
result := readVideosFromResultset(database.Query(query))
// jsonify results
str, _ := json.Marshal(result)
return str
})
var rtn struct {
Movieid int
}
AddHandler("readThumbnail", VideoNode, &rtn, func() []byte {
var pic []byte
query := fmt.Sprintf("SELECT thumbnail FROM videos WHERE movie_id=%d", rtn.Movieid)
err := database.QueryRow(query).Scan(&pic)
if err != nil {
fmt.Printf("the thumbnail of movie id %d couldn't be found", rtn.Movieid)
return nil
}
return pic
})
var grm struct {
Number int
}
AddHandler("getRandomMovies", VideoNode, &grm, func() []byte {
var result struct {
Tags []types.Tag
Videos []types.VideoUnloadedType
}
query := fmt.Sprintf("SELECT movie_id,movie_name FROM videos ORDER BY RAND() LIMIT %d", grm.Number)
result.Videos = readVideosFromResultset(database.Query(query))
var ids string
for i := range result.Videos {
ids += "video_tags.video_id=" + strconv.Itoa(result.Videos[i].MovieId)
if i < len(result.Videos)-1 {
ids += " OR "
}
}
// add the corresponding tags
query = fmt.Sprintf(`SELECT t.tag_name,t.tag_id FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE %s
GROUP BY t.tag_id`, ids)
rows := database.Query(query)
for rows.Next() {
var tag types.Tag
err := rows.Scan(&tag.TagName, &tag.TagId)
if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app
}
// append to final array
result.Tags = append(result.Tags, tag)
}
// jsonify results
str, _ := json.Marshal(result)
return str
})
var gsk struct {
KeyWord string
}
AddHandler("getSearchKeyWord", VideoNode, &gsk, func() []byte {
query := fmt.Sprintf(`SELECT movie_id,movie_name FROM videos
WHERE movie_name LIKE '%%%s%%'
ORDER BY likes DESC, create_date DESC, movie_name`, gsk.KeyWord)
result := readVideosFromResultset(database.Query(query))
// jsonify results
str, _ := json.Marshal(result)
return str
})
}
// function to handle stuff for loading specific videos and startdata
func loadVideosHandlers() {
var lv struct {
MovieId int
}
AddHandler("loadVideo", VideoNode, &lv, func() []byte {
query := fmt.Sprintf(`SELECT movie_name,movie_url,movie_id,thumbnail,poster,likes,quality,length
FROM videos WHERE movie_id=%d`, lv.MovieId)
var res types.FullVideoType
var poster []byte
var thumbnail []byte
err := database.QueryRow(query).Scan(&res.MovieName, &res.MovieUrl, &res.MovieId, &thumbnail, &poster, &res.Likes, &res.Quality, &res.Length)
if err != nil {
fmt.Printf("error getting full data list of videoid - %d", lv.MovieId)
fmt.Println(err.Error())
return nil
}
// we ned to urlencode the movieurl
res.MovieUrl = url.PathEscape(res.MovieUrl)
// we need to stringify the pic byte array
res.Poster = string(poster)
// if poster in db is empty we use the thumbnail
if res.Poster == "" {
res.Poster = string(thumbnail)
}
// now add the tags of this video
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
WHERE video_tags.video_id=%d
GROUP BY t.tag_id`, lv.MovieId)
res.Tags = readTagsFromResultset(database.Query(query))
query = fmt.Sprintf(`SELECT * FROM tags
WHERE tag_id NOT IN (
SELECT video_tags.tag_id FROM video_tags
WHERE video_id=%d)
ORDER BY rand()
LIMIT 5`, lv.MovieId)
res.SuggestedTag = readTagsFromResultset(database.Query(query))
// query the actors corresponding to video
query = fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=%d`, lv.MovieId)
res.Actors = readActorsFromResultset(database.Query(query))
// jsonify results
str, _ := json.Marshal(res)
return str
})
AddHandler("getStartData", VideoNode, nil, func() []byte {
var result types.StartData
// query settings and infotile values
query := `
SELECT (
SELECT COUNT(*) FROM videos
) AS videonr,
(
SELECT COUNT(*) 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
) AS tagged,
(
SELECT COUNT(*) FROM video_tags as vt
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='hd'
) AS hd,
(
SELECT COUNT(*) FROM video_tags as vt
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='fullhd'
) AS fullhd,
(
SELECT COUNT(*) FROM video_tags as vt
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='lowquality'
) AS lq,
(
SELECT COUNT(*) as nr FROM tags
) as tags
LIMIT 1`
_ = database.QueryRow(query).Scan(&result.VideoNr, &result.Tagged, &result.HDNr, &result.FullHdNr, &result.SDNr, &result.DifferentTags)
// jsonify results
str, _ := json.Marshal(result)
return str
})
}
func addToVideoHandlers() {
var al struct {
MovieId int
}
AddHandler("addLike", VideoNode, &al, func() []byte {
query := fmt.Sprintf("update videos set likes = likes + 1 where movie_id = %d", al.MovieId)
return database.SuccessQuery(query)
})
var dv struct {
MovieId int
}
AddHandler("deleteVideo", VideoNode, &dv, func() []byte {
// delete tag constraints
query := fmt.Sprintf("DELETE FROM video_tags WHERE video_id=%d", dv.MovieId)
err := database.Edit(query)
// delete actor constraints
query = fmt.Sprintf("DELETE FROM actors_videos WHERE video_id=%d", dv.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", dv.MovieId)
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
}

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

@ -0,0 +1,61 @@
package oauth
import (
"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), node int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, err := srv.ValidationBearerToken(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f(w, r, node)
}
}

61
apiGo/api/types/Types.go Normal file
View File

@ -0,0 +1,61 @@
package types
type VideoUnloadedType struct {
MovieId int
MovieName string
}
type FullVideoType struct {
MovieName string
MovieId uint32
MovieUrl string
Poster string
Likes uint64
Quality uint16
Length uint16
Tags []Tag
SuggestedTag []Tag
Actors []Actor
}
type Tag struct {
TagName string
TagId uint32
}
type Actor struct {
ActorId uint32
Name string
Thumbnail string
}
type StartData struct {
VideoNr uint32
FullHdNr uint32
HDNr uint32
SDNr uint32
DifferentTags uint32
Tagged uint32
}
type SettingsType struct {
VideoPath string
EpisodePath string
MediacenterName string
Password string
PasswordEnabled bool
TMDBGrabbing bool
DarkMode bool
VideoNr uint32
DBSize float32
DifferentTags uint32
TagsAdded uint32
PathPrefix string
}
type TVShow struct {
Id uint32
Name string
}

136
apiGo/database/Database.go Normal file
View File

@ -0,0 +1,136 @@
package database
import (
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
"openmediacenter/apiGo/api/types"
)
var db *sql.DB
var DBName string
// store the command line parameter for Videoprefix
var SettingsVideoPrefix = ""
type DatabaseConfig struct {
DBHost string
DBPort int
DBUser string
DBPassword string
DBName string
}
func InitDB(dbconf *DatabaseConfig) {
DBName = dbconf.DBName
// Open up our database connection.
var err error
db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", dbconf.DBUser, dbconf.DBPassword, dbconf.DBHost, dbconf.DBPort, dbconf.DBName))
// if there is an error opening the connection, handle it
if err != nil {
fmt.Printf("Error while connecting to database! - %s\n", err.Error())
}
if db != nil {
ping := db.Ping()
if ping != nil {
fmt.Printf("Error while connecting to database! - %s\n", ping.Error())
}
}
}
func Query(query string, args ...interface{}) *sql.Rows {
// perform a db.Query insert
res, err := db.Query(query, args...)
// if there is an error inserting, handle it
if err != nil {
fmt.Printf("Error while requesting data! - %s\n", err.Error())
}
return res
}
func QueryRow(SQL string, args ...interface{}) *sql.Row {
return db.QueryRow(SQL, args...)
}
// edit something in the DB and give only an error response
func Edit(query string, args ...interface{}) error {
_, err := db.Exec(query, args...)
return err
}
// insert/edit a query and return last insert id
func Insert(query string, args ...interface{}) (error, int64) {
resp, err := db.Exec(query, args...)
var id int64 = 0
if err == nil {
id, err = resp.LastInsertId()
}
return err, id
}
func SuccessQuery(query string, args ...interface{}) []byte {
return ManualSuccessResponse(Edit(query, args...))
}
func ManualSuccessResponse(err error) []byte {
if err == nil {
return []byte(`{"result":"success"}`)
} else {
return []byte(fmt.Sprintf(`{"result":"%s"}`, err.Error()))
}
}
func Close() {
db.Close()
}
func GetSettings() types.SettingsType {
var result types.SettingsType
// query settings and infotile values
query := fmt.Sprintf(`
SELECT (
SELECT COUNT(*)
FROM videos
) AS videonr,
(
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS Size
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '%s'
GROUP BY table_schema
) AS dbsize,
(
SELECT COUNT(*)
FROM tags
) AS difftagnr,
(
SELECT COUNT(*)
FROM video_tags
) AS tagsadded,
video_path, episode_path, password, mediacenter_name, TMDB_grabbing, DarkMode
FROM settings
LIMIT 1`, DBName)
var DarkMode int
var TMDBGrabbing int
err := QueryRow(query).Scan(&result.VideoNr, &result.DBSize, &result.DifferentTags, &result.TagsAdded,
&result.VideoPath, &result.EpisodePath, &result.Password, &result.MediacenterName, &TMDBGrabbing, &DarkMode)
if err != nil {
fmt.Println(err.Error())
}
result.TMDBGrabbing = TMDBGrabbing != 0
result.PasswordEnabled = result.Password != "-1"
result.DarkMode = DarkMode != 0
result.PathPrefix = SettingsVideoPrefix
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
}

9
apiGo/go.mod Normal file
View File

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

114
apiGo/go.sum Normal file
View File

@ -0,0 +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/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=

67
apiGo/main.go Normal file
View File

@ -0,0 +1,67 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"openmediacenter/apiGo/api"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/static"
"openmediacenter/apiGo/videoparser"
)
func main() {
fmt.Println("init OpenMediaCenter server")
port := 8081
db, verbose, pathPrefix := handleCommandLineArguments()
// todo some verbosity logger or sth
fmt.Printf("Use verbose output: %t\n", verbose)
fmt.Printf("Videopath prefix: %s\n", *pathPrefix)
// set pathprefix in database settings object
database.SettingsVideoPrefix = *pathPrefix
database.InitDB(db)
defer database.Close()
api.AddVideoHandlers()
api.AddSettingsHandlers()
api.AddTagHandlers()
api.AddActorsHandlers()
api.AddTvshowHandlers()
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) {
dbhostPtr := flag.String("DBHost", "127.0.0.1", "database host name")
dbPortPtr := flag.Int("DBPort", 3306, "database port")
dbUserPtr := flag.String("DBUser", "mediacenteruser", "database username")
dbPassPtr := flag.String("DBPassword", "mediapassword", "database username")
dbNamePtr := flag.String("DBName", "mediacenter", "database name")
verbosePtr := flag.Bool("v", true, "Verbose log output")
pathPrefix := flag.String("ReindexPrefix", "/var/www/openmediacenter", "Prefix path for videos to reindex")
flag.Parse()
return &database.DatabaseConfig{
DBHost: *dbhostPtr,
DBPort: *dbPortPtr,
DBUser: *dbUserPtr,
DBPassword: *dbPassPtr,
DBName: *dbNamePtr,
}, *verbosePtr, pathPrefix
}

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 @@
package videoparser

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

@ -0,0 +1,244 @@
package videoparser
import (
"database/sql"
"fmt"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/videoparser/tmdb"
"regexp"
"strconv"
"strings"
)
var mSettings types.SettingsType
var mExtDepsAvailable *ExtDependencySupport
// default Tag ids
const (
FullHd = 2
Hd = 4
LowQuality = 3
)
type ExtDependencySupport struct {
FFMpeg bool
MediaInfo bool
}
type VideoAttributes struct {
Duration float32
FileSize uint
Width uint
}
func ReIndexVideos(path []string, sett types.SettingsType) {
mSettings = sett
// check if the extern dependencies are available
mExtDepsAvailable = checkExtDependencySupport()
fmt.Printf("FFMPEG support: %t\n", mExtDepsAvailable.FFMpeg)
fmt.Printf("MediaInfo support: %t\n", mExtDepsAvailable.MediaInfo)
// 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)
}
AppendMessage("reindex finished successfully!")
SendEvent("stop")
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) {
fmt.Printf("Processing %s video-", fileNameOrig)
// match the file extension
r := regexp.MustCompile(`\.[a-zA-Z0-9]+$`)
fileName := r.ReplaceAllString(fileNameOrig, "")
// match the year and cut year from name
year, fileName := matchYear(fileName)
fmt.Printf("The Video %s doesn't exist! Adding it to database.\n", fileName)
addVideo(fileName, fileNameOrig, year)
}
// add a video to the database
func addVideo(videoName string, fileName string, year int) {
var ppic *string
var poster *string
var tmdbData *tmdb.VideoTMDB
var err error
// initialize defaults
vidAtr := &VideoAttributes{
Duration: 0,
FileSize: 0,
Width: 0,
}
if mExtDepsAvailable.FFMpeg {
ppic, err = parseFFmpegPic(mSettings.VideoPath + fileName)
if err != nil {
fmt.Printf("FFmpeg error occured: %s\n", err.Error())
} else {
fmt.Println("successfully extracted thumbnail!!")
}
}
if mExtDepsAvailable.MediaInfo {
atr := getVideoAttributes(mSettings.VideoPath + fileName)
if atr != nil {
vidAtr = atr
}
}
// if TMDB grabbing is enabled serach in api for video...
if mSettings.TMDBGrabbing {
tmdbData = tmdb.SearchVideo(videoName, year)
if tmdbData != nil {
// reassign parsed pic as poster
poster = ppic
// and tmdb pic as thumbnail
ppic = &tmdbData.Thumbnail
}
}
query := `INSERT INTO videos(movie_name,movie_url,poster,thumbnail,quality,length) VALUES (?,?,?,?,?,?)`
err, insertId := database.Insert(query, videoName, fileName, poster, ppic, vidAtr.Width, vidAtr.Duration)
if err != nil {
fmt.Printf("Failed to insert video into db: %s\n", err.Error())
return
}
// add default tags
if vidAtr.Width != 0 {
insertSizeTag(vidAtr.Width, uint(insertId))
}
// add tmdb tags
if mSettings.TMDBGrabbing && tmdbData != nil {
insertTMDBTags(tmdbData.GenreIds, insertId)
}
AppendMessage(fmt.Sprintf("%s - added!", videoName))
}
func matchYear(fileName string) (int, string) {
r := regexp.MustCompile(`\([0-9]{4}?\)`)
years := r.FindAllString(fileName, -1)
if len(years) == 0 {
return -1, fileName
}
yearStr := 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 {
return -1, fileName
}
// cut out year from filename
return year, r.ReplaceAllString(fileName, "")
}
// insert the default size tags to corresponding video
func insertSizeTag(width uint, videoId uint) {
var tagType uint
if width >= 1080 {
tagType = FullHd
} else if width >= 720 {
tagType = Hd
} else {
tagType = LowQuality
}
query := fmt.Sprintf("INSERT INTO video_tags(video_id,tag_id) VALUES (%d,%d)", videoId, tagType)
err := database.Edit(query)
if err != nil {
fmt.Printf("Eror occured while adding default Tag: %s\n", err.Error())
}
}
// insert id array of tmdb geners to database
func insertTMDBTags(ids []int, videoId int64) {
genres := tmdb.GetGenres()
for _, id := range ids {
var idGenre *tmdb.TMDBGenre
for _, genre := range *genres {
if genre.Id == id {
idGenre = &genre
break
}
}
// skip tag if name couldn't be found
if idGenre == nil {
continue
}
// now we check if the tag we want to add already exists
tagId := createTagToDB(idGenre.Name)
// now we add the tag
query := fmt.Sprintf("INSERT INTO video_tags(video_id,tag_id) VALUES (%d,%d)", videoId, tagId)
_ = database.Edit(query)
}
}
// returns id of tag or creates it if not existing
func createTagToDB(tagName string) int64 {
query := fmt.Sprintf("SELECT tag_id FROM tags WHERE tag_name = %s", tagName)
var id int64
err := database.QueryRow(query).Scan(&id)
if err == sql.ErrNoRows {
// tag doesn't exist -- add it
query = fmt.Sprintf("INSERT INTO tags (tag_name) VALUES (%s)", tagName)
err, id = database.Insert(query)
}
return id
}

View File

@ -0,0 +1,126 @@
package videoparser
import (
"fmt"
"io/ioutil"
"openmediacenter/apiGo/database"
"os"
"path/filepath"
"strings"
)
type StatusMessage struct {
Messages []string
ContentAvailable bool
}
func StartReindex() bool {
fmt.Println("starting reindex..")
SendEvent("start")
AppendMessage("starting reindex..")
mSettings := database.GetSettings()
// add the path prefix to videopath
mSettings.VideoPath = mSettings.PathPrefix + mSettings.VideoPath
// check if path even exists
if _, err := os.Stat(mSettings.VideoPath); os.IsNotExist(err) {
fmt.Println("Reindex path doesn't exist!")
AppendMessage(fmt.Sprintf("Reindex path doesn't exist! :%s", mSettings.VideoPath))
SendEvent("stop")
return false
}
var files []string
err := filepath.Walk(mSettings.VideoPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
fmt.Println(err.Error())
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".mp4") {
files = append(files, info.Name())
}
return nil
})
if err != nil {
fmt.Println(err.Error())
}
// start reindex process
AppendMessage("Starting Reindexing!")
go ReIndexVideos(files, mSettings)
return true
}
type Show struct {
Name string
files []string
}
// StartTVShowReindex reindex dir walks for TVShow reindex
func StartTVShowReindex() {
fmt.Println("starting tvshow reindex..")
SendEvent("start")
AppendMessage("starting tvshow reindex...")
mSettings := database.GetSettings()
// add the path prefix to videopath
mSettings.EpisodePath = mSettings.PathPrefix + mSettings.EpisodePath
// add slash suffix if not existing
if !strings.HasSuffix(mSettings.EpisodePath, "/") {
mSettings.EpisodePath += "/"
}
// 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
}
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() {
// todo start cleanup
}

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

@ -0,0 +1,212 @@
package tmdb
import (
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"regexp"
)
const apiKey = "9fd90530b11447f5646f8e6fb4733fb4"
const baseUrl = "https://api.themoviedb.org/3/"
const pictureBase = "https://image.tmdb.org/t/p/w500"
type VideoTMDB struct {
Thumbnail string
Overview string
Title string
GenreIds []int
}
type TVShowTMDB struct {
Thumbnail string
Overview string
GenreIds []int
}
type tmdbVidResult struct {
PosterPath string `json:"poster_path"`
Adult bool `json:"adult"`
Overview string `json:"overview"`
ReleaseDate string `json:"release_date"`
GenreIds []int `json:"genre_ids"`
Id int `json:"id"`
OriginalTitle string `json:"original_title"`
OriginalLanguage string `json:"original_language"`
Title string `json:"title"`
BackdropPath string `json:"backdrop_path"`
Popularity int `json:"popularity"`
VoteCount int `json:"vote_count"`
Video bool `json:"video"`
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 {
Id int
Name string
}
func SearchVideo(MovieName string, year int) *VideoTMDB {
fmt.Printf("Searching TMDB for: Moviename: %s, year:%d \n", MovieName, year)
queryURL := fmt.Sprintf("%ssearch/movie?api_key=%s&query=%s", baseUrl, apiKey, url.QueryEscape(MovieName))
resp, err := http.Get(queryURL)
if err != nil {
fmt.Println(err.Error())
return nil
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println(err.Error())
return nil
}
var t struct {
Results []tmdbVidResult
}
err = json.Unmarshal(body, &t)
fmt.Println(len(t.Results))
// if there was no match with tmdb return 0
if len(t.Results) == 0 {
return nil
}
var tmdbVid tmdbVidResult
if year != -1 {
for _, result := range t.Results {
r, _ := regexp.Compile(fmt.Sprintf(`^%d-[0-9]{2}?-[0-9]{2}?$`, year))
if r.MatchString(result.ReleaseDate) {
tmdbVid = result
// continue parsing
goto cont
}
}
// if there is no match use first one
tmdbVid = t.Results[0]
} else {
tmdbVid = t.Results[0]
}
// continue label
cont:
thumbnail := fetchPoster(tmdbVid.PosterPath)
result := VideoTMDB{
Thumbnail: *thumbnail,
Overview: tmdbVid.Overview,
Title: tmdbVid.Title,
GenreIds: tmdbVid.GenreIds,
}
return &result
}
func SearchTVShow(Name string) *TVShowTMDB {
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
}
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,
}
thumbnail := fetchPoster(t.Results[0].PosterPath)
if thumbnail != nil {
res.Thumbnail = *thumbnail
}
return &res
}
func fetchPoster(posterPath string) *string {
posterURL := fmt.Sprintf("%s%s", pictureBase, posterPath)
resp, err := http.Get(posterURL)
if err != nil {
fmt.Println(err.Error())
return nil
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println(err.Error())
return nil
}
backpic64 := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body)
return &backpic64
}
var tmdbGenres *[]TMDBGenre
func fetchGenres() *[]TMDBGenre {
posterURL := fmt.Sprintf("%sgenre/movie/list?api_key=%s", baseUrl, apiKey)
resp, err := http.Get(posterURL)
if err != nil {
fmt.Println(err.Error())
return nil
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println(err.Error())
return nil
}
var t []TMDBGenre
err = json.Unmarshal(body, &t)
return &t
}
func GetGenres() *[]TMDBGenre {
// if generes are nil fetch them once
if tmdbGenres == nil {
tmdbGenres = fetchGenres()
}
return tmdbGenres
}

View File

@ -1,3 +1,22 @@
create table if not exists actors
(
actor_id int auto_increment
primary key,
name varchar(50) null,
thumbnail mediumblob null
)
comment 'informations about different actors';
create table if not exists settings
(
video_path varchar(255) null,
episode_path varchar(255) null,
password varchar(32) default '-1' null,
mediacenter_name varchar(32) default 'OpenMediaCenter' null,
TMDB_grabbing tinyint null,
DarkMode tinyint default 0 null
);
create table if not exists tags
(
tag_id int auto_increment
@ -5,6 +24,29 @@ create table if not exists tags
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
(
movie_id int auto_increment
@ -16,9 +58,25 @@ create table if not exists videos
likes int default 0 null,
quality int null,
length int null comment 'in seconds',
create_date datetime default CURRENT_TIMESTAMP null
create_date datetime default current_timestamp() null
);
create table if not exists actors_videos
(
actor_id int null,
video_id int null,
constraint actors_videos_actors_id_fk
foreign key (actor_id) references actors (actor_id),
constraint actors_videos_videos_movie_id_fk
foreign key (video_id) references videos (movie_id)
);
create index if not exists actors_videos_actor_id_index
on actors_videos (actor_id);
create index if not exists actors_videos_video_id_index
on actors_videos (video_id);
create table if not exists video_tags
(
tag_id int null,
@ -30,15 +88,6 @@ create table if not exists video_tags
on delete cascade
);
create table if not exists settings
(
video_path varchar(255) null,
episode_path varchar(255) null,
password varchar(32) default '-1' null,
mediacenter_name varchar(32) default 'OpenMediaCenter' null,
TMDB_grabbing tinyint null,
DarkMode tinyint default 0 null
);
INSERT IGNORE INTO tags (tag_id, tag_name)
VALUES (2, 'fullhd');

View File

@ -1,5 +1,5 @@
Package: OpenMediaCenter
Depends: nginx, php-fpm, php-mysqli, mariadb-server
Depends: nginx, mariadb-server, mediainfo
Section: web
Priority: optional
Architecture: all

View File

@ -2,20 +2,6 @@
# enable nginx site
ln -sf /etc/nginx/sites-available/OpenMediaCenter.conf /etc/nginx/sites-enabled/OpenMediaCenter.conf
# link general socket to current one
phpsymlink="/var/run/php/php-fpm.sock";
# create a gneral symlink to the php socket if not already existing
if [ -L ${phpsymlink} ] ; then
if [ -e ${phpsymlink} ] ; then
echo "general php symlink already exists."
else
ln -sf /var/run/php/php*.*-fpm.sock /var/run/php/php-fpm.sock
fi
else
ln -sf /var/run/php/php*.*-fpm.sock /var/run/php/php-fpm.sock
fi
# setup database
mysql -uroot -pPASS -e "CREATE DATABASE IF NOT EXISTS mediacenter;"
mysql -uroot -pPASS -e "CREATE USER IF NOT EXISTS 'mediacenteruser'@'localhost' IDENTIFIED BY 'mediapassword';"
@ -31,6 +17,5 @@ chown -R www-data:www-data /var/www/openmediacenter
# restart services
systemctl restart nginx
# trigger a movie reindex
php /var/www/openmediacenter/api/extractvideopreviews.php
rm /tmp/output.log
systemctl enable OpenMediaCenter.service
systemctl restart OpenMediaCenter.service

View File

@ -2,12 +2,6 @@ server {
listen 8080 default_server;
listen [::]:8080 default_server;
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
}
root /var/www/openmediacenter;
index index.html;
@ -16,6 +10,17 @@ server {
error_log /var/log/nginx/openmediacenter.error.log;
location / {
try_files $uri $uri/ =404;
try_files $uri /index.html;
}
location ~* ^/(api/|token) {
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

@ -0,0 +1,12 @@
[Unit]
Description=OpenMediaCenter start job
After=network.target
[Service]
Type=simple
ExecStart=openmediacenter
Restart=always
User=root
[Install]
WantedBy=default.target

1
declaration.d.ts vendored Normal file
View File

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

View File

@ -1,25 +1,31 @@
{
"name": "openmediacenter",
"version": "0.1.1",
"version": "0.1.3",
"private": true,
"author": {
"email": "lukas.heiligenbrunner@gmail.com",
"name": "Lukas Heiligenbrunner",
"url": "https://heili.eu"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "^0.1.11",
"@fortawesome/react-fontawesome": "^0.1.13",
"bootstrap": "^4.5.3",
"plyr-react": "^2.2.0",
"react": "^16.14.0",
"plyr-react": "^3.0.7",
"react": "^17.0.1",
"react-bootstrap": "^1.4.0",
"react-dom": "^16.14.0",
"react-scripts": "^3.4.4"
"react-dom": "^17.0.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"typescript": "^4.1.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --reporters=jest-junit --reporters=default",
"coverage": "react-scripts test --coverage --watchAll=false",
"eject": "react-scripts eject"
"build": "CI=false react-scripts build",
"test": "CI=true react-scripts test --reporters=jest-junit --verbose --silent --coverage --reporters=default",
"lint": "eslint --format gitlab src/"
},
"jest": {
"collectCoverageFrom": [
@ -31,11 +37,8 @@
"text-summary"
]
},
"proxy": "http://192.168.0.42:8080",
"proxy": "http://127.0.0.1:8081",
"homepage": "/",
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
@ -49,11 +52,30 @@
]
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.6.0",
"@types/jest": "^26.0.19",
"@types/node": "^14.14.31",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.1",
"@types/react-router": "5.1.12",
"@types/react-router-dom": "^5.1.6",
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.5",
"jest-junit": "^10.0.0"
"eslint": "^7.22.0",
"eslint-config-prettier": "^8.1.0",
"eslint-formatter-gitlab": "^2.2.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-jest": "^24.3.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"jest-junit": "^12.0.0",
"prettier": "^2.2.1",
"prettier-config": "^1.0.0",
"react-scripts": "4.0.3"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -2,14 +2,14 @@
<html lang="en">
<head>
<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="theme-color" content="#000000" />
<meta
name="description"
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
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",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"src": "logo_circle.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",

View File

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

View File

@ -16,6 +16,7 @@
.navitem:hover {
opacity: 1;
text-decoration: none;
transition: opacity .5s;
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import App from './App';
import {shallow} from 'enzyme';
import GlobalInfos from "./utils/GlobalInfos";
describe('<App/>', function () {
it('renders without crashing ', function () {
@ -10,99 +11,37 @@ describe('<App/>', function () {
it('renders title', () => {
const wrapper = shallow(<App/>);
wrapper.setState({password: false});
expect(wrapper.find('.navbrand').text()).toBe('OpenMediaCenter');
});
it('are navlinks correct', function () {
const wrapper = shallow(<App/>);
expect(wrapper.find('.navitem')).toHaveLength(4);
});
it('simulate video view change ', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find('#testit')).toHaveLength(1);
});
it('test hide video again', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find('#testit')).toHaveLength(1);
wrapper.instance().returnToLastElement();
expect(wrapper.find('HomePage')).toHaveLength(1);
});
it('test fallback to last loaded page', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.find('.navitem').findWhere(t => t.text() === 'Random Video' && t.type() === 'div').simulate('click');
wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find('#testit')).toHaveLength(1);
wrapper.instance().returnToLastElement();
expect(wrapper.find('RandomPage')).toHaveLength(1);
});
it('test home click', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.setState({page: 'wrongvalue'});
expect(wrapper.find('HomePage')).toHaveLength(0);
wrapper.find('.navitem').findWhere(t => t.text() === 'Home' && t.type() === 'div').simulate('click');
expect(wrapper.find('HomePage')).toHaveLength(1);
});
it('test category click', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
expect(wrapper.find('CategoryPage')).toHaveLength(0);
wrapper.find('.navitem').findWhere(t => t.text() === 'Categories' && t.type() === 'div').simulate('click');
expect(wrapper.find('CategoryPage')).toHaveLength(1);
});
it('test settings click', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
expect(wrapper.find('SettingsPage')).toHaveLength(0);
wrapper.find('.navitem').findWhere(t => t.text() === 'Settings' && t.type() === 'div').simulate('click');
expect(wrapper.find('SettingsPage')).toHaveLength(1);
wrapper.setState({password: false});
expect(wrapper.find('.navitem')).toHaveLength(5);
});
it('test initial fetch from api', done => {
global.fetch = global.prepareFetchApi({
generalSettingsLoaded: true,
passwordsupport: true,
mediacentername: 'testname'
});
callAPIMock({
MediacenterName: 'testname'
})
GlobalInfos.enableDarkTheme = jest.fn((r) => {})
const wrapper = shallow(<App/>);
const func = jest.fn();
wrapper.instance().setState = func;
expect(global.fetch).toBeCalledTimes(1);
process.nextTick(() => {
expect(func).toBeCalledTimes(1);
expect(document.title).toBe('testname');
global.fetch.mockClear();
done();
});
});
it('test render of password page', function () {
const wrapper = shallow(<App/>);
wrapper.setState({password: true});
expect(wrapper.find('AuthenticationPage')).toHaveLength(1);
});
});

197
src/App.tsx Normal file
View File

@ -0,0 +1,197 @@
import React from 'react';
import HomePage from './pages/HomePage/HomePage';
import RandomPage from './pages/RandomPage/RandomPage';
import GlobalInfos from './utils/GlobalInfos';
// include bootstraps css
import 'bootstrap/dist/css/bootstrap.min.css';
import style from './App.module.css';
import SettingsPage from './pages/SettingsPage/SettingsPage';
import CategoryPage from './pages/CategoryPage/CategoryPage';
import {APINode, callAPI} from './utils/Api';
import {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom';
import Player from './pages/Player/Player';
import ActorOverviewPage from './pages/ActorOverviewPage/ActorOverviewPage';
import ActorPage from './pages/ActorPage/ActorPage';
import {SettingsTypes} from './types/ApiTypes';
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 {
password: boolean | null; // null if uninitialized - true if pwd needed false if not needed
mediacentername: string;
}
/**
* The main App handles the main tabs and which content to show
*/
class App extends React.Component<{}, state> {
constructor(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 = {
mediacentername: 'OpenMediaCenter',
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);
};
}
initialAPICall(): void {
// this is the first api call so if it fails we know there is no connection to backend
callAPI(APINode.Settings, {action: 'loadInitialData'}, (result: SettingsTypes.initialApiCallData) => {
// set theme
GlobalInfos.enableDarkTheme(result.DarkMode);
GlobalInfos.setVideoPaths(result.VideoPath, result.TVShowPath);
this.setState({
mediacentername: result.MediacenterName
});
// set tab title to received mediacenter name
document.title = result.MediacenterName;
});
}
componentDidMount(): void {
this.initialAPICall();
}
render(): JSX.Element {
// add the main theme to the page body
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 (
<div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}>
<div className={style.navbrand}>{this.state.mediacentername}</div>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/'} activeStyle={{opacity: '0.85'}}>
Home
</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/random'} activeStyle={{opacity: '0.85'}}>
Random Video
</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/categories'} activeStyle={{opacity: '0.85'}}>
Categories
</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/tvshows'} activeStyle={{opacity: '0.85'}}>
TV Shows
</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/settings'} activeStyle={{opacity: '0.85'}}>
Settings
</NavLink>
</div>
);
}
/**
* render the react router elements
*/
routing(): JSX.Element {
return (
<Switch>
<Route path='/random'>
<RandomPage />
</Route>
<Route path='/categories'>
<CategoryPage />
</Route>
<Route path='/tvshows'>
<TVShowPage />
</Route>
<Route path='/settings'>
<SettingsPage />
</Route>
<Route exact path='/player/:id'>
<Player />
</Route>
<Route exact path='/tvplayer/:id'>
<TVPlayer />
</Route>
<Route exact path='/actors'>
<ActorOverviewPage />
</Route>
<Route path='/actors/:id'>
<ActorPage />
</Route>
<Route path='/'>
<HomePage />
</Route>
</Switch>
);
}
}
export default App;

View File

@ -22,6 +22,14 @@
background: white;
}
.navitem {
color: white;
}
.navitem:hover {
color: white;
}
.hrcolor {
border-color: rgba(255, 255, 255, .1);
}

View File

@ -2,6 +2,14 @@
* The coloring elements for light theme
*/
.navitem {
color: black;
}
.navitem:hover {
color: black;
}
.navitem::after {
background: black;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,81 +0,0 @@
import React from 'react';
import {shallow} from 'enzyme';
import '@testing-library/jest-dom';
import AddTagPopup from './AddTagPopup';
describe('<AddTagPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.unmount();
});
it('test tag insertion', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.setState({
items: [{tag_id: 1, tag_name: 'test'}, {tag_id: 2, tag_name: 'ee'}]
}, () => {
expect(wrapper.find('Tag')).toHaveLength(2);
expect(wrapper.find('Tag').first().dive().text()).toBe('test');
});
});
it('test tag click', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.instance().addTag = jest.fn();
wrapper.setState({
items: [{tag_id: 1, tag_name: 'test'}]
}, () => {
wrapper.find('Tag').first().dive().simulate('click');
expect(wrapper.instance().addTag).toHaveBeenCalledTimes(1);
});
});
it('test addtag', done => {
const wrapper = shallow(<AddTagPopup/>);
global.fetch = prepareFetchApi({result: 'success'});
wrapper.setProps({
submit: jest.fn(() => {}),
onHide: jest.fn()
}, () => {
wrapper.instance().addTag(1, 'test');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
process.nextTick(() => {
expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
it('test failing addTag', done => {
const wrapper = shallow(<AddTagPopup/>);
global.fetch = prepareFetchApi({result: 'fail'});
wrapper.setProps({
submit: jest.fn(() => {}),
onHide: jest.fn()
}, () => {
wrapper.instance().addTag(1, 'test');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
process.nextTick(() => {
expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(0);
expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
});

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,107 @@
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 (prevProps.data.length !== this.props.data.length) {
this.clean();
this.loadPreviewBlock(this.InitialLoadNR);
}
}
/**
* clear all elements rendered...
*/
clean(): void {
this.loadindex = 0;
this.setState({loadeditems: []});
}
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,23 @@
import React from 'react';
import style from './PageTitle.module.css';
import GlobalInfos from '../../GlobalInfos';
import GlobalInfos from '../../utils/GlobalInfos';
interface props {
title: string;
subtitle: string | number | null;
}
/**
* Component for generating PageTitle with bottom Line
*/
class PageTitle extends React.Component {
render() {
class PageTitle extends React.Component<props> {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={style.pageheader + ' ' + themeStyle.backgroundcolor}>
<span className={style.pageheadertitle + ' ' + themeStyle.textcolor}>{this.props.title}</span>
<span className={style.pageheadersubtitle + ' ' + themeStyle.textcolor}>{this.props.subtitle}</span>
<>
{this.props.children}
</>
<>{this.props.children}</>
<Line />
</div>
);
@ -26,7 +29,7 @@ class PageTitle extends React.Component {
* use this for horizontal lines to use the current active theming
*/
export class Line extends React.Component {
render() {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<>

View File

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

View File

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

View File

@ -0,0 +1,168 @@
import PopupBase from '../PopupBase';
import React from 'react';
import ActorTile from '../../ActorTile/ActorTile';
import style from './AddActorPopup.module.css';
import {NewActorPopupContent} from '../NewActorPopup/NewActorPopup';
import {APINode, callAPI} from '../../../utils/Api';
import {ActorType} from '../../../types/VideoTypes';
import {GeneralSuccess} from '../../../types/GeneralTypes';
import FilterButton from '../../FilterButton/FilterButton';
interface Props {
onHide: () => void;
movieId: number;
}
interface state {
contentDefault: boolean;
actors: ActorType[];
filter: string;
}
/**
* Popup for Adding a new Actor to a Video
*/
class AddActorPopup extends React.Component<Props, state> {
// filterfield anchor, needed to focus after filter btn click
private filterfield: HTMLInputElement | null | undefined;
constructor(props: Props) {
super(props);
this.state = {
contentDefault: true,
actors: [],
filter: ''
};
this.tileClickHandler = this.tileClickHandler.bind(this);
this.filterSearch = this.filterSearch.bind(this);
this.parentSubmit = this.parentSubmit.bind(this);
}
componentDidMount(): void {
// fetch the available actors
this.loadActors();
}
render(): JSX.Element {
return (
<>
{/* todo render actor tiles here and add search field*/}
<PopupBase
title='Add new Actor to Video'
onHide={this.props.onHide}
banner={
<button
className={style.newactorbutton}
onClick={(): void => {
this.setState({contentDefault: false});
}}>
Create new Actor
</button>
}
ParentSubmit={this.parentSubmit}>
{this.resolvePage()}
</PopupBase>
</>
);
}
/**
* selector for current showing popup page
* @returns {JSX.Element}
*/
resolvePage(): JSX.Element {
if (this.state.contentDefault) {
return this.getContent();
} else {
return (
<NewActorPopupContent
onHide={(): void => {
this.loadActors();
this.setState({contentDefault: true});
}}
/>
);
}
}
/**
* returns content for the newActor popup
* @returns {JSX.Element}
*/
getContent(): JSX.Element {
if (this.state.actors.length !== 0) {
return (
<>
<div className={style.searchbar}>
<FilterButton
onFilterChange={(filter): void => {
this.setState({filter: filter});
}}
/>
</div>
{this.state.actors.filter(this.filterSearch).map((el) => (
<ActorTile actor={el} onClick={this.tileClickHandler} />
))}
</>
);
} else {
return <div>somekind of loading</div>;
}
}
/**
* event handling for ActorTile Click
*/
tileClickHandler(actor: ActorType): void {
// fetch the available actors
callAPI<GeneralSuccess>(
APINode.Actor,
{
action: 'addActorToVideo',
ActorId: actor.ActorId,
MovieId: this.props.movieId
},
(result) => {
if (result.result === 'success') {
// return back to player page
this.props.onHide();
} else {
console.error('an error occured while fetching actors: ' + result);
}
}
);
}
/**
* load the actors from backend and set state
*/
loadActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, (result) => {
this.setState({actors: result});
});
}
/**
* filter the actor array for search matches
* @param actor
*/
private filterSearch(actor: ActorType): boolean {
return actor.Name.toLowerCase().includes(this.state.filter.toLowerCase());
}
/**
* handle a Popupbase parent submit action
*/
private parentSubmit(): void {
// allow submit only if one item is left in selection
const filteredList = this.state.actors.filter(this.filterSearch);
if (filteredList.length === 1) {
// simulate click if parent submit
this.tileClickHandler(filteredList[0]);
}
}
}
export default AddActorPopup;

View File

@ -0,0 +1,3 @@
.actionbar{
margin-bottom: 15px;
}

View File

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

@ -0,0 +1,74 @@
import React from 'react';
import Tag from '../../Tag/Tag';
import PopupBase from '../PopupBase';
import {APINode, callAPI} from '../../../utils/Api';
import {TagType} from '../../../types/VideoTypes';
import FilterButton from '../../FilterButton/FilterButton';
import styles from './AddTagPopup.module.css';
interface Props {
onHide: () => void;
submit: (tagId: number, tagName: string) => void;
}
interface state {
items: TagType[];
filter: string;
}
/**
* component creates overlay to add a new tag to a video
*/
class AddTagPopup extends React.Component<Props, state> {
constructor(props: Props) {
super(props);
this.state = {items: [], filter: ''};
this.tagFilter = this.tagFilter.bind(this);
this.parentSubmit = this.parentSubmit.bind(this);
this.onItemClick = this.onItemClick.bind(this);
}
componentDidMount(): void {
callAPI(APINode.Tags, {action: 'getAllTags'}, (result: TagType[]) => {
this.setState({
items: result
});
});
}
render(): JSX.Element {
return (
<PopupBase title='Add a Tag to this Video:' onHide={this.props.onHide} ParentSubmit={this.parentSubmit}>
<div className={styles.actionbar}>
<FilterButton onFilterChange={(filter): void => this.setState({filter: filter})} />
</div>
{this.state.items
? this.state.items.filter(this.tagFilter).map((i) => <Tag tagInfo={i} onclick={(): void => this.onItemClick(i)} />)
: null}
</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;

View File

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

View File

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

View File

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

View File

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

View File

@ -11,12 +11,7 @@ describe('<NewTagPopup/>', function () {
});
it('test storeseletion click event', done => {
const mockSuccessResponse = {};
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise
});
global.fetch = jest.fn().mockImplementation(() => mockFetchPromise);
global.fetch = prepareFetchApi({});
const func = jest.fn();
@ -27,7 +22,9 @@ describe('<NewTagPopup/>', function () {
}
});
wrapper.find('ModalFooter').find('button').simulate('click');
wrapper.instance().value = 'testvalue';
wrapper.find('button').simulate('click');
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
@ -38,4 +35,12 @@ describe('<NewTagPopup/>', function () {
done();
});
});
it('simulate textfield change', function () {
const wrapper = shallow(<NewTagPopup/>);
wrapper.find('input').simulate('change', {target: {value: 'testvalue'}});
expect(wrapper.instance().value).toBe('testvalue');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,99 +0,0 @@
import React from 'react';
import style from './Preview.module.css';
import Player from '../../pages/Player/Player';
import {Spinner} from 'react-bootstrap';
import GlobalInfos from '../../GlobalInfos';
/**
* Component for single preview tile
* floating side by side
*/
class Preview extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
previewpicture: null,
name: null
};
}
componentDidMount() {
this.setState({
previewpicture: null,
name: this.props.name
});
const updateRequest = new FormData();
updateRequest.append('action', 'readThumbnail');
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.text()
.then((result) => {
this.setState({
previewpicture: result
});
}));
}
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
onClick={() => this.itemClick()}>
<div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.state.name}</div>
<div className={style.previewpic}>
{this.state.previewpicture !== null ?
<img className={style.previewimage}
src={this.state.previewpicture}
alt='Pic loading.'/> :
<span className={style.loadAnimation}><Spinner animation='border'/></span>}
</div>
<div className={style.previewbottom}>
</div>
</div>
);
}
/**
* handle the click event of a tile
*/
itemClick() {
console.log('item clicked!' + this.state.name);
this.props.viewbinding.changeRootElement(
<Player
viewbinding={this.props.viewbinding}
movie_id={this.props.movie_id}/>);
}
}
/**
* Component for a Tag-name tile (used in category page)
*/
export class TagPreview extends React.Component {
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div
className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
onClick={() => this.itemClick()}>
<div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>
{this.props.name}
</div>
</div>
);
}
/**
* handle the click event of a Tag tile
*/
itemClick() {
this.props.categorybinding(this.props.name);
}
}
export default Preview;

View File

@ -0,0 +1,98 @@
import React from 'react';
import style from './Preview.module.css';
import {Spinner} from 'react-bootstrap';
import {Link} from 'react-router-dom';
import GlobalInfos from '../../utils/GlobalInfos';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
interface PreviewProps {
name: string;
picLoader: (callback: (pic: string) => void) => void;
linkPath?: string;
onClick?: () => void;
}
interface PreviewState {
picLoaded: boolean | null;
}
/**
* Component for single preview tile
* floating side by side
*/
class Preview extends React.Component<PreviewProps, PreviewState> {
// store the picture to display
pic?: string;
constructor(props: PreviewProps) {
super(props);
this.state = {
picLoaded: null
};
}
componentDidMount(): void {
this.props.picLoader((result) => {
this.pic = result;
this.setState({
picLoaded: result !== ''
});
});
}
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();
return (
<div
className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
onClick={this.props.onClick}>
<div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div>
<div className={style.previewpic}>
{this.state.picLoaded === false ? (
<FontAwesomeIcon
style={{
color: 'white',
marginTop: '55px'
}}
icon={faPhotoVideo}
size='5x'
/>
) : this.state.picLoaded === null ? (
<span className={style.loadAnimation}>
<Spinner animation='border' />
</span>
) : (
<img className={style.previewimage} src={this.pic} alt='Pic loading.' />
)}
</div>
<div className={style.previewbottom} />
</div>
);
}
}
/**
* Component for a Tag-name tile (used in category page)
*/
export class TagPreview extends React.Component<{name: string}> {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}>
<div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div>
</div>
);
}
}
export default Preview;

View File

@ -5,60 +5,29 @@ import Preview, {TagPreview} from './Preview';
describe('<Preview/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<Preview/>);
const wrapper = shallow(<Preview movieId={1} name='test' picLoader={callback => callback('')}/>);
wrapper.unmount();
});
it('picture rendered correctly', () => {
const func = jest.fn();
const wrapper = shallow(<Preview movieId={1} name='test' picLoader={callback => {
func();
callback('42');
}}/>);
// expect picloader tobe called once
expect(func).toHaveBeenCalledTimes(1)
// received picture should be rendered into wrapper
expect(wrapper.find('.previewimage').props().src).toBe('42');
// check if preview title renders correctly
it('renders title', () => {
const wrapper = shallow(<Preview name='test'/>);
expect(wrapper.find('.previewtitle').text()).toBe('test');
});
it('click event triggered', () => {
const func = jest.fn();
const wrapper = shallow(<Preview/>);
wrapper.setProps({
viewbinding: {
changeRootElement: () => {
func();
}
}
});
wrapper.find('.videopreview').simulate('click');
//callback to open player should have called
expect(func).toHaveBeenCalledTimes(1);
});
it('picture rendered correctly', done => {
const mockSuccessResponse = 'testsrc';
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({
text: () => mockJsonPromise
});
global.fetch = jest.fn().mockImplementation(() => mockFetchPromise);
const wrapper = shallow(<Preview/>);
// 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();
global.fetch.mockClear();
done();
});
});
it('spinner loads correctly', function () {
const wrapper = shallow(<Preview/>);
// if callback is never called --> infinite spinner
const wrapper = shallow(<Preview movieId={1} name='test' picLoader={callback => {}}/>);
// expect load animation to be visible
expect(wrapper.find('.loadAnimation')).toHaveLength(1);
@ -76,24 +45,5 @@ describe('<TagPreview/>', function () {
const wrapper = shallow(<TagPreview name='test'/>);
expect(wrapper.find('.tagpreviewtitle').text()).toBe('test');
});
it('click event triggered', function () {
const func = jest.fn();
const wrapper = shallow(<TagPreview/>);
wrapper.setProps({
categorybinding: () => {
func();
}
});
// first call of fetch is getting of available tags
expect(func).toHaveBeenCalledTimes(0);
wrapper.find('.videopreview').simulate('click');
// now called 1 times
expect(func).toHaveBeenCalledTimes(1);
});
});

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