Compare commits

..

125 Commits

Author SHA1 Message Date
f51d9597e9 set randompage as the startpage 2023-12-02 09:32:54 +01:00
af1de3a244 increase token validity 2022-11-30 23:30:24 +01:00
752200d42e order actor and tag api reply by name 2022-11-30 23:28:10 +01:00
39ed5cd7d8 Merge remote-tracking branch 'origin/morevidefiletypes'
# Conflicts:
#	apiGo/videoparser/VideoParser.go
2022-09-21 13:23:51 +02:00
5d409c3e23 fix correct redirects on tvshows page 2022-09-20 14:26:15 +02:00
05ea72a8ca fix ip of testserver 2022-06-25 14:37:51 +02:00
7476e2397d add a filewatcher to automatically reindex if new file is added 2022-06-25 14:02:25 +02:00
929e0c337d fix invalid links if videos are in subfolders 2022-05-22 20:48:04 +02:00
f7a0d8fa07 add housekeeping code to delete duplicated tags and videos 2022-05-05 21:32:35 +02:00
9faf457c89 add tag delete button if filtertag selected 2022-05-05 19:43:29 +02:00
11c1e25de5 update browserlist 2022-05-05 18:42:48 +02:00
3d845aaf04 Merge branch 'randompage_filter_tags' into 'master'
make tags on random page filterable

See merge request lukas/openmediacenter!61
2022-05-05 16:41:46 +00:00
7f98528fbe use seperate jobs for manual and auto runs 2022-05-05 18:06:41 +02:00
191fa5386d fix detached pipeline 2022-05-05 18:02:32 +02:00
9715012685 make tags on random page filterable 2022-05-05 17:46:59 +02:00
43091ff7ed fix loading of tv show page 2022-01-14 20:33:29 +01:00
12dc8427aa fix invalid link to actor overview page
add new start-header link to actor overview
2021-12-20 17:10:34 +01:00
23d91973d7 fix wrong redirect on player tag click 2021-11-07 19:02:44 +01:00
e513877019 test deploy to sec server 2021-11-07 18:05:46 +01:00
be2b848f8e Merge branch 'preview-prerender' into 'master'
prerender preview size

See merge request lukas/openmediacenter!59
2021-10-24 13:22:15 +00:00
08310f78bb prerender preview size 2021-10-24 13:22:14 +00:00
dfcb7f71d9 use correct filepath when checking if tv show episode already exits in databse 2021-09-30 10:49:33 +02:00
3d1671d6b5 iconify like, delete and addtag buttons
fix lukas/openmediacenter#45
2021-09-29 12:15:16 +02:00
c2a5d88743 Merge branch 'release_date' into 'master'
Release date from TMDB and db migrations

See merge request lukas/openmediacenter!57
2021-09-28 21:28:33 +00:00
3588df7c4f add release date to videopage
improve reindex db insertion logic
2021-09-28 10:52:18 +02:00
0df96a093f add automatic database migrations with goose 2021-09-28 00:36:14 +02:00
5b2eff3f6d avoid using deprecated CodecCtx() 2021-09-27 22:36:06 +02:00
ecef80f87f avoid nil pointer dereference panic when freeing unavailable stream 2021-09-27 20:42:01 +02:00
881281af70 fix lukas/openmediacenter#68 tmdb categories not indexed correctly 2021-09-27 19:30:18 +02:00
61ea42ef01 allow more videotypes than mp4 2021-09-27 17:33:05 +02:00
e4f09eddac iniitaldata load not fetched when user not logged in. 2021-09-27 11:20:31 +02:00
bb24bfd908 Merge branch 'libffmpeg' into 'master'
Libffmpeg for thumbnailparsing

See merge request lukas/openmediacenter!56
2021-09-26 22:01:22 +00:00
63e4faf73d avoid triggering forceupdate on homepage on init of theme state 2021-09-26 23:58:56 +02:00
b685b7d7be Merge branch 'fileupload' into 'master'
Video upload through webpage

Closes #59

See merge request lukas/openmediacenter!55
2021-09-26 20:46:21 +00:00
be4a7db4a0 correct redirect to search page, avoid duplicate keys on moviesettingspage 2021-09-26 22:41:48 +02:00
6c553e6f48 message if upload was successfull or not 2021-09-26 22:30:32 +02:00
7bf3b537f8 use go-ffmpeg docker image 2021-09-26 21:26:32 +02:00
9a88c16559 remove direct libcall due to lib update 2021-09-25 23:52:21 +02:00
9f1d1255cb install build dep during build
use correct jpeg encoding codec
2021-09-25 22:50:32 +02:00
800a48c610 use libffmpeg to parse video frame and vid information 2021-09-25 20:49:47 +02:00
fd5542c528 parse new video in new go function
validate extension on server to allow only videos
2021-09-24 22:12:42 +02:00
156aaa7a71 nice heading above uploadfield
10G upload limit for nginx config
2021-09-23 20:16:09 +02:00
afaad81849 fix linter errror
use correct videopath on reindex after upload
2021-09-23 19:45:58 +02:00
a92ce73806 seperate component for file drop and upload
correct save position of uploaded files
then parse video file
2021-09-23 17:38:20 +02:00
d3bd810a1a nice progressbar and correct authentication header 2021-09-21 23:39:21 +02:00
284f78de49 uploadable file 2021-09-21 17:45:24 +02:00
51ba86d13d Merge branch 'apihandling' into 'master'
Impvroved api handling

See merge request lukas/openmediacenter!54
2021-09-21 08:59:35 +00:00
334c54be4a fix linter warnings
one method to add handlers
2021-09-21 10:45:52 +02:00
b10fbd6142 add some backend unit tests 2021-09-20 19:06:50 +02:00
70413ac887 fix tests and delete some useless tests 2021-09-20 18:04:48 +02:00
ab0eab5085 fix redirect path
remove dead code
2021-09-20 12:33:43 +02:00
e71f262b79 new features context to render features correctly on change 2021-09-20 12:20:22 +02:00
f17bac399a basic frontend implementation of new token system 2021-09-19 23:20:37 +02:00
e985eb941c overwork most of how api works
dont transmit handler within payload
don't use oauth to gen token -- jwt instead
2021-09-16 22:38:28 +02:00
0fcb92c61a Merge branch 'conffile' into 'master'
Config

See merge request lukas/openmediacenter!53
2021-09-11 21:31:49 +00:00
aa49d601ab override config entries with cli args
use getconfig instead of settings file
2021-09-09 23:33:04 +02:00
2706929bb4 load and save config file 2021-09-06 20:20:33 +02:00
98c5211020 correct videopath to delete 2021-09-05 15:27:14 +02:00
2a098527bd Merge branch 'fullydeleable' into 'master'
Fully Deletable Videos

Closes #74

See merge request lukas/openmediacenter!52
2021-09-05 13:14:48 +00:00
916f092406 add some tests and correct deletion path 2021-09-05 15:01:11 +02:00
924f05b2d2 fix tests and send feature support within first api call 2021-09-03 12:09:51 +02:00
543ce5b250 fully deletable videos -- enable/disable with cli args 2021-08-29 19:48:03 +02:00
7ebc5766e9 fix unit tests 2021-08-28 22:55:27 +02:00
f8dbadc45b player page: goback only if backstack available, go to homepage if not
fix invalid api request when creating new actor
2021-08-28 22:21:51 +02:00
032b90a93d update dependencies 2021-07-29 19:35:58 +02:00
6b4267b50b implement lukas/openmediacenter#72 2021-07-25 10:15:28 +02:00
ebb55eb0dc Merge branch 'settingssavefix' into 'master'
fix type error on settingssave

Closes #71

See merge request lukas/openmediacenter!50
2021-07-11 12:45:08 +00:00
24ecfb46e6 fix type error on settingssave 2021-07-11 14:26:10 +02:00
64897d2abe implement api fetch with async await and outsource general code into seperate function 2021-06-24 17:58:42 +02:00
c93d02ca14 Merge branch 'filterbutton' into 'master'
SortBY feature

See merge request lukas/openmediacenter!49
2021-06-22 20:15:05 +00:00
c7a0368a26 add test and move style in stylesheet 2021-06-22 22:01:35 +02:00
95a6a4d407 Merge branch 'disableableTVShowNav' into 'master'
tVShows navlink and pages optionally disabled

See merge request lukas/openmediacenter!48
2021-06-14 19:37:04 +00:00
8d50ec54e7 add new sortby button and allow sorting on homepage 2021-06-13 22:29:50 +02:00
d94b672a12 fix invalid underlining of navitems 2021-06-11 23:10:57 +02:00
c47ab476a2 update dependencies 2021-06-10 21:37:59 +02:00
a60f5a30b8 make tvshow navlink and pages and backend disableable with a command line parameter 2021-06-08 21:55:54 +02:00
f7b7df5934 Merge branch 'wrong_playerid' into 'master'
Homepage redirect on wrong Player id

Closes #69

See merge request lukas/openmediacenter!47
2021-06-08 18:12:09 +00:00
7d44ffa225 Homepage redirect on wrong Player id 2021-06-08 18:12:09 +00:00
f0bc0c29dd fix lukas/openmediacenter#70 2021-06-06 11:49:42 +02:00
e3c7fe514b Merge branch 'APIDoc_handling_mechanics' into 'master'
Api mechanics

See merge request lukas/openmediacenter!46
2021-05-25 20:52:09 +00:00
e47b3eecf2 fix api typo 2021-05-25 22:34:29 +02:00
b59b6a17f4 add missing apidoc, dont show sendrequest form on docpage
fix unit tests
2021-05-23 14:21:44 +02:00
31ad6ec1e5 new apihandler mechanics to allow asynchronous api calls
document some api nodes with apidoc
2021-05-22 21:33:32 +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
141 changed files with 9255 additions and 4423 deletions

295
.eslintrc.js Normal file
View File

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

View File

@ -1,23 +1,19 @@
image: node:14 image: node:14
stages: stages:
- build - build_frontend
- build_backend
- test - test
- packaging - packaging
- deploy - deploy
include:
- template: Code-Quality.gitlab-ci.yml
variables:
SAST_DISABLE_DIND: "true"
Minimize_Frontend: Minimize_Frontend:
stage: build stage: build_frontend
before_script: before_script:
- yarn install --cache-folder .yarn - yarn install --cache-folder .yarn
script: script:
- yarn run build - yarn run build
- rm build/*/*/*.map
artifacts: artifacts:
expire_in: 2 days expire_in: 2 days
paths: paths:
@ -29,12 +25,16 @@ Minimize_Frontend:
- node_modules/ - node_modules/
Build_Backend: Build_Backend:
image: golang:latest image: luki42/go-ffmpeg:latest
stage: build stage: build_backend
script: script:
- cd apiGo - cd apiGo
- go build -v -o openmediacenter - go build -v -tags sharedffmpeg -o openmediacenter
- env GOOS=windows GOARCH=amd64 go build -v -o openmediacenter.exe - cp -r ../build/ ./static/
- go build -v -tags static -o openmediacenter_full
- env GOOS=windows GOARCH=amd64 go build -v -tags static -o openmediacenter.exe
needs:
- Minimize_Frontend
artifacts: artifacts:
expire_in: 2 days expire_in: 2 days
paths: paths:
@ -46,6 +46,7 @@ Frontend_Tests:
- yarn install --cache-folder .yarn - yarn install --cache-folder .yarn
script: script:
- yarn run test - yarn run test
needs: []
artifacts: artifacts:
reports: reports:
junit: junit:
@ -61,16 +62,29 @@ Backend_Tests:
stage: test stage: test
script: script:
- cd apiGo - cd apiGo
- go get -u github.com/jstemmer/go-junit-report - go install github.com/jstemmer/go-junit-report@v0.9.1
- go test -v ./... 2>&1 | go-junit-report -set-exit-code > report.xml - go test -v ./... 2>&1 | go-junit-report -set-exit-code > report.xml
needs: []
artifacts: artifacts:
when: always when: always
reports: reports:
junit: ./apiGo/report.xml junit: ./apiGo/report.xml
code_quality: lint:
tags: stage: test
- dind before_script:
- yarn install --cache-folder .yarn
script:
- yarn run lint
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .yarn/
- ./node_modules/
artifacts:
reports:
codequality: gl-codequality.json
needs: []
Debian_Server: Debian_Server:
stage: packaging stage: packaging
@ -79,11 +93,9 @@ Debian_Server:
- vers=$(grep -Po '"version":.*?[^\\]",' package.json | grep -Po '[0-9]+\.[0-9]+\.[0-9]+') # parse the version out of package .json - vers=$(grep -Po '"version":.*?[^\\]",' package.json | grep -Po '[0-9]+\.[0-9]+\.[0-9]+') # parse the version out of package .json
- cd deb - cd deb
- mkdir -p "./OpenMediaCenter/var/www/openmediacenter/videos/" - mkdir -p "./OpenMediaCenter/var/www/openmediacenter/videos/"
- mkdir -p "./OpenMediaCenter/tmp/"
- mkdir -p "./OpenMediaCenter/usr/bin/" - mkdir -p "./OpenMediaCenter/usr/bin/"
- cp -r ../build/* ./OpenMediaCenter/var/www/openmediacenter/ - cp -r ../build/* ./OpenMediaCenter/var/www/openmediacenter/
- cp ../apiGo/openmediacenter ./OpenMediaCenter/usr/bin/ - cp ../apiGo/openmediacenter ./OpenMediaCenter/usr/bin/
- cp ../database.sql ./OpenMediaCenter/tmp/openmediacenter.sql
- 'echo "Version: ${vers}" >> ./OpenMediaCenter/DEBIAN/control' - 'echo "Version: ${vers}" >> ./OpenMediaCenter/DEBIAN/control'
- chmod -R 0775 * - chmod -R 0775 *
- dpkg-deb --build OpenMediaCenter - dpkg-deb --build OpenMediaCenter
@ -96,21 +108,54 @@ Debian_Server:
- Minimize_Frontend - Minimize_Frontend
- Build_Backend - Build_Backend
Test_Server: .Test_Server_Common:
stage: deploy stage: deploy
image: luki42/alpineopenssh:latest image: luki42/ssh:latest
needs: needs:
- Frontend_Tests - Frontend_Tests
- Backend_Tests - Backend_Tests
- Debian_Server - Debian_Server
only:
- master
script: script:
- eval $(ssh-agent -s) - eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY") - echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh - mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config' - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- scp deb/OpenMediaCenter-*.deb root@192.168.0.42:/tmp/ - scp deb/OpenMediaCenter-*.deb root@192.168.0.42:/tmp/
- ssh root@192.168.0.42 "DEBIAN_FRONTEND=noninteractive apt-get --reinstall -y -qq install /tmp/OpenMediaCenter-*.deb && rm /tmp/OpenMediaCenter-*.deb" - ssh root@192.168.0.42 "DEBIAN_FRONTEND=noninteractive apt-get --reinstall -y -qq install /tmp/OpenMediaCenter-*.deb && rm /tmp/OpenMediaCenter-*.deb"
allow_failure: true allow_failure: true
Test_Server_CD:
extends: .Test_Server_Common
only:
refs:
- master
Test_Server_MANUAL:
extends: .Test_Server_Common
when: manual
.Test_Server_2_Common:
stage: deploy
image: luki42/ssh:latest
needs:
- Frontend_Tests
- Backend_Tests
- Debian_Server
script:
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY_2" | 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.44:/tmp/
- ssh root@192.168.0.44 "DEBIAN_FRONTEND=noninteractive apt-get --reinstall -y -qq install /tmp/OpenMediaCenter-*.deb && rm /tmp/OpenMediaCenter-*.deb"
allow_failure: true
Test_Server_2_CD:
extends: .Test_Server_2_Common
only:
refs:
- master
Test_Server_2_MANUAL:
extends: .Test_Server_2_Common
when: manual

10
.prettierrc.js Normal file
View File

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

View File

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

10
apiGo/api/API.go Normal file
View File

@ -0,0 +1,10 @@
package api
func AddHandlers() {
addVideoHandlers()
addSettingsHandlers()
addTagHandlers()
addActorsHandlers()
addTvshowHandlers()
addUploadHandler()
}

View File

@ -2,42 +2,97 @@ package api
import ( import (
"fmt" "fmt"
"openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/api/types" "openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
) )
func AddActorsHandlers() { func addActorsHandlers() {
saveActorsToDB() saveActorsToDB()
getActorsFromDB() getActorsFromDB()
} }
func getActorsFromDB() { func getActorsFromDB() {
AddHandler("getAllActors", ActorNode, nil, func() []byte { /**
query := "SELECT actor_id, name, thumbnail FROM actors" * @api {post} /api/actor [getAllActors]
return jsonify(readActorsFromResultset(database.Query(query))) * @apiDescription Get all available Actors
* @apiName getAllActors
* @apiGroup Actor
*
* @apiSuccess {Object[]} . Array of Actors available
* @apiSuccess {uint32} .ActorId Actor Id
* @apiSuccess {string} .Name Actor Name
* @apiSuccess {string} .Thumbnail Portrait Thumbnail
*/
api.AddHandler("getAllActors", api.ActorNode, api.PermUser, func(context api.Context) {
query := "SELECT actor_id, name, thumbnail FROM actors ORDER BY name ASC"
context.Json(readActorsFromResultset(database.Query(query)))
}) })
var gaov struct { /**
MovieId int * @api {post} /api/actor [getActorsOfVideo]
} * @apiDescription Get all actors playing in one video
AddHandler("getActorsOfVideo", ActorNode, &gaov, func() []byte { * @apiName getActorsOfVideo
* @apiGroup Actor
*
* @apiParam {int} MovieId ID of video
*
* @apiSuccess {Object[]} . Array of Actors available
* @apiSuccess {uint32} .ActorId Actor Id
* @apiSuccess {string} .Name Actor Name
* @apiSuccess {string} .Thumbnail Portrait Thumbnail
*/
api.AddHandler("getActorsOfVideo", api.ActorNode, api.PermUser, func(context api.Context) {
var args struct {
MovieId int
}
err := api.DecodeRequest(context.GetRequest(), &args)
if err != nil {
context.Text("failed to decode request")
return
}
query := fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos query := fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=%d`, gaov.MovieId) WHERE actors_videos.video_id=%d`, args.MovieId)
return jsonify(readActorsFromResultset(database.Query(query))) context.Json(readActorsFromResultset(database.Query(query)))
}) })
var gai struct { /**
ActorId int * @api {post} /api/actor [getActorInfo]
} * @apiDescription Get all infos for an actor
AddHandler("getActorInfo", ActorNode, &gai, func() []byte { * @apiName getActorInfo
* @apiGroup Actor
*
* @apiParam {int} ActorId ID of Actor
*
* @apiSuccess {VideoUnloadedType[]} Videos Array of Videos this actor plays in
* @apiSuccess {uint32} Videos.MovieId Video Id
* @apiSuccess {string} Videos.MovieName Video Name
*
* @apiSuccess {Info} Info Infos about the actor
* @apiSuccess {uint32} Info.ActorId Actor Id
* @apiSuccess {string} Info.Name Actor Name
* @apiSuccess {string} Info.Thumbnail Actor Thumbnail
*/
api.AddHandler("getActorInfo", api.ActorNode, api.PermUser, func(context api.Context) {
var args struct {
ActorId int
}
err := api.DecodeRequest(context.GetRequest(), &args)
if err != nil {
context.Error("unable to decode request")
return
}
query := fmt.Sprintf(`SELECT movie_id, movie_name FROM actors_videos query := fmt.Sprintf(`SELECT movie_id, movie_name FROM actors_videos
JOIN videos v on v.movie_id = actors_videos.video_id JOIN videos v on v.movie_id = actors_videos.video_id
WHERE actors_videos.actor_id=%d`, gai.ActorId) WHERE actors_videos.actor_id=%d`, args.ActorId)
videos := readVideosFromResultset(database.Query(query)) videos := readVideosFromResultset(database.Query(query))
query = fmt.Sprintf("SELECT actor_id, name, thumbnail FROM actors WHERE actor_id=%d", gai.ActorId) query = fmt.Sprintf("SELECT actor_id, name, thumbnail FROM actors WHERE actor_id=%d", args.ActorId)
actor := readActorsFromResultset(database.Query(query))[0] actor := readActorsFromResultset(database.Query(query))[0]
var result = struct { var result = struct {
@ -48,25 +103,55 @@ func getActorsFromDB() {
Info: actor, Info: actor,
} }
return jsonify(result) context.Json(result)
}) })
} }
func saveActorsToDB() { func saveActorsToDB() {
var ca struct { /**
ActorName string * @api {post} /api/video [createActor]
} * @apiDescription Create a new Actor
AddHandler("createActor", ActorNode, &ca, func() []byte { * @apiName createActor
* @apiGroup Actor
*
* @apiParam {string} ActorName Name of new Actor
*
* @apiSuccess {string} result 'success' if successfully or Error message if not
*/
api.AddHandler("createActor", api.ActorNode, api.PermUser, func(context api.Context) {
var args struct {
ActorName string
}
api.DecodeRequest(context.GetRequest(), &args)
query := "INSERT IGNORE INTO actors (name) VALUES (?)" query := "INSERT IGNORE INTO actors (name) VALUES (?)"
return database.SuccessQuery(query, ca.ActorName) // todo bit ugly
context.Text(string(database.SuccessQuery(query, args.ActorName)))
}) })
var aatv struct { /**
ActorId int * @api {post} /api/video [addActorToVideo]
MovieId int * @apiDescription Add Actor to Video
} * @apiName addActorToVideo
AddHandler("addActorToVideo", ActorNode, &aatv, func() []byte { * @apiGroup Actor
query := fmt.Sprintf("INSERT IGNORE INTO actors_videos (actor_id, video_id) VALUES (%d,%d)", aatv.ActorId, aatv.MovieId) *
return database.SuccessQuery(query) * @apiParam {int} ActorId Id of Actor
* @apiParam {int} MovieId Id of Movie to add to
*
* @apiSuccess {string} result 'success' if successfully or Error message if not
*/
api.AddHandler("addActorToVideo", api.ActorNode, api.PermUser, func(context api.Context) {
var args struct {
ActorId int
MovieId int
}
err := api.DecodeRequest(context.GetRequest(), &args)
if err != nil {
context.Error("unable to decode request")
return
}
query := fmt.Sprintf("INSERT IGNORE INTO actors_videos (actor_id, video_id) VALUES (%d,%d)", args.ActorId, args.MovieId)
context.Text(string(database.SuccessQuery(query)))
}) })
} }

View File

@ -1,105 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"openmediacenter/apiGo/api/oauth"
)
const APIPREFIX = "/api"
const (
VideoNode = iota
TagNode = iota
SettingsNode = iota
ActorNode = 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(port uint16) {
http.Handle(APIPREFIX+"/video", oauth.ValidateToken(videoHandler))
http.Handle(APIPREFIX+"/tags", oauth.ValidateToken(tagHandler))
http.Handle(APIPREFIX+"/settings", oauth.ValidateToken(settingsHandler))
http.Handle(APIPREFIX+"/actor", oauth.ValidateToken(actorHandler))
// initialize oauth service and add corresponding auth routes
oauth.InitOAuth()
fmt.Printf("OpenMediacenter server up and running on port %d\n", port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
}
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 actorHandler(rw http.ResponseWriter, req *http.Request) {
handlefunc(rw, req, ActorNode)
}
func videoHandler(rw http.ResponseWriter, req *http.Request) {
handlefunc(rw, req, VideoNode)
}
func tagHandler(rw http.ResponseWriter, req *http.Request) {
handlefunc(rw, req, TagNode)
}
func settingsHandler(rw http.ResponseWriter, req *http.Request) {
handlefunc(rw, req, SettingsNode)
}
func handlefunc(rw http.ResponseWriter, req *http.Request, node int) {
// only allow post requests
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))
}

View File

@ -1,66 +0,0 @@
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")
}
}

69
apiGo/api/FileUpload.go Normal file
View File

@ -0,0 +1,69 @@
package api
import (
"fmt"
"io"
"openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/videoparser"
"os"
)
func addUploadHandler() {
api.AddHandler("fileupload", api.VideoNode, api.PermUser, func(ctx api.Context) {
// get path where to store videos to
mSettings, PathPrefix, _ := database.GetSettings()
req := ctx.GetRequest()
mr, err := req.MultipartReader()
if err != nil {
ctx.Errorf("incorrect request!")
return
}
videoparser.InitDeps(&mSettings)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
// only allow valid extensions
if !videoparser.ValidVideoSuffix(part.FileName()) {
continue
}
vidpath := PathPrefix + mSettings.VideoPath + part.FileName()
dst, err := os.OpenFile(vidpath, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
ctx.Error("error opening file")
return
}
fmt.Printf("Uploading file %s\n", part.FileName())
// so now loop through every appended file and upload
buffer := make([]byte, 100000)
for {
cBytes, err := part.Read(buffer)
if cBytes > 0 {
dst.Write(buffer[0:cBytes])
}
if err == io.EOF {
fmt.Printf("Finished uploading file %s\n", part.FileName())
go videoparser.ProcessVideo(part.FileName())
break
}
}
_ = dst.Close()
}
ctx.Json(struct {
Message string
}{Message: "finished all files"})
})
}

View File

@ -2,7 +2,6 @@ package api
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"openmediacenter/apiGo/api/types" "openmediacenter/apiGo/api/types"
) )
@ -14,7 +13,8 @@ func readVideosFromResultset(rows *sql.Rows) []types.VideoUnloadedType {
var vid types.VideoUnloadedType var vid types.VideoUnloadedType
err := rows.Scan(&vid.MovieId, &vid.MovieName) err := rows.Scan(&vid.MovieId, &vid.MovieName)
if err != nil { if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app fmt.Println(err.Error())
return nil
} }
result = append(result, vid) result = append(result, vid)
} }
@ -31,7 +31,7 @@ func readTagsFromResultset(rows *sql.Rows) []types.Tag {
var tag types.Tag var tag types.Tag
err := rows.Scan(&tag.TagId, &tag.TagName) err := rows.Scan(&tag.TagId, &tag.TagName)
if err != nil { if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app panic(err.Error()) // proper Error handling instead of panic in your app
} }
result = append(result, tag) result = append(result, tag)
} }
@ -52,7 +52,7 @@ func readActorsFromResultset(rows *sql.Rows) []types.Actor {
actor.Thumbnail = string(thumbnail) actor.Thumbnail = string(thumbnail)
} }
if err != nil { if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app panic(err.Error()) // proper Error handling instead of panic in your app
} }
result = append(result, actor) result = append(result, actor)
} }
@ -61,11 +61,18 @@ func readActorsFromResultset(rows *sql.Rows) []types.Actor {
return result return result
} }
func jsonify(v interface{}) []byte { // ID - Name : pay attention to the order!
// jsonify results func readTVshowsFromResultset(rows *sql.Rows) []types.TVShow {
str, err := json.Marshal(v) result := []types.TVShow{}
if err != nil { for rows.Next() {
fmt.Println("Error while Jsonifying return object: " + err.Error()) 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)
} }
return str rows.Close()
return result
} }

View File

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

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

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

View File

@ -2,73 +2,135 @@ package api
import ( import (
"fmt" "fmt"
"openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
"regexp" "regexp"
) )
func AddTagHandlers() { func addTagHandlers() {
getFromDB() getFromDB()
addToDB() addToDB()
deleteFromDB() deleteFromDB()
} }
func deleteFromDB() { func deleteFromDB() {
var dT struct { /**
TagId int * @api {post} /api/tags [deleteTag]
Force bool * @apiDescription Start Database video reindex Job
} * @apiName deleteTag
AddHandler("deleteTag", TagNode, &dT, func() []byte { * @apiGroup Tags
*
* @apiParam {bool} [Force] force delete tag with its constraints
* @apiParam {int} TagId id of tag to delete
*
* @apiSuccess {string} result 'success' if successfully or Error message if not
*/
api.AddHandler("deleteTag", api.TagNode, api.PermUser, func(context api.Context) {
var args struct {
TagId int
Force bool
}
err := api.DecodeRequest(context.GetRequest(), &args)
if err != nil {
context.Text("unable to decode request")
return
}
// delete key constraints first // delete key constraints first
if dT.Force { if args.Force {
query := fmt.Sprintf("DELETE FROM video_tags WHERE tag_id=%d", dT.TagId) query := fmt.Sprintf("DELETE FROM video_tags WHERE tag_id=%d", args.TagId)
err := database.Edit(query) err := database.Edit(query)
// respond only if result not successful // respond only if result not successful
if err != nil { if err != nil {
return database.ManualSuccessResponse(err) context.Text(string(database.ManualSuccessResponse(err)))
return
} }
} }
query := fmt.Sprintf("DELETE FROM tags WHERE tag_id=%d", dT.TagId) query := fmt.Sprintf("DELETE FROM tags WHERE tag_id=%d", args.TagId)
err := database.Edit(query) err = database.Edit(query)
if err == nil { if err == nil {
// return if successful // return if successful
return database.ManualSuccessResponse(err) context.Text(string(database.ManualSuccessResponse(err)))
} else { } else {
// check with regex if its the key constraint error // check with regex if its the key constraint Error
r, _ := regexp.Compile("^.*a foreign key constraint fails.*$") r := regexp.MustCompile("^.*a foreign key constraint fails.*$")
if r.MatchString(err.Error()) { if r.MatchString(err.Error()) {
return []byte(`{"result":"not empty tag"}`) context.Text(string(database.ManualSuccessResponse(fmt.Errorf("not empty tag"))))
} else { } else {
return database.ManualSuccessResponse(err) context.Text(string(database.ManualSuccessResponse(err)))
} }
} }
}) })
} }
func getFromDB() { func getFromDB() {
AddHandler("getAllTags", TagNode, nil, func() []byte { /**
query := "SELECT tag_id,tag_name from tags" * @api {post} /api/tags [getAllTags]
return jsonify(readTagsFromResultset(database.Query(query))) * @apiDescription get all available Tags
* @apiName getAllTags
* @apiGroup Tags
*
* @apiSuccess {Object[]} array of tag objects
* @apiSuccess {uint32} TagId
* @apiSuccess {string} TagName name of the Tag
*/
api.AddHandler("getAllTags", api.TagNode, api.PermUser, func(context api.Context) {
query := "SELECT tag_id,tag_name from tags ORDER BY tag_name ASC"
context.Json(readTagsFromResultset(database.Query(query)))
}) })
} }
func addToDB() { func addToDB() {
var ct struct { /**
TagName string * @api {post} /api/tags [createTag]
} * @apiDescription create a new tag
AddHandler("createTag", TagNode, &ct, func() []byte { * @apiName createTag
* @apiGroup Tags
*
* @apiParam {string} TagName name of the tag
*
* @apiSuccess {string} result 'success' if successfully or Error message if not
*/
api.AddHandler("createTag", api.TagNode, api.PermUser, func(context api.Context) {
var args struct {
TagName string
}
err := api.DecodeRequest(context.GetRequest(), &args)
if err != nil {
context.Text("unable to decode request")
return
}
query := "INSERT IGNORE INTO tags (tag_name) VALUES (?)" query := "INSERT IGNORE INTO tags (tag_name) VALUES (?)"
return database.SuccessQuery(query, ct.TagName) context.Text(string(database.SuccessQuery(query, args.TagName)))
}) })
var at struct { /**
MovieId int * @api {post} /api/tags [addTag]
TagId int * @apiDescription Add new tag to video
} * @apiName addTag
AddHandler("addTag", TagNode, &at, func() []byte { * @apiGroup Tags
*
* @apiParam {int} TagId Tag id to add to video
* @apiParam {int} MovieId Video Id of video to add tag to
*
* @apiSuccess {string} result 'success' if successfully or Error message if not
*/
api.AddHandler("addTag", api.TagNode, api.PermUser, func(context api.Context) {
var args struct {
MovieId int
TagId int
}
err := api.DecodeRequest(context.GetRequest(), &args)
if err != nil {
context.Text("unable to decode request")
return
}
query := "INSERT IGNORE INTO video_tags(tag_id, video_id) VALUES (?,?)" query := "INSERT IGNORE INTO video_tags(tag_id, video_id) VALUES (?,?)"
return database.SuccessQuery(query, at.TagId, at.MovieId) context.Text(string(database.SuccessQuery(query, args.TagId, args.MovieId)))
}) })
} }

View File

@ -4,134 +4,311 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/api/types" "openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
"os"
"strconv" "strconv"
"strings"
) )
func AddVideoHandlers() { func addVideoHandlers() {
getVideoHandlers() getVideoHandlers()
loadVideosHandlers() loadVideosHandlers()
addToVideoHandlers() addToVideoHandlers()
} }
func getVideoHandlers() { func getVideoHandlers() {
var mrq struct { /**
Tag int * @api {post} /api/video [getMovies]
} * @apiDescription Request available Videos
AddHandler("getMovies", VideoNode, &mrq, func() []byte { * @apiName GetMovies
* @apiGroup video
*
* @apiParam {int} [Tag=1] id of VideoTag to get videos (1=all)
*
* @apiSuccess {Object[]} Videos List of Videos
* @apiSuccess {number} Videos.MovieId Id of Video
* @apiSuccess {String} Videos.MovieName Name of video
* @apiSuccess {String} TagName Name of the Tag returned
*/
api.AddHandler("getMovies", api.VideoNode, api.PermUser, func(context api.Context) {
var args struct {
Tag uint32
Sort uint8
}
err := api.DecodeRequest(context.GetRequest(), &args)
if err != nil {
context.Text("unable to decode request")
return
}
const (
date = iota
likes = iota
random = iota
names = iota
length = iota
)
// if wrong number passed no sorting is performed
var SortClause = ""
switch args.Sort {
case date:
SortClause = "ORDER BY create_date DESC, movie_name"
break
case likes:
SortClause = "ORDER BY likes DESC"
break
case random:
SortClause = "ORDER BY RAND()"
break
case names:
SortClause = "ORDER BY movie_name"
break
case length:
SortClause = "ORDER BY length DESC"
break
}
var query string var query string
// 1 is the id of the ALL tag // 1 is the id of the ALL tag
if mrq.Tag != 1 { if args.Tag != 1 {
query = fmt.Sprintf(`SELECT movie_id,movie_name FROM videos query = fmt.Sprintf(`SELECT movie_id,movie_name,previewratio,t.tag_name FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_id = '%d' WHERE t.tag_id = %d %s`, args.Tag, SortClause)
ORDER BY likes DESC, create_date, movie_name`, mrq.Tag)
} else { } else {
query = "SELECT movie_id,movie_name FROM videos ORDER BY create_date DESC, movie_name" query = fmt.Sprintf("SELECT movie_id,movie_name,previewratio, (SELECT 'All' as tag_name) FROM videos %s", SortClause)
} }
result := readVideosFromResultset(database.Query(query)) var result struct {
// jsonify results Videos []types.VideoUnloadedType
str, _ := json.Marshal(result) TagName string
return str }
rows := database.Query(query)
vids := []types.VideoUnloadedType{}
var name string
for rows.Next() {
var vid types.VideoUnloadedType
err := rows.Scan(&vid.MovieId, &vid.MovieName, &vid.Ratio, &name)
if err != nil {
return
}
vids = append(vids, vid)
}
if rows.Close() != nil {
return
}
// if the tag id doesn't exist the query won't return a name
if name == "" {
return
}
result.Videos = vids
result.TagName = name
context.Json(result)
}) })
var rtn struct { /**
Movieid int * @api {post} /api/video [readThumbnail]
} * @apiDescription Load Thubnail of specific Video
AddHandler("readThumbnail", VideoNode, &rtn, func() []byte { * @apiName readThumbnail
* @apiGroup video
*
* @apiParam {int} Movieid id of video to load thumbnail
*
* @apiSuccess {string} . Base64 encoded Thubnail
*/
api.AddHandler("readThumbnail", api.VideoNode, api.PermUser, func(context api.Context) {
var args struct {
Movieid int
}
err := api.DecodeRequest(context.GetRequest(), &args)
if err != nil {
context.Text("unable to decode request")
return
}
var pic []byte var pic []byte
query := fmt.Sprintf("SELECT thumbnail FROM videos WHERE movie_id='%d'", rtn.Movieid) query := fmt.Sprintf("SELECT thumbnail FROM videos WHERE movie_id=%d", args.Movieid)
err := database.QueryRow(query).Scan(&pic) err = database.QueryRow(query).Scan(&pic)
if err != nil { if err != nil {
fmt.Printf("the thumbnail of movie id %d couldn't be found", rtn.Movieid) fmt.Printf("the thumbnail of movie id %d couldn't be found", args.Movieid)
return nil return
} }
return pic context.Text(string(pic))
}) })
var grm struct { /**
Number int * @api {post} /api/video [getRandomMovies]
} * @apiDescription Load random videos
AddHandler("getRandomMovies", VideoNode, &grm, func() []byte { * @apiName getRandomMovies
* @apiGroup video
*
* @apiParam {int} Number number of random videos to load
*
* @apiSuccess {Object[]} Tags Array of tags occuring in selection
* @apiSuccess {string} Tags.TagName Tagname
* @apiSuccess {uint32} Tags.TagId Tag ID
*
* @apiSuccess {Object[]} Videos Array of the videos
* @apiSuccess {string} Videos.MovieName Video Name
* @apiSuccess {int} Videos.MovieId Video ID
*/
api.AddHandler("getRandomMovies", api.VideoNode, api.PermUser, func(context api.Context) {
var args struct {
Number int
TagFilter []uint32
}
if api.DecodeRequest(context.GetRequest(), &args) != nil {
context.Text("unable to decode request")
return
}
var result struct { var result struct {
Tags []types.Tag Tags []types.Tag
Videos []types.VideoUnloadedType Videos []types.VideoUnloadedType
} }
query := fmt.Sprintf("SELECT movie_id,movie_name FROM videos ORDER BY RAND() LIMIT %d", grm.Number) whereclause := "WHERE 1"
result.Videos = readVideosFromResultset(database.Query(query)) if len(args.TagFilter) > 0 {
d, _ := json.Marshal(args.TagFilter)
vals := strings.Trim(string(d), "[]")
var ids string whereclause = fmt.Sprintf("WHERE tag_id IN (%s)", vals)
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(`
query = fmt.Sprintf(`SELECT t.tag_name,t.tag_id FROM video_tags SELECT video_tags.video_id,v.movie_name FROM video_tags join videos v on v.movie_id = video_tags.video_id
%s
group by video_id
ORDER BY RAND()
LIMIT %d`, whereclause, args.Number)
result.Videos = readVideosFromResultset(database.Query(query))
if len(result.Videos) > 0 {
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 INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE %s WHERE %s
GROUP BY t.tag_id`, ids) GROUP BY t.tag_id`, ids)
rows := database.Query(query) rows := database.Query(query)
if rows != nil {
for rows.Next() { for rows.Next() {
var tag types.Tag var tag types.Tag
err := rows.Scan(&tag.TagName, &tag.TagId) err := rows.Scan(&tag.TagName, &tag.TagId)
if err != nil { if err != nil {
panic(err.Error()) // proper error handling instead of panic in your app panic(err.Error()) // proper Error handling instead of panic in your app
}
// append to final array
result.Tags = append(result.Tags, tag)
}
} }
// append to final array } else {
result.Tags = append(result.Tags, tag) result.Tags = []types.Tag{}
} }
// jsonify results context.Json(result)
str, _ := json.Marshal(result)
return str
}) })
var gsk struct { /**
KeyWord string * @api {post} /api/video [getSearchKeyWord]
} * @apiDescription Get videos for search keyword
AddHandler("getSearchKeyWord", VideoNode, &gsk, func() []byte { * @apiName getSearchKeyWord
* @apiGroup video
*
* @apiParam {string} KeyWord Keyword to search for
*
* @apiSuccess {Object[]} . List of Videos
* @apiSuccess {number} .MovieId Id of Video
* @apiSuccess {String} .MovieName Name of video
*/
api.AddHandler("getSearchKeyWord", api.VideoNode, api.PermUser, func(context api.Context) {
var args struct {
KeyWord string
}
if api.DecodeRequest(context.GetRequest(), &args) != nil {
context.Text("unable to decode request")
return
}
query := fmt.Sprintf(`SELECT movie_id,movie_name FROM videos query := fmt.Sprintf(`SELECT movie_id,movie_name FROM videos
WHERE movie_name LIKE '%%%s%%' WHERE movie_name LIKE '%%%s%%'
ORDER BY likes DESC, create_date DESC, movie_name`, gsk.KeyWord) ORDER BY likes DESC, create_date DESC, movie_name`, args.KeyWord)
context.Json(readVideosFromResultset(database.Query(query)))
result := readVideosFromResultset(database.Query(query))
// jsonify results
str, _ := json.Marshal(result)
return str
}) })
} }
// function to handle stuff for loading specific videos and startdata // function to handle stuff for loading specific videos and startdata
func loadVideosHandlers() { func loadVideosHandlers() {
var lv struct { /**
MovieId int * @api {post} /api/video [loadVideo]
} * @apiDescription Load all data for a specific video
AddHandler("loadVideo", VideoNode, &lv, func() []byte { * @apiName loadVideo
query := fmt.Sprintf(`SELECT movie_name,movie_url,movie_id,thumbnail,poster,likes,quality,length * @apiGroup video
FROM videos WHERE movie_id=%d`, lv.MovieId) *
* @apiParam {int} MovieId ID of video
*
* @apiSuccess {string} MovieName Videoname
* @apiSuccess {uint32} MovieId Video ID
* @apiSuccess {string} MovieUrl Url to video file
* @apiSuccess {string} Poster Base64 encoded Poster
* @apiSuccess {uint64} Likes Number of likes
* @apiSuccess {uint16} Quality Video FrameWidth
* @apiSuccess {uint16} Length Video Length in seconds
*
*
* @apiSuccess {Object[]} Tags Array of tags of video
* @apiSuccess {string} Tags.TagName Tagname
* @apiSuccess {uint32} Tags.TagId Tag ID
*
* @apiSuccess {Object[]} SuggestedTag Array of tags for quick add suggestions
* @apiSuccess {string} SuggestedTag.TagName Tagname
* @apiSuccess {uint32} SuggestedTag.TagId Tag ID
*
* @apiSuccess {Object[]} Actors Array of Actors playing in this video
* @apiSuccess {uint32} Actors.ActorId Actor Id
* @apiSuccess {string} Actors.Name Actor Name
* @apiSuccess {string} Actors.Thumbnail Portrait Thumbnail
*/
api.AddHandler("loadVideo", api.VideoNode, api.PermUser, func(context api.Context) {
var args struct {
MovieId int
}
if api.DecodeRequest(context.GetRequest(), &args) != nil {
context.Text("unable to decode request")
return
}
query := fmt.Sprintf(`SELECT movie_name,movie_url,movie_id,thumbnail,poster,likes,quality,length,release_date
FROM videos WHERE movie_id=%d`, args.MovieId)
var res types.FullVideoType var res types.FullVideoType
var poster []byte var poster []byte
var thumbnail []byte var thumbnail []byte
err := database.QueryRow(query).Scan(&res.MovieName, &res.MovieUrl, &res.MovieId, &thumbnail, &poster, &res.Likes, &res.Quality, &res.Length) err := database.QueryRow(query).Scan(&res.MovieName, &res.MovieUrl, &res.MovieId, &thumbnail, &poster, &res.Likes, &res.Quality, &res.Length, &res.ReleaseDate)
if err != nil { if err != nil {
fmt.Printf("error getting full data list of videoid - %d", lv.MovieId) fmt.Printf("Error getting full data list of videoid - %d", args.MovieId)
fmt.Println(err.Error()) fmt.Println(err.Error())
return nil return
} }
// we ned to urlencode the movieurl // we ned to urlencode the movieurl
@ -149,7 +326,7 @@ func loadVideosHandlers() {
query = fmt.Sprintf(`SELECT t.tag_id, t.tag_name FROM video_tags query = fmt.Sprintf(`SELECT t.tag_id, t.tag_name FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE video_tags.video_id=%d WHERE video_tags.video_id=%d
GROUP BY t.tag_id`, lv.MovieId) GROUP BY t.tag_id`, args.MovieId)
res.Tags = readTagsFromResultset(database.Query(query)) res.Tags = readTagsFromResultset(database.Query(query))
@ -158,23 +335,34 @@ func loadVideosHandlers() {
SELECT video_tags.tag_id FROM video_tags SELECT video_tags.tag_id FROM video_tags
WHERE video_id=%d) WHERE video_id=%d)
ORDER BY rand() ORDER BY rand()
LIMIT 5`, lv.MovieId) LIMIT 5`, args.MovieId)
res.SuggestedTag = readTagsFromResultset(database.Query(query)) res.SuggestedTag = readTagsFromResultset(database.Query(query))
// query the actors corresponding to video // query the actors corresponding to video
query = fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos query = fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=%d`, lv.MovieId) WHERE actors_videos.video_id=%d`, args.MovieId)
res.Actors = readActorsFromResultset(database.Query(query)) res.Actors = readActorsFromResultset(database.Query(query))
// jsonify results context.Json(res)
str, _ := json.Marshal(res)
return str
}) })
AddHandler("getStartData", VideoNode, nil, func() []byte { /**
* @api {post} /api/video [getStartData]
* @apiDescription Get general video informations at start
* @apiName getStartData
* @apiGroup video
*
* @apiSuccess {uint32} VideoNr Total nr of videos
* @apiSuccess {uint32} FullHdNr number of FullHD videos
* @apiSuccess {uint32} HDNr number of HD videos
* @apiSuccess {uint32} SDNr number of SD videos
* @apiSuccess {uint32} DifferentTags number of different Tags available
* @apiSuccess {uint32} Tagged number of different Tags assigned
*/
api.AddHandler("getStartData", api.VideoNode, api.PermUser, func(context api.Context) {
var result types.StartData var result types.StartData
// query settings and infotile values // query settings and infotile values
query := ` query := `
@ -208,26 +396,90 @@ func loadVideosHandlers() {
_ = database.QueryRow(query).Scan(&result.VideoNr, &result.Tagged, &result.HDNr, &result.FullHdNr, &result.SDNr, &result.DifferentTags) _ = database.QueryRow(query).Scan(&result.VideoNr, &result.Tagged, &result.HDNr, &result.FullHdNr, &result.SDNr, &result.DifferentTags)
// jsonify results context.Json(result)
str, _ := json.Marshal(result)
return str
}) })
} }
func addToVideoHandlers() { func addToVideoHandlers() {
var al struct { /**
MovieId int * @api {post} /api/video [addLike]
} * @apiDescription Add a like to a video
AddHandler("addLike", VideoNode, &al, func() []byte { * @apiName addLike
query := fmt.Sprintf("update videos set likes = likes + 1 where movie_id = %d", al.MovieId) * @apiGroup video
return database.SuccessQuery(query) *
* @apiParam {int} MovieId ID of video
*
* @apiSuccess {string} result 'success' if successfully or Error message if not
*/
api.AddHandler("addLike", api.VideoNode, api.PermUser, func(context api.Context) {
var args struct {
MovieId int
}
if api.DecodeRequest(context.GetRequest(), &args) != nil {
context.Text("unable to decode request")
return
}
query := fmt.Sprintf("update videos set likes = likes + 1 where movie_id = %d", args.MovieId)
context.Text(string(database.SuccessQuery(query)))
}) })
var dv struct { /**
MovieId int * @api {post} /api/video [deleteVideo]
} * @apiDescription Delete a specific video from database
AddHandler("deleteVideo", VideoNode, &dv, func() []byte { * @apiName deleteVideo
query := fmt.Sprintf("DELETE FROM videos WHERE movie_id=%d", dv.MovieId) * @apiGroup video
return database.SuccessQuery(query) *
* @apiParam {int} MovieId ID of video
* @apiParam {bool} FullyDelete Delete video from disk?
*
* @apiSuccess {string} result 'success' if successfully or Error message if not
*/
api.AddHandler("deleteVideo", api.VideoNode, api.PermUser, func(context api.Context) {
var args struct {
MovieId int
FullyDelete bool
}
if api.DecodeRequest(context.GetRequest(), &args) != nil {
context.Text("unable to decode request")
return
}
// delete tag constraints
query := fmt.Sprintf("DELETE FROM video_tags WHERE video_id=%d", args.MovieId)
err := database.Edit(query)
// delete actor constraints
query = fmt.Sprintf("DELETE FROM actors_videos WHERE video_id=%d", args.MovieId)
err = database.Edit(query)
// respond only if result not successful
if err != nil {
context.Text(string(database.ManualSuccessResponse(err)))
}
// only allow deletion of video if cli flag is set, independent of passed api arg
if config.GetConfig().Features.FullyDeletableVideos && args.FullyDelete {
// get physical path of video to delete
query = fmt.Sprintf("SELECT movie_url FROM videos WHERE movie_id=%d", args.MovieId)
var vidpath string
err := database.QueryRow(query).Scan(&vidpath)
if err != nil {
context.Text(string(database.ManualSuccessResponse(err)))
}
sett, videoprefix, _ := database.GetSettings()
assembledPath := videoprefix + sett.VideoPath + vidpath
err = os.Remove(assembledPath)
if err != nil {
fmt.Printf("unable to delete file: %s -- %s\n", assembledPath, err.Error())
context.Text(string(database.ManualSuccessResponse(err)))
}
}
// delete video row from db
query = fmt.Sprintf("DELETE FROM videos WHERE movie_id=%d", args.MovieId)
context.Text(string(database.SuccessQuery(query)))
}) })
} }

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

@ -0,0 +1,64 @@
package api
import (
"fmt"
"net/http"
"openmediacenter/apiGo/database/settings"
)
const (
VideoNode = "video"
TagNode = "tags"
SettingsNode = "settings"
ActorNode = "actor"
TVShowNode = "tv"
LoginNode = "login"
)
func AddHandler(action string, apiNode string, perm Perm, handler func(ctx Context)) {
http.Handle(fmt.Sprintf("/api/%s/%s", apiNode, action), http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
srvPwd := settings.GetPassword()
if srvPwd == nil {
// no password set
ctx := &apicontext{writer: writer, responseWritten: false, request: request, userid: -1, permid: PermUnauthorized}
callHandler(ctx, handler, writer)
} else {
tokenheader := request.Header.Get("Token")
id := -1
permid := PermUnauthorized
// check token if token provided
if tokenheader != "" {
id, permid = TokenValid(request.Header.Get("Token"))
}
ctx := &apicontext{writer: writer, responseWritten: false, request: request, userid: id, permid: permid}
// check if rights are sufficient to perform the action
if permid <= perm {
callHandler(ctx, handler, writer)
} else {
ctx.Error("insufficient permissions")
}
}
}))
}
func callHandler(ctx *apicontext, handler func(ctx Context), writer http.ResponseWriter) {
handler(ctx)
if !ctx.responseWritten {
// none of the response functions called so send default response
ctx.Error("Unknown server Error occured")
writer.WriteHeader(501)
}
}
func ServerInit(port uint16) error {
// initialize auth service and add corresponding auth routes
InitOAuth()
fmt.Printf("OpenMediacenter server up and running on port %d\n", port)
return http.ListenAndServe(fmt.Sprintf(":%d", port), nil)
}

100
apiGo/api/api/Auth.go Normal file
View File

@ -0,0 +1,100 @@
package api
import (
"fmt"
"github.com/dgrijalva/jwt-go"
"openmediacenter/apiGo/database"
"strconv"
"time"
)
type Perm uint8
const (
PermAdmin Perm = iota
PermUser
PermUnauthorized
)
func (p Perm) String() string {
return [...]string{"PermAdmin", "PermUser", "PermUnauthorized"}[p]
}
const SignKey = "89013f1753a6890c6090b09e3c23ff43"
const TokenExpireHours = 8760
type Token struct {
Token string
ExpiresAt int64
}
func TokenValid(token string) (int, Perm) {
t, err := jwt.ParseWithClaims(token, &jwt.StandardClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(SignKey), nil
})
if err != nil {
return -1, PermUnauthorized
}
claims := t.Claims.(*jwt.StandardClaims)
id, err := strconv.Atoi(claims.Issuer)
permid, err := strconv.Atoi(claims.Subject)
if err != nil {
return -1, PermUnauthorized
}
return id, Perm(permid)
}
func InitOAuth() {
AddHandler("login", LoginNode, PermUnauthorized, func(ctx Context) {
var t struct {
Password string
}
if DecodeRequest(ctx.GetRequest(), &t) != nil {
fmt.Println("Error accured while decoding Testrequest!!")
}
// empty check
if t.Password == "" {
ctx.Error("empty password")
return
}
// generate Argon2 Hash of passed pwd
HashPassword(t.Password)
// todo use hashed password
var password string
err := database.QueryRow("SELECT password FROM settings WHERE 1").Scan(&password)
if err != nil || t.Password != password {
ctx.Error("unauthorized")
return
}
expires := time.Now().Add(time.Hour * TokenExpireHours).Unix()
claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
Issuer: strconv.Itoa(int(0)),
Subject: strconv.Itoa(int(PermUser)),
ExpiresAt: expires,
})
token, err := claims.SignedString([]byte(SignKey))
if err != nil {
fmt.Println(err.Error())
ctx.Error("failed to generate authorization token")
return
}
type ResponseType struct {
Token Token
}
ctx.Json(Token{
Token: token,
ExpiresAt: expires,
})
})
}

65
apiGo/api/api/Context.go Normal file
View File

@ -0,0 +1,65 @@
package api
import (
"fmt"
"net/http"
)
type Context interface {
Json(t interface{})
Text(msg string)
Error(msg string)
Errorf(msg string, args ...interface{})
GetRequest() *http.Request
GetWriter() http.ResponseWriter
UserID() int
PermID() Perm
}
type apicontext struct {
writer http.ResponseWriter
request *http.Request
responseWritten bool
userid int
permid Perm
}
func (r *apicontext) GetRequest() *http.Request {
return r.request
}
func (r *apicontext) UserID() int {
return r.userid
}
func (r *apicontext) GetWriter() http.ResponseWriter {
return r.writer
}
func (r *apicontext) Json(t interface{}) {
r.writer.Write(Jsonify(t))
r.responseWritten = true
}
func (r *apicontext) Text(msg string) {
r.writer.Write([]byte(msg))
r.responseWritten = true
}
func (r *apicontext) Error(msg string) {
type Error struct {
Message string
}
r.writer.WriteHeader(500)
r.writer.Write(Jsonify(Error{Message: msg}))
r.responseWritten = true
}
func (r *apicontext) Errorf(msg string, args ...interface{}) {
r.Error(fmt.Sprintf(msg, &args))
}
func (r *apicontext) PermID() Perm {
return r.permid
}

13
apiGo/api/api/Hash.go Normal file
View File

@ -0,0 +1,13 @@
package api
import (
"encoding/hex"
"golang.org/x/crypto/argon2"
)
func HashPassword(pwd string) *string {
// todo generate random salt
hash := argon2.IDKey([]byte(pwd), []byte(SignKey), 3, 64*1024, 2, 32)
hexx := hex.EncodeToString(hash)
return &hexx
}

View File

@ -0,0 +1,11 @@
package api
import "testing"
func TestHashlength(t *testing.T) {
h := HashPassword("test")
if len(*h) != 64 {
t.Errorf("Invalid hash length: %d", len(*h))
}
}

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

@ -0,0 +1,31 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
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
}
// DecodeRequest decodes the request
func DecodeRequest(request *http.Request, arg interface{}) error {
buf := new(bytes.Buffer)
buf.ReadFrom(request.Body)
body := buf.String()
err := json.Unmarshal([]byte(body), &arg)
if err != nil {
fmt.Println("JSON decode Error" + err.Error())
}
return err
}

View File

@ -0,0 +1,22 @@
package api
import "testing"
func TestJsonify(t *testing.T) {
var obj = struct {
ID uint32
Str string
Boo bool
}{
ID: 42,
Str: "teststr",
Boo: true,
}
res := Jsonify(obj)
exp := `{"ID":42,"Str":"teststr","Boo":true}`
if string(res) != exp {
t.Errorf("Invalid json response: %s !== %s", string(res), exp)
}
}

View File

@ -1,67 +0,0 @@
package oauth
import (
"gopkg.in/oauth2.v3/errors"
"gopkg.in/oauth2.v3/manage"
"gopkg.in/oauth2.v3/models"
"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())
clientStore := store.NewClientStore()
// todo we need to check here if a password is enabled in db -- when yes set it here!
clientStore.Set("openmediacenter", &models.Client{
ID: "openmediacenter",
Secret: "openmediacenter",
Domain: "http://localhost:8081",
})
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 http.HandlerFunc) 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.ServeHTTP(w, r)
}
}

View File

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

206
apiGo/config/Config.go Normal file
View File

@ -0,0 +1,206 @@
package config
import (
"errors"
"flag"
"fmt"
"github.com/pelletier/go-toml/v2"
"os"
)
type DatabaseT struct {
DBName string
DBPassword string
DBUser string
DBPort uint16
DBHost string
}
type FeaturesT struct {
DisableTVSupport bool
FullyDeletableVideos bool
}
type GeneralT struct {
VerboseLogging bool
ReindexPrefix string
}
type FileConfT struct {
Database DatabaseT
General GeneralT
Features FeaturesT
}
func defaultConfig() *FileConfT {
return &FileConfT{
Database: DatabaseT{
DBName: "mediacenter",
DBPassword: "mediapassword",
DBUser: "mediacenteruser",
DBPort: 3306,
DBHost: "127.0.0.1",
},
General: GeneralT{
VerboseLogging: false,
ReindexPrefix: "/var/www/openmediacenter",
},
Features: FeaturesT{
DisableTVSupport: false,
FullyDeletableVideos: false,
},
}
}
var liveConf FileConfT
func Init() {
cfgname := "openmediacenter.cfg"
cfgpath := "/etc/"
// load config from disk
dat, err := os.ReadFile(cfgpath + cfgname)
if err != nil {
// handle error if not exists or no sufficient read access
if _, ok := err.(*os.PathError); ok {
// check if config exists on local dir
dat, err = os.ReadFile(cfgname)
if err != nil {
generateNewConfig(cfgpath, cfgname)
} else {
// ok decode local config
decodeConfig(&dat)
}
} else {
// other error
fmt.Println(err.Error())
}
} else {
decodeConfig(&dat)
}
handleCommandLineArguments()
}
func generateNewConfig(cfgpath string, cfgname string) {
// config really doesn't exist!
fmt.Printf("config not existing -- generating new empty config at %s%s\n", cfgpath, cfgname)
// generate new default config
obj, _ := toml.Marshal(defaultConfig())
liveConf = *defaultConfig()
err := os.WriteFile(cfgpath+cfgname, obj, 777)
if err != nil {
if errors.Is(err, os.ErrPermission) {
// permisson denied to create file try to create at current dir
err = os.WriteFile(cfgname, obj, 777)
if err != nil {
fmt.Println("failed to create default config file!")
} else {
fmt.Println("config file created at .")
}
} else {
fmt.Println(err.Error())
}
}
}
func decodeConfig(bin *[]byte) {
config := FileConfT{}
err := toml.Unmarshal(*bin, &config)
if err != nil {
fmt.Println(err)
liveConf = *defaultConfig()
} else {
fmt.Println("Successfully loaded config file!")
liveConf = config
}
}
// handleCommandLineArguments let cli args override the config
func handleCommandLineArguments() {
// get a defaultconfig obj to set defaults
dconf := defaultConfig()
const (
DBHost = "DBHost"
DBPort = "DBPort"
DBUser = "DBUser"
DBPassword = "DBPassword"
DBName = "DBName"
Verbose = "v"
ReindexPrefix = "ReindexPrefix"
DisableTVSupport = "DisableTVSupport"
FullyDeletableVideos = "FullyDeletableVideos"
)
dbhostPtr := flag.String(DBHost, dconf.Database.DBHost, "database host name")
dbPortPtr := flag.Int(DBPort, int(dconf.Database.DBPort), "database port")
dbUserPtr := flag.String(DBUser, dconf.Database.DBUser, "database username")
dbPassPtr := flag.String(DBPassword, dconf.Database.DBPassword, "database username")
dbNamePtr := flag.String(DBName, dconf.Database.DBName, "database name")
verbosePtr := flag.Bool(Verbose, dconf.General.VerboseLogging, "Verbose log output")
pathPrefix := flag.String(ReindexPrefix, dconf.General.ReindexPrefix, "Prefix path for videos to reindex")
disableTVShowSupport := flag.Bool(DisableTVSupport, dconf.Features.DisableTVSupport, "Disable the TVShow support and pages")
videosFullyDeletable := flag.Bool(FullyDeletableVideos, dconf.Features.FullyDeletableVideos, "Allow deletion from harddisk")
flag.Parse()
if isFlagPassed(DBHost) {
liveConf.Database.DBHost = *dbhostPtr
}
if isFlagPassed(DBPort) {
liveConf.Database.DBPort = uint16(*dbPortPtr)
}
if isFlagPassed(DBName) {
liveConf.Database.DBName = *dbNamePtr
}
if isFlagPassed(DBUser) {
liveConf.Database.DBUser = *dbUserPtr
}
if isFlagPassed(DBPassword) {
liveConf.Database.DBPassword = *dbPassPtr
}
if isFlagPassed(Verbose) {
liveConf.General.VerboseLogging = *verbosePtr
}
if isFlagPassed(ReindexPrefix) {
liveConf.General.ReindexPrefix = *pathPrefix
}
if isFlagPassed(DisableTVSupport) {
liveConf.Features.DisableTVSupport = *disableTVShowSupport
}
if isFlagPassed(FullyDeletableVideos) {
liveConf.Features.FullyDeletableVideos = *videosFullyDeletable
}
}
// isFlagPassed check whether a flag was passed
func isFlagPassed(name string) bool {
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
func GetConfig() *FileConfT {
return &liveConf
}

View File

@ -0,0 +1,9 @@
package config
import "testing"
func TestSaveLoadConfig(t *testing.T) {
generateNewConfig("", "openmediacenter.cfg")
Init()
}

View File

@ -2,26 +2,24 @@ package database
import ( import (
"database/sql" "database/sql"
"embed"
"fmt" "fmt"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
"github.com/pressly/goose/v3"
"log"
"openmediacenter/apiGo/api/types" "openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/config"
"os"
) )
var db *sql.DB var db *sql.DB
var DBName string var DBName string
// store the command line parameter for Videoprefix //go:embed migrations/*.sql
var SettingsVideoPrefix = "" var embedMigrations embed.FS
type DatabaseConfig struct { func InitDB() error {
DBHost string dbconf := config.GetConfig().Database
DBPort int
DBUser string
DBPassword string
DBName string
}
func InitDB(dbconf *DatabaseConfig) {
DBName = dbconf.DBName DBName = dbconf.DBName
// Open up our database connection. // Open up our database connection.
@ -30,15 +28,32 @@ func InitDB(dbconf *DatabaseConfig) {
// if there is an error opening the connection, handle it // if there is an error opening the connection, handle it
if err != nil { if err != nil {
fmt.Printf("Error while connecting to database! - %s\n", err.Error()) return fmt.Errorf("Error while connecting to database! - %s\n", err.Error())
} }
if db != nil { if db != nil {
ping := db.Ping() ping := db.Ping()
if ping != nil { if ping != nil {
fmt.Printf("Error while connecting to database! - %s\n", ping.Error()) return fmt.Errorf("Error while connecting to database! - %s\n", ping.Error())
} }
} }
fmt.Println("Running Database migrations!")
// perform database migrations
goose.SetBaseFS(embedMigrations)
goose.SetLogger(log.New(os.Stdout, "", 0))
// set mysql dialect
err = goose.SetDialect("mysql")
if err != nil {
return err
}
if err := goose.Up(db, "migrations"); err != nil {
return err
}
return nil
} }
func Query(query string, args ...interface{}) *sql.Rows { func Query(query string, args ...interface{}) *sql.Rows {
@ -90,9 +105,7 @@ func Close() {
db.Close() db.Close()
} }
func GetSettings() types.SettingsType { func GetSettings() (result types.SettingsType, PathPrefix string, sizes types.SettingsSizeType) {
var result types.SettingsType
// query settings and infotile values // query settings and infotile values
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT ( SELECT (
@ -120,7 +133,7 @@ func GetSettings() types.SettingsType {
var DarkMode int var DarkMode int
var TMDBGrabbing int var TMDBGrabbing int
err := QueryRow(query).Scan(&result.VideoNr, &result.DBSize, &result.DifferentTags, &result.TagsAdded, err := QueryRow(query).Scan(&sizes.VideoNr, &sizes.DBSize, &sizes.DifferentTags, &sizes.TagsAdded,
&result.VideoPath, &result.EpisodePath, &result.Password, &result.MediacenterName, &TMDBGrabbing, &DarkMode) &result.VideoPath, &result.EpisodePath, &result.Password, &result.MediacenterName, &TMDBGrabbing, &DarkMode)
if err != nil { if err != nil {
@ -130,7 +143,6 @@ func GetSettings() types.SettingsType {
result.TMDBGrabbing = TMDBGrabbing != 0 result.TMDBGrabbing = TMDBGrabbing != 0
result.PasswordEnabled = result.Password != "-1" result.PasswordEnabled = result.Password != "-1"
result.DarkMode = DarkMode != 0 result.DarkMode = DarkMode != 0
result.PathPrefix = SettingsVideoPrefix PathPrefix = config.GetConfig().General.ReindexPrefix
return
return result
} }

View File

@ -0,0 +1,122 @@
-- +goose Up
-- +goose StatementBegin
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';
-- +goose StatementEnd
-- +goose StatementBegin
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
);
-- +goose StatementEnd
-- +goose StatementBegin
create table if not exists tags
(
tag_id int auto_increment
primary key,
tag_name varchar(50) null
);
-- +goose StatementEnd
-- +goose StatementBegin
create table if not exists tvshow
(
name varchar(100) null,
thumbnail mediumblob null,
id int auto_increment
primary key,
foldername varchar(100) null
);
-- +goose StatementEnd
-- +goose StatementBegin
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)
);
-- +goose StatementEnd
-- +goose StatementBegin
create table if not exists videos
(
movie_id int auto_increment
primary key,
movie_name varchar(200) null,
movie_url varchar(250) null,
thumbnail mediumblob null,
poster mediumblob null,
likes int default 0 null,
quality int default 0 null,
length int default 0 null comment 'in seconds',
create_date datetime default current_timestamp() null
);
-- +goose StatementEnd
-- +goose StatementBegin
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)
);
-- +goose StatementEnd
-- +goose StatementBegin
create index if not exists actors_videos_actor_id_index
on actors_videos (actor_id);
-- +goose StatementEnd
-- +goose StatementBegin
create index if not exists actors_videos_video_id_index
on actors_videos (video_id);
-- +goose StatementEnd
-- +goose StatementBegin
create table if not exists video_tags
(
tag_id int null,
video_id int null,
constraint video_tags_tags_tag_id_fk
foreign key (tag_id) references tags (tag_id),
constraint video_tags_videos_movie_id_fk
foreign key (video_id) references videos (movie_id)
on delete cascade
);
-- +goose StatementEnd
-- +goose StatementBegin
INSERT IGNORE INTO tags (tag_id, tag_name)
VALUES (2, 'fullhd');
-- +goose StatementEnd
-- +goose StatementBegin
INSERT IGNORE INTO tags (tag_id, tag_name)
VALUES (3, 'lowquality');
-- +goose StatementEnd
-- +goose StatementBegin
INSERT IGNORE INTO tags (tag_id, tag_name)
VALUES (4, 'hd');
-- +goose StatementEnd
-- +goose StatementBegin
INSERT IGNORE INTO settings (video_path, episode_path, password, mediacenter_name)
VALUES ('./videos/', './tvshows/', -1, 'OpenMediaCenter');
-- +goose StatementEnd
-- +goose Down

View File

@ -0,0 +1,12 @@
-- +goose Up
-- +goose StatementBegin
alter table videos
add release_date date null;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
alter table videos
drop release_date;
-- +goose StatementEnd

View File

@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
alter table videos
add previewratio FLOAT default -1.0 null;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
alter table videos
drop previewratio;
-- +goose StatementEnd

View File

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

View File

@ -3,7 +3,12 @@ module openmediacenter/apiGo
go 1.16 go 1.16
require ( require (
github.com/go-session/session v3.1.2+incompatible github.com/3d0c/gmf v0.0.0-20210925211039-e278e6e53b16
github.com/go-sql-driver/mysql v1.5.0 github.com/dgrijalva/jwt-go v3.2.0+incompatible
gopkg.in/oauth2.v3 v3.12.0 github.com/fsnotify/fsnotify v1.5.4
github.com/go-sql-driver/mysql v1.6.0
github.com/pelletier/go-toml/v2 v2.0.0-beta.3
github.com/pressly/goose/v3 v3.1.0
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
nhooyr.io/websocket v1.8.7
) )

View File

@ -1,111 +1,104 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/3d0c/gmf v0.0.0-20210925211039-e278e6e53b16 h1:LX3XWmS88yKgWJcMXb8vusphpDBe9+6LTI9FyHeFFWQ=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/3d0c/gmf v0.0.0-20210925211039-e278e6e53b16/go.mod h1:0QMRcUq2JsDECeAq7bj4h79k7XbhtTsrPUQf6G7qfPs=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ClickHouse/clickhouse-go v1.4.5/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4=
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 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/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/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-session/session v3.1.2+incompatible h1:yStchEObKg4nk2F7JGE7KoFIrA/1Y078peagMWcrncg= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
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 h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/klauspost/compress v1.8.2 h1:Bx0qjetmNjdFXASH02NSAREKpiaDwkO1DRZ3dV2KCcs= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/pelletier/go-toml/v2 v2.0.0-beta.3 h1:PNCTU4naEJ8mKal97P3A2qDU74QRQGlv4FXiL1XDqi4=
github.com/pelletier/go-toml/v2 v2.0.0-beta.3/go.mod h1:aNseLYu/uKskg0zpr/kbr2z8yGuWtotWf/0BpGIAL2Y=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/pressly/goose/v3 v3.1.0 h1:V2Ulfm2XL9GtYNmrPUNFHieimf6diwADyMObnuuR2Mc=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/pressly/goose/v3 v3.1.0/go.mod h1:tYsY0oL0yd48jg15POIZfOZiu66mqWpfDd/nJ28KWyU=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942 h1:t0lM6y/M5IiUZyvbBTcngso8SZEZICH7is9B6g/obVU=
github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8= github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/buntdb v1.1.0 h1:H6LzK59KiNjf1nHVPFrYj4Qnl8d8YLBsYamdL8N+Bao= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/tidwall/buntdb v1.1.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/tidwall/gjson v1.3.2 h1:+7p3qQFaH3fOMXAJSrdZwGKcOO/lYdGS0HqGhPqDdTI= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb h1:5NSYaAdrnblKByzd7XByQEJVT8+9v0W/tIY0Oo4OwrE= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0 h1:uWF8lgKmeaIewWVPwi4GRq2P6+R46IgYZdxWtM+GtEY=
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 h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
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 h1:k7pJ2yAPLPgbskkFdhRCsA77k2fySZ1zf2zCjvQCiIM=
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-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/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/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 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

@ -0,0 +1,18 @@
package housekeeping
import "fmt"
func RunHouseKeepingTasks() {
fmt.Println("Runnint houskeeping tasks!")
fmt.Println("Deduplicating Tags")
deduplicateTags()
fmt.Println("Deduplicating Tags assigned to videos")
deduplicateVideoTags()
fmt.Println("Fix missing video metadata like ratio")
fixMissingMetadata()
fmt.Println("Finished housekeeping")
}

View File

@ -0,0 +1,5 @@
package housekeeping
func fixMissingMetadata() {
// todo
}

View File

@ -0,0 +1,86 @@
package housekeeping
import (
"fmt"
"openmediacenter/apiGo/database"
)
func deduplicateTags() {
// find all duplicate tags
// gives first occurence of duplicate
query := `
SELECT
tag_name
FROM
tags
GROUP BY tag_name
HAVING COUNT(tag_name) > 1`
rows := database.Query(query)
duplicates := []string{}
if rows != nil {
for rows.Next() {
var id string
err := rows.Scan(&id)
if err != nil {
panic(err.Error()) // proper Error handling instead of panic in your app
}
duplicates = append(duplicates, id)
}
} else {
// nothing to do
return
}
fmt.Print("deleting duplicate tag ids: ")
fmt.Println(duplicates)
for _, el := range duplicates {
query := fmt.Sprintf("SELECT tag_id FROM tags WHERE tag_name='%s'", el)
rows := database.Query(query)
ids := []uint32{}
for rows.Next() {
var id uint32
err := rows.Scan(&id)
if err != nil {
panic(err.Error()) // proper Error handling instead of panic in your app
}
ids = append(ids, id)
}
// id to copy other data to
mainid := ids[0]
// ids to copy from
copyids := ids[1:]
fmt.Printf("Migrating %s\n", el)
migrateTags(mainid, copyids)
}
}
func migrateTags(destid uint32, sourcids []uint32) {
querytempl := `
UPDATE video_tags
SET
tag_id = %d
WHERE
tag_id = %d`
for _, id := range sourcids {
err := database.Edit(fmt.Sprintf(querytempl, destid, id))
if err != nil {
fmt.Printf("failed to set id from %d to %d\n", id, destid)
return
}
fmt.Printf("Merged %d into %d\n", id, destid)
// now lets delete this tag
query := fmt.Sprintf(`DELETE FROM tags WHERE tag_id=%d`, id)
err = database.Edit(query)
if err != nil {
fmt.Printf("failed to delete Tag %d", id)
return
}
}
}

View File

@ -0,0 +1,37 @@
package housekeeping
import (
"fmt"
"openmediacenter/apiGo/database"
)
func deduplicateVideoTags() {
// gives first occurence of duplicate
query := `
SELECT
tag_id, video_id, count(tag_id)
FROM
video_tags
GROUP BY tag_id, video_id
HAVING COUNT(tag_id) > 1`
rows := database.Query(query)
if rows != nil {
for rows.Next() {
var tagid uint32
var vidid uint32
var nr uint32
err := rows.Scan(&tagid, &vidid, &nr)
if err != nil {
panic(err.Error()) // proper Error handling instead of panic in your app
}
// now lets delete this tag
query := fmt.Sprintf(`DELETE FROM video_tags WHERE tag_id=%d AND video_id=%d LIMIT %d`, tagid, vidid, nr-1)
err = database.Edit(query)
if err != nil {
fmt.Printf("failed to delete Tag %d + vid %d", tagid, vidid)
return
}
}
}
}

View File

@ -4,50 +4,60 @@ import (
"flag" "flag"
"fmt" "fmt"
"openmediacenter/apiGo/api" "openmediacenter/apiGo/api"
api2 "openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
"openmediacenter/apiGo/housekeeping"
"openmediacenter/apiGo/static"
"openmediacenter/apiGo/videoparser"
"os"
"os/signal"
) )
func main() { func main() {
fmt.Println("init OpenMediaCenter server") fmt.Println("init OpenMediaCenter server")
const port uint16 = 8081
errc := make(chan error, 1)
housekPTr := flag.Bool("HouseKeeping", false, "Run housekeeping tasks")
config.Init()
db, verbose, pathPrefix := handleCommandLineArguments()
// todo some verbosity logger or sth // todo some verbosity logger or sth
fmt.Printf("Use verbose output: %t\n", config.GetConfig().General.VerboseLogging)
fmt.Printf("Videopath prefix: %s\n", config.GetConfig().General.ReindexPrefix)
fmt.Printf("Use verbose output: %t\n", verbose) err := database.InitDB()
fmt.Printf("Videopath prefix: %s\n", *pathPrefix) if err != nil {
errc <- err
// set pathprefix in database settings object }
database.SettingsVideoPrefix = *pathPrefix
database.InitDB(db)
defer database.Close() defer database.Close()
api.AddVideoHandlers() // check if we should run the housekeeping tasks
api.AddSettingsHandlers() if *housekPTr {
api.AddTagHandlers() housekeeping.RunHouseKeepingTasks()
api.AddActorsHandlers() return
}
api.ServerInit(8081) api.AddHandlers()
}
videoparser.SetupSettingsWebsocket()
func handleCommandLineArguments() (*database.DatabaseConfig, bool, *string) { videoparser.InitFileWatcher()
dbhostPtr := flag.String("DBHost", "127.0.0.1", "database host name")
dbPortPtr := flag.Int("DBPort", 3306, "database port") // add the static files
dbUserPtr := flag.String("DBUser", "mediacenteruser", "database username") static.ServeStaticFiles()
dbPassPtr := flag.String("DBPassword", "mediapassword", "database username")
dbNamePtr := flag.String("DBName", "mediacenter", "database name") // init api
go func() {
verbosePtr := flag.Bool("v", true, "Verbose log output") errc <- api2.ServerInit(port)
}()
pathPrefix := flag.String("ReindexPrefix", "/var/www/openmediacenter", "Prefix path for videos to reindex")
sigs := make(chan os.Signal, 1)
flag.Parse() signal.Notify(sigs, os.Interrupt)
select {
return &database.DatabaseConfig{ case err := <-errc:
DBHost: *dbhostPtr, fmt.Printf("failed to serve: %v\n", err)
DBPort: *dbPortPtr, case sig := <-sigs:
DBUser: *dbUserPtr, fmt.Printf("terminating server: %v\n", sig)
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,58 @@
package videoparser
import (
"github.com/fsnotify/fsnotify"
"log"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database"
"strings"
)
func InitFileWatcher() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal("NewWatcher failed: ", err)
}
mSettings, _, _ := database.GetSettings()
vidFolder := config.GetConfig().General.ReindexPrefix + mSettings.VideoPath
epsfolder := config.GetConfig().General.ReindexPrefix + mSettings.EpisodePath
defer watcher.Close()
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
// start new reindex
// (may be optimized by checking here if added file is video
// and start reindex just for one file)
if strings.Contains(event.Name, vidFolder) {
StartReindex()
} else if strings.Contains(event.Name, epsfolder) {
StartTVShowReindex()
} else {
log.Printf("Event in wrong folder: %s %s\n", event.Name, event.Op)
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("error:", err)
}
}
}()
err = watcher.Add(vidFolder)
if err != nil {
log.Println("Adding of file watcher failed: ", err)
}
err = watcher.Add(epsfolder)
if err != nil {
log.Println("Adding of file watcher failed: ", err)
}
}

View File

@ -0,0 +1,31 @@
package videoparser
import (
"encoding/json"
)
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)
}

View File

@ -1,294 +0,0 @@
package videoparser
import (
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/videoparser/tmdb"
"os/exec"
"regexp"
"strconv"
)
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)
for _, s := range path {
processVideo(s)
}
AppendMessageBuffer("reindex finished successfully!")
contentAvailable = false
fmt.Println("Reindexing finished!")
}
func processVideo(fileNameOrig string) {
fmt.Printf("Processing %s video-", fileNameOrig)
// match the file extension
r, _ := regexp.Compile(`\.[a-zA-Z0-9]+$`)
fileName := r.ReplaceAllString(fileNameOrig, "")
year, fileName := matchYear(fileName)
// now we should look if this video already exists in db
query := "SELECT * FROM videos WHERE movie_name = ?"
err := database.QueryRow(query, fileName).Scan()
if err == sql.ErrNoRows {
fmt.Printf("The Video %s does't exist! Adding it to database.\n", fileName)
addVideo(fileName, fileNameOrig, year)
} else {
fmt.Println(" :existing!")
}
}
// add a video to the database
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(fileName)
if err != nil {
fmt.Printf("FFmpeg error occured: %s\n", err.Error())
} else {
fmt.Println("successfully extracted thumbnail!!")
}
}
if mExtDepsAvailable.MediaInfo {
atr := getVideoAttributes(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)
}
AppendMessageBuffer(fmt.Sprintf("%s - added!", videoName))
}
func matchYear(fileName string) (int, string) {
r, _ := regexp.Compile(`\([0-9]{4}?\)`)
years := r.FindAllString(fileName, -1)
if len(years) == 0 {
return -1, fileName
}
year, err := strconv.Atoi(years[len(years)-1])
if err != nil {
return -1, fileName
}
// cut out year from filename
return year, r.ReplaceAllString(fileName, "")
}
// parse the thumbail picture from video file
func parseFFmpegPic(fileName string) (*string, error) {
app := "ffmpeg"
cmd := exec.Command(app,
"-hide_banner",
"-loglevel", "panic",
"-ss", "00:04:00",
"-i", mSettings.VideoPath+fileName,
"-vframes", "1",
"-q:v", "2",
"-f", "singlejpeg",
"pipe:1")
stdout, err := cmd.Output()
if err != nil {
fmt.Println(err.Error())
fmt.Println(string(err.(*exec.ExitError).Stderr))
return nil, err
}
backpic64 := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(stdout)
return &backpic64, nil
}
func getVideoAttributes(fileName string) *VideoAttributes {
app := "mediainfo"
arg0 := mSettings.VideoPath + fileName
arg1 := "--Output=JSON"
cmd := exec.Command(app, arg1, "-f", arg0)
stdout, err := cmd.Output()
var t struct {
Media struct {
Track []struct {
Duration string
FileSize string
Width string
}
}
}
err = json.Unmarshal(stdout, &t)
if err != nil {
fmt.Println(err.Error())
return nil
}
duration, err := strconv.ParseFloat(t.Media.Track[0].Duration, 32)
filesize, err := strconv.Atoi(t.Media.Track[0].FileSize)
width, err := strconv.Atoi(t.Media.Track[1].Width)
ret := VideoAttributes{
Duration: float32(duration),
FileSize: uint(filesize),
Width: uint(width),
}
return &ret
}
func AppendMessageBuffer(message string) {
messageBuffer = append(messageBuffer, message)
}
// ext dependency support check
func checkExtDependencySupport() *ExtDependencySupport {
var extDepsAvailable ExtDependencySupport
extDepsAvailable.FFMpeg = commandExists("ffmpeg")
extDepsAvailable.MediaInfo = commandExists("mediainfo")
return &extDepsAvailable
}
// check if a specific system command is available
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// insert the default size tags to corresponding video
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,143 @@
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 filename 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 filename string
err := rows.Scan(&filename)
if err != nil {
fmt.Println(err.Error())
}
dbepisodes = append(dbepisodes, filename)
}
// 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 {
if b == nil || len(b) == 0 {
return a
}
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
err := rows.Scan(&show)
if err != nil {
continue
}
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,246 @@
package videoparser
import (
"database/sql"
"fmt"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/videoparser/thumbnail"
"openmediacenter/apiGo/videoparser/tmdb"
"regexp"
"strconv"
"strings"
)
var mSettings *types.SettingsType
// default Tag ids
const (
FullHd = 2
Hd = 4
LowQuality = 3
)
type VideoAttributes struct {
Duration float32
FileSize uint
Width uint
}
func InitDeps(sett *types.SettingsType) {
mSettings = sett
}
func ReIndexVideos(path []string) {
// 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\n", 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 tmdbData *tmdb.VideoTMDB
var err error
var insertid int64
vidFolder := config.GetConfig().General.ReindexPrefix + mSettings.VideoPath
// if TMDB grabbing is enabled serach in api for video...
if mSettings.TMDBGrabbing {
tmdbData = tmdb.SearchVideo(videoName, year)
}
// parse pic from 4min frame
ppic, vinfo, ffmpegErr := thumbnail.Parse(vidFolder+fileName, 240)
if ffmpegErr == nil {
if mSettings.TMDBGrabbing && tmdbData != nil {
// inesert fixed pic ratio what we get from tmdb
previewRatio := 2 / 3
query := `INSERT INTO videos(movie_name,movie_url,poster,thumbnail,quality,previewratio,length,release_date) VALUES (?,?,?,?,?,?,?,?)`
err, insertid = database.Insert(query, videoName, fileName, ppic, tmdbData.Thumbnail, vinfo.Width, previewRatio, vinfo.Length, tmdbData.ReleaseDate)
} else {
previewRatio := float32(vinfo.Height) / float32(vinfo.Width)
// insert without tmdb info
query := `INSERT INTO videos(movie_name,movie_url,poster,thumbnail,quality,previewratio,length) VALUES (?,?,?,?,?,?,?)`
err, insertid = database.Insert(query, videoName, fileName, ppic, ppic, vinfo.Width, previewRatio, vinfo.Length)
}
} else {
fmt.Printf("FFmpeg error occured: %s\n", ffmpegErr.Error())
if mSettings.TMDBGrabbing && tmdbData != nil {
query := `INSERT INTO videos(movie_name,movie_url,thumbnail,release_date) VALUES (?,?,?,?)`
err, insertid = database.Insert(query, videoName, fileName, tmdbData.Thumbnail, tmdbData.ReleaseDate)
} else {
query := `INSERT INTO videos(movie_name,movie_url) VALUES (?,?)`
err, insertid = database.Insert(query, videoName, fileName)
}
}
if err != nil {
fmt.Printf("Failed to insert video into db: %s\n", err.Error())
return
}
if ffmpegErr == nil {
// add default tags
if vinfo.Width != 0 {
insertSizeTag(uint(vinfo.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)
if tagId != -1 {
// 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 := "SELECT tag_id FROM tags WHERE tag_name = ?"
var id int64 = -1
err := database.QueryRow(query, tagName).Scan(&id)
if err == sql.ErrNoRows {
// tag doesn't exist -- add it
query = "INSERT INTO tags (tag_name) VALUES (?)"
err, id = database.Insert(query, tagName)
if err != nil {
fmt.Println(err.Error())
}
} else if err != nil {
if err != nil {
fmt.Println(err.Error())
}
}
return id
}

View File

@ -2,67 +2,136 @@ package videoparser
import ( import (
"fmt" "fmt"
"io/ioutil"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database" "openmediacenter/apiGo/database"
"os" "os"
"path/filepath"
"strings" "strings"
) )
var messageBuffer []string
var contentAvailable = false
type StatusMessage struct { type StatusMessage struct {
Messages []string Messages []string
ContentAvailable bool ContentAvailable bool
} }
func getVideoTypes() []string {
return []string{".mp4", ".mov", ".mkv", ".flv", ".avi", ".mpeg", ".m4v"}
}
func ValidVideoSuffix(filename string) bool {
validExts := getVideoTypes()
for _, validExt := range validExts {
if strings.HasSuffix(filename, validExt) {
return true
}
}
return false
}
func StartReindex() bool { func StartReindex() bool {
messageBuffer = []string{}
contentAvailable = true
fmt.Println("starting reindex..") fmt.Println("starting reindex..")
SendEvent("start")
AppendMessage("starting reindex..")
mSettings, _, _ := database.GetSettings()
mSettings := database.GetSettings()
// add the path prefix to videopath // add the path prefix to videopath
mSettings.VideoPath = mSettings.PathPrefix + mSettings.VideoPath vidFolder := config.GetConfig().General.ReindexPrefix + mSettings.VideoPath
// check if path even exists // check if path even exists
if _, err := os.Stat(mSettings.VideoPath); os.IsNotExist(err) { if _, err := os.Stat(vidFolder); os.IsNotExist(err) {
fmt.Println("Reindex path doesn't exist!") fmt.Println("Reindex path doesn't exist!")
AppendMessage(fmt.Sprintf("Reindex path doesn't exist! :%s", vidFolder))
SendEvent("stop")
return false
}
filelist, err := ioutil.ReadDir(vidFolder)
if err != nil {
fmt.Println(err.Error())
return false return false
} }
var files []string var files []string
err := filepath.Walk(mSettings.VideoPath, func(path string, info os.FileInfo, err error) error { for _, file := range filelist {
if err != nil { if !file.IsDir() && ValidVideoSuffix(file.Name()) {
fmt.Println(err.Error()) files = append(files, file.Name())
return err
} }
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".mp4") { // start reindex process
files = append(files, info.Name()) AppendMessage("Starting Reindexing!")
InitDeps(&mSettings)
go ReIndexVideos(files)
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, PathPrefix, _ := database.GetSettings()
// add the path prefix to videopath
mSettings.EpisodePath = PathPrefix + mSettings.EpisodePath
// add slash suffix if not existing
if !strings.HasSuffix(mSettings.EpisodePath, "/") {
mSettings.EpisodePath += "/"
}
// 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 ValidVideoSuffix(epfile.Name()) {
elem.files = append(elem.files, epfile.Name())
}
}
files = append(files, elem)
} }
return nil }
})
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
} }
// start reindex process // start reindex process
AppendMessageBuffer("Starting Reindexing!") AppendMessage("Starting Reindexing!")
go ReIndexVideos(files, mSettings) go startTVShowReindex(files)
return true
}
func GetStatusMessage() *StatusMessage {
msg := StatusMessage{
Messages: messageBuffer,
ContentAvailable: contentAvailable,
}
messageBuffer = []string{}
return &msg
} }
func StartCleanup() { func StartCleanup() {

View File

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

View File

@ -0,0 +1,22 @@
package thumbnail
import (
"encoding/base64"
"fmt"
)
type VidInfo struct {
Width uint32
Height uint32
Length uint64
FrameRate float32
Size int64
}
func EncodeBase64(data *[]byte, mimetype string) *string {
strEncPic := base64.StdEncoding.EncodeToString(*data)
backpic64 := fmt.Sprintf("data:%s;base64,%s", mimetype, strEncPic)
return &backpic64
}

View File

@ -0,0 +1,198 @@
// +build sharedffmpeg
package thumbnail
import (
"fmt"
"github.com/3d0c/gmf"
"io"
"log"
"os"
)
func Parse(filename string, time uint64) (*string, *VidInfo, error) {
dta, inf, err := decodePic(filename, "mjpeg", time)
if err == nil && dta != nil {
// base64 encode picture
enc := EncodeBase64(dta, "image/jpeg")
return enc, inf, nil
} else {
return nil, nil, err
}
}
func decodePic(srcFileName string, encodeExtension string, time uint64) (pic *[]byte, info *VidInfo, err error) {
var swsctx *gmf.SwsCtx
gmf.LogSetLevel(gmf.AV_LOG_PANIC)
stat, err := os.Stat(srcFileName)
if err != nil {
// file seems to not even exist
return nil, nil, err
}
fileSize := stat.Size()
inputCtx, err := gmf.NewInputCtx(srcFileName)
if err != nil {
log.Printf("Error creating context - %s\n", err)
return nil, nil, err
}
defer inputCtx.Free()
srcVideoStream, err := inputCtx.GetBestStream(gmf.AVMEDIA_TYPE_VIDEO)
if err != nil {
log.Printf("No video stream found in '%s'\n", srcFileName)
return nil, nil, err
}
encodeCodec, err := gmf.FindEncoder(encodeExtension)
if err != nil {
log.Printf("%s\n", err)
return nil, nil, err
}
cc := gmf.NewCodecCtx(encodeCodec)
defer gmf.Release(cc)
cc.SetTimeBase(gmf.AVR{Num: 1, Den: 1})
cc.SetPixFmt(gmf.AV_PIX_FMT_YUVJ444P).SetWidth(srcVideoStream.CodecPar().Width()).SetHeight(srcVideoStream.CodecPar().Height())
if encodeCodec.IsExperimental() {
cc.SetStrictCompliance(gmf.FF_COMPLIANCE_EXPERIMENTAL)
}
if err := cc.Open(nil); err != nil {
log.Println(err)
return nil, nil, err
}
defer cc.Free()
err = inputCtx.SeekFrameAt(int64(time), srcVideoStream.Index())
if err != nil {
log.Printf("Error while seeking file: %s\n", err.Error())
return nil, nil, err
}
// find encodeCodec to decode video
decodeCodec, err := gmf.FindDecoder(srcVideoStream.CodecPar().CodecId())
if err != nil {
fmt.Println(err)
return nil, nil, err
}
icc := gmf.NewCodecCtx(decodeCodec)
defer gmf.Release(icc)
// copy stream parameters in codeccontext
err = srcVideoStream.CodecPar().ToContext(icc)
if err != nil {
fmt.Println(err.Error())
}
// convert source pix_fmt into AV_PIX_FMT_RGBA
if swsctx, err = gmf.NewSwsCtx(icc.Width(), icc.Height(), icc.PixFmt(), cc.Width(), cc.Height(), cc.PixFmt(), gmf.SWS_BICUBIC); err != nil {
panic(err)
}
defer swsctx.Free()
frameRate := float32(srcVideoStream.GetRFrameRate().AVR().Num) / float32(srcVideoStream.GetRFrameRate().AVR().Den)
inf := VidInfo{
Width: uint32(icc.Width()),
Height: uint32(icc.Height()),
FrameRate: frameRate,
Length: uint64(inputCtx.Duration()),
Size: fileSize,
}
info = &inf
var (
pkt *gmf.Packet
frames []*gmf.Frame
drain int = -1
frameCount int = 0
)
for {
if drain >= 0 {
break
}
pkt, err = inputCtx.GetNextPacket()
if err != nil && err != io.EOF {
if pkt != nil {
pkt.Free()
}
log.Printf("error getting next packet - %s", err)
break
} else if err != nil && pkt == nil {
drain = 0
}
if pkt != nil && pkt.StreamIndex() != srcVideoStream.Index() {
continue
}
frames, err = srcVideoStream.CodecCtx().Decode(pkt)
if err != nil {
log.Printf("Fatal error during decoding - %s\n", err)
break
}
// Decode() method doesn't treat EAGAIN and EOF as errors
// it returns empty frames slice instead. Countinue until
// input EOF or frames received.
if len(frames) == 0 && drain < 0 {
continue
}
if frames, err = gmf.DefaultRescaler(swsctx, frames); err != nil {
panic(err)
}
packets, err := cc.Encode(frames, drain)
if len(packets) == 0 {
continue
}
if err != nil {
continue
}
picdata := packets[0].Data()
pic = &picdata
// cleanup here
for _, p := range packets {
p.Free()
}
for i := range frames {
frames[i].Free()
frameCount++
}
if pkt != nil {
pkt.Free()
pkt = nil
}
// we only want to encode first picture then exit
break
}
for i := 0; i < inputCtx.StreamsCnt(); i++ {
st, err := inputCtx.GetStream(i)
if err == nil && st != nil {
st.Free()
}
}
icc.Free()
srcVideoStream.Free()
return
}

View File

@ -0,0 +1,130 @@
// +build !sharedffmpeg
package thumbnail
import (
"encoding/json"
"fmt"
"os/exec"
"strconv"
)
type ExtDependencySupport struct {
FFMpeg bool
MediaInfo bool
}
func Parse(filename string, time uint64) (*string, *VidInfo, error) {
// 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)
var pic *string = nil
var info *VidInfo = nil
if mExtDepsAvailable.FFMpeg {
p, err := parseFFmpegPic(filename, time)
if err != nil {
return nil, nil, err
}
pic = EncodeBase64(p, "image/jpeg")
}
if mExtDepsAvailable.MediaInfo {
i, err := getVideoAttributes(filename)
if err != nil {
return nil, nil, err
}
info = i
}
return pic, info, nil
}
// check if a specific system command is available
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// ext dependency support check
func checkExtDependencySupport() *ExtDependencySupport {
var extDepsAvailable ExtDependencySupport
extDepsAvailable.FFMpeg = commandExists("ffmpeg")
extDepsAvailable.MediaInfo = commandExists("mediainfo")
return &extDepsAvailable
}
func secToString(time uint64) string {
return fmt.Sprintf("%02d:%02d:%02d", time/3600, (time/60)%60, time%60)
}
// parse the thumbail picture from video file
func parseFFmpegPic(path string, time uint64) (*[]byte, error) {
app := "ffmpeg"
cmd := exec.Command(app,
"-hide_banner",
"-loglevel", "panic",
"-ss", secToString(time),
"-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
}
return &stdout, nil
}
func getVideoAttributes(path string) (*VidInfo, error) {
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
Height string
}
}
}
err = json.Unmarshal(stdout, &t)
if err != nil {
return nil, err
}
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)
height, err := strconv.Atoi(t.Media.Track[1].Height)
if err != nil {
return nil, err
}
ret := VidInfo{
Length: uint64(duration),
Size: int64(filesize),
Width: uint32(width),
Height: uint32(height),
FrameRate: 0,
}
return &ret, nil
}

View File

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

View File

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

View File

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

View File

@ -6,10 +6,6 @@ ln -sf /etc/nginx/sites-available/OpenMediaCenter.conf /etc/nginx/sites-enabled/
mysql -uroot -pPASS -e "CREATE DATABASE IF NOT EXISTS mediacenter;" mysql -uroot -pPASS -e "CREATE DATABASE IF NOT EXISTS mediacenter;"
mysql -uroot -pPASS -e "CREATE USER IF NOT EXISTS 'mediacenteruser'@'localhost' IDENTIFIED BY 'mediapassword';" mysql -uroot -pPASS -e "CREATE USER IF NOT EXISTS 'mediacenteruser'@'localhost' IDENTIFIED BY 'mediapassword';"
mysql -uroot -pPASS -e "GRANT ALL PRIVILEGES ON mediacenter . * TO 'mediacenteruser'@'localhost';" mysql -uroot -pPASS -e "GRANT ALL PRIVILEGES ON mediacenter . * TO 'mediacenteruser'@'localhost';"
mysql -u mediacenteruser -pmediapassword mediacenter < /tmp/openmediacenter.sql
# removed unused sql style file
rm /tmp/openmediacenter.sql
# correct user rights # correct user rights
chown -R www-data:www-data /var/www/openmediacenter chown -R www-data:www-data /var/www/openmediacenter
@ -18,4 +14,4 @@ chown -R www-data:www-data /var/www/openmediacenter
systemctl restart nginx systemctl restart nginx
systemctl enable OpenMediaCenter.service systemctl enable OpenMediaCenter.service
systemctl start OpenMediaCenter.service systemctl restart OpenMediaCenter.service

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/logo_circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import App from './App'; import App from './App';
import {shallow} from 'enzyme'; import {shallow} from 'enzyme';
import GlobalInfos from "./utils/GlobalInfos";
import {LoginContext} from './utils/context/LoginContext';
describe('<App/>', function () { describe('<App/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -10,34 +12,20 @@ describe('<App/>', function () {
it('renders title', () => { it('renders title', () => {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({password: false});
expect(wrapper.find('.navbrand').text()).toBe('OpenMediaCenter'); expect(wrapper.find('.navbrand').text()).toBe('OpenMediaCenter');
}); });
it('are navlinks correct', function () { it('are navlinks correct', function () {
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
expect(wrapper.find('.navitem')).toHaveLength(4); wrapper.setState({password: false});
expect(wrapper.find('.navitem')).toHaveLength(5);
}); });
it('test initial fetch from api', done => { it('test render of password page', function () {
global.fetch = global.prepareFetchApi({
generalSettingsLoaded: true,
passwordsupport: true,
mediacentername: 'testname'
});
const wrapper = shallow(<App/>); const wrapper = shallow(<App/>);
wrapper.setState({password: true});
expect(wrapper.find('AuthenticationPage')).toHaveLength(1);
const func = jest.fn();
wrapper.instance().setState = func;
expect(global.fetch).toBeCalledTimes(1);
process.nextTick(() => {
expect(func).toBeCalledTimes(1);
global.fetch.mockClear();
done();
});
}); });
}); });

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, {useContext} from 'react';
import HomePage from './pages/HomePage/HomePage'; import HomePage from './pages/HomePage/HomePage';
import RandomPage from './pages/RandomPage/RandomPage'; import RandomPage from './pages/RandomPage/RandomPage';
import GlobalInfos from './utils/GlobalInfos'; import GlobalInfos from './utils/GlobalInfos';
@ -9,20 +9,19 @@ import style from './App.module.css';
import SettingsPage from './pages/SettingsPage/SettingsPage'; import SettingsPage from './pages/SettingsPage/SettingsPage';
import CategoryPage from './pages/CategoryPage/CategoryPage'; import CategoryPage from './pages/CategoryPage/CategoryPage';
import {APINode, callAPI} from './utils/Api';
import {NoBackendConnectionPopup} from './elements/Popups/NoBackendConnectionPopup/NoBackendConnectionPopup';
import {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom'; import {NavLink, Route, Switch, useRouteMatch} from 'react-router-dom';
import Player from './pages/Player/Player'; import Player from './pages/Player/Player';
import ActorOverviewPage from './pages/ActorOverviewPage/ActorOverviewPage'; import ActorOverviewPage from './pages/ActorOverviewPage/ActorOverviewPage';
import ActorPage from './pages/ActorPage/ActorPage'; import ActorPage from './pages/ActorPage/ActorPage';
import {SettingsTypes} from './types/ApiTypes'; import AuthenticationPage from './pages/AuthenticationPage/AuthenticationPage';
import TVShowPage from './pages/TVShowPage/TVShowPage';
import TVPlayer from './pages/TVShowPage/TVPlayer';
import {LoginContextProvider} from './utils/context/LoginContextProvider';
import {FeatureContext} from './utils/context/FeatureContext';
interface state { interface state {
generalSettingsLoaded: boolean;
passwordsupport: boolean;
mediacentername: string; mediacentername: string;
onapierror: boolean;
} }
/** /**
@ -31,96 +30,133 @@ interface state {
class App extends React.Component<{}, state> { class App extends React.Component<{}, state> {
constructor(props: {}) { constructor(props: {}) {
super(props); super(props);
this.state = { this.state = {
generalSettingsLoaded: false, mediacentername: 'OpenMediaCenter'
passwordsupport: false,
mediacentername: 'OpenMediaCenter',
onapierror: false
}; };
}
initialAPICall(): void { // force an update on theme change
// this is the first api call so if it fails we know there is no connection to backend GlobalInfos.onThemeChange(() => {
callAPI(APINode.Settings, {action: 'loadInitialData'}, (result: SettingsTypes.initialApiCallData) => { this.forceUpdate();
// set theme
GlobalInfos.enableDarkTheme(result.DarkMode);
GlobalInfos.setVideoPath(result.VideoPath);
this.setState({
generalSettingsLoaded: true,
passwordsupport: result.Password,
mediacentername: result.Mediacenter_name,
onapierror: false
});
// set tab title to received mediacenter name
document.title = result.Mediacenter_name;
}, error => {
this.setState({onapierror: true});
}); });
} }
componentDidMount(): void {
this.initialAPICall();
}
render(): JSX.Element { render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
// add the main theme to the page body // add the main theme to the page body
document.body.className = themeStyle.backgroundcolor; document.body.className = GlobalInfos.getThemeStyle().backgroundcolor;
return ( return (
<Router> <LoginContextProvider>
<div className={style.app}> <Switch>
<div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}> <Route path='/login'>
<div className={style.navbrand}>{this.state.mediacentername}</div> <AuthenticationPage />
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/'} activeStyle={{opacity: '0.85'}}>Home</NavLink> </Route>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/random'} activeStyle={{opacity: '0.85'}}>Random <Route path='/media'>
Video</NavLink> {this.navBar()}
<MyRouter />
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/categories'} activeStyle={{opacity: '0.85'}}>Categories</NavLink> </Route>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/settings'} activeStyle={{opacity: '0.85'}}>Settings</NavLink> </Switch>
</div> </LoginContextProvider>
{this.routing()}
</div>
{this.state.onapierror ? this.ApiError() : null}
</Router>
); );
} }
routing(): JSX.Element { static contextType = FeatureContext;
return (
<Switch>
<Route path="/random">
<RandomPage/>
</Route>
<Route path="/categories">
<CategoryPage/>
</Route>
<Route path="/settings">
<SettingsPage/>
</Route>
<Route exact path="/player/:id">
<Player/>
</Route>
<Route exact path="/actors">
<ActorOverviewPage/>
</Route>
<Route path="/actors/:id">
<ActorPage/>
</Route>
<Route path="/">
<HomePage/>
</Route>
</Switch>
);
}
ApiError(): JSX.Element { /**
// on api error show popup and retry and show again if failing.. * render the top navigation bar
return (<NoBackendConnectionPopup onHide={(): void => this.initialAPICall()}/>); */
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={'/media'} activeStyle={{opacity: '0.85'}}>
Home
</NavLink>
<NavLink
className={[style.navitem, themeStyle.navitem].join(' ')}
to={'/media/random'}
activeStyle={{opacity: '0.85'}}>
Random Video
</NavLink>
<NavLink
className={[style.navitem, themeStyle.navitem].join(' ')}
to={'/media/categories'}
activeStyle={{opacity: '0.85'}}>
Categories
</NavLink>
<NavLink
className={[style.navitem, themeStyle.navitem].join(' ')}
to={'/media/actors'}
activeStyle={{opacity: '0.85'}}>
Actors
</NavLink>
{this.context.TVShowEnabled ? (
<NavLink
className={[style.navitem, themeStyle.navitem].join(' ')}
to={'/media/tvshows'}
activeStyle={{opacity: '0.85'}}>
TV Shows
</NavLink>
) : null}
<NavLink
className={[style.navitem, themeStyle.navitem].join(' ')}
to={'/media/settings'}
activeStyle={{opacity: '0.85'}}>
Settings
</NavLink>
</div>
);
} }
} }
const MyRouter = (): JSX.Element => {
const match = useRouteMatch();
const features = useContext(FeatureContext);
return (
<Switch>
<Route exact path={`${match.url}/random`}>
<RandomPage />
</Route>
<Route path={`${match.url}/categories`}>
<CategoryPage />
</Route>
<Route path={`${match.url}/settings`}>
<SettingsPage />
</Route>
<Route exact path={`${match.url}/player/:id`}>
<Player />
</Route>
<Route exact path={`${match.url}/actors`}>
<ActorOverviewPage />
</Route>
<Route exact path={`${match.url}/actors/:id`}>
<ActorPage />
</Route>
{features.TVShowEnabled ? (
<Route path={`${match.url}/tvshows`}>
<TVShowPage />
</Route>
) : null}
{features.TVShowEnabled ? (
<Route exact path={`${match.url}/tvplayer/:id`}>
<TVPlayer />
</Route>
) : null}
<Route path={`${match.url}/`}>
<HomePage />
</Route>
</Switch>
);
};
export default App; export default App;

View File

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

View File

@ -0,0 +1,30 @@
.dropArea {
border: 2px dashed #ccc;
border-radius: 20px;
width: 480px;
font-family: sans-serif;
padding: 20px;
}
.dropArea:hover {
cursor: pointer;
}
.dropArea.highlight {
border-color: purple;
}
.myForm {
margin-bottom: 10px;
}
.progresswrapper {
width: 100%;
height: 5px;
margin-top: 3px;
}
.finished {
margin-top: 10px;
text-align: center;
}

View File

@ -0,0 +1,108 @@
import style from './DropZone.module.css';
import React, {useState} from 'react';
import {cookie} from '../../utils/context/Cookie';
import GlobalInfos from '../../utils/GlobalInfos';
export const DropZone = (): JSX.Element => {
const [ondrag, setDrag] = useState(0);
const [percent, setpercent] = useState(0.0);
const [finished, setfinished] = useState<string | null>(null);
const theme = GlobalInfos.getThemeStyle();
const uploadFile = (f: FileList): void => {
const xhr = new XMLHttpRequest(); // create XMLHttpRequest
const data = new FormData(); // create formData object
for (let i = 0; i < f.length; i++) {
const file = f.item(i);
if (file) {
data.append('file' + i, file);
}
}
xhr.onload = function (): void {
console.log(this.responseText); // whatever the server returns
const resp = JSON.parse(this.responseText);
if (resp.Message === 'finished all files') {
setfinished('');
} else {
setfinished(resp.Message);
}
setTimeout(() => {
setpercent(0);
setfinished(null);
}, 2000);
};
xhr.upload.onprogress = function (e): void {
console.log(e.loaded / e.total);
setpercent((e.loaded * 100.0) / e.total);
};
xhr.open('post', '/api/video/fileupload'); // open connection
xhr.setRequestHeader('Accept', 'multipart/form-data');
const tkn = cookie.Load();
if (tkn) {
xhr.setRequestHeader('Token', tkn.Token);
}
xhr.send(data); // send data
};
return (
<div
className={style.dropArea + (ondrag > 0 ? ' ' + style.highlight : '') + ' ' + theme.secbackground}
onDragEnter={(e): void => {
e.preventDefault();
e.stopPropagation();
setDrag(ondrag + 1);
}}
onDragLeave={(e): void => {
e.preventDefault();
e.stopPropagation();
setDrag(ondrag - 1);
}}
onDragOver={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
onDrop={(e): void => {
setDrag(0);
e.preventDefault();
e.stopPropagation();
uploadFile(e.dataTransfer.files);
}}
onClick={(): void => {
let input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = function (): void {
if (input.files) {
uploadFile(input.files);
}
};
input.click();
}}>
<div className={style.myForm}>
<p>To upload new Videos darg and drop them here or click to select some...</p>
<div className={style.progresswrapper}>
<div style={{width: percent + '%', backgroundColor: 'green', height: 5}} />
</div>
{finished !== null ? (
finished === '' ? (
<div className={style.finished}>Finished uploading</div>
) : (
<div className={style.finished}>Upload failed: {finished}</div>
)
) : (
<></>
)}
</div>
</div>
);
};

View File

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

View File

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

View File

@ -0,0 +1,113 @@
import React from 'react';
import style from './DynamicContentContainer.module.css';
interface Props<T> {
renderElement: (elem: T) => JSX.Element;
data: T[];
initialLoadNr?: number;
}
interface state<T> {
loadeditems: T[];
}
/**
* A videocontainer storing lots of Preview elements
* includes scroll handling and loading of preview infos
*/
class DynamicContentContainer<T> extends React.Component<Props<T>, state<T>> {
// stores current index of loaded elements
loadindex: number = 0;
readonly InitialLoadNR = this.props.initialLoadNr
? this.props.initialLoadNr === -1
? this.props.data.length
: this.props.initialLoadNr
: 16;
constructor(props: Props<T>) {
super(props);
this.state = {
loadeditems: []
};
}
componentDidMount(): void {
document.addEventListener('scroll', this.trackScrolling);
this.loadPreviewBlock(this.InitialLoadNR);
}
componentDidUpdate(prevProps: Props<T>): void {
// when source props change force update!
if (
// diff the two arrays
this.props.data
.filter((x) => !prevProps.data.includes(x))
.concat(prevProps.data.filter((x) => !this.props.data.includes(x))).length !== 0
) {
this.clean((): void => {
this.loadPreviewBlock(this.InitialLoadNR);
});
}
}
/**
* clear all elements rendered...
*/
clean(callback: () => void): void {
this.loadindex = 0;
this.setState({loadeditems: []}, callback);
}
render(): JSX.Element {
return (
<div className={style.maincontent}>
{this.state.loadeditems.map((elem) => {
return this.props.renderElement(elem);
})}
{/*todo css for no items to show*/}
{this.state.loadeditems.length === 0 ? 'no items to show!' : null}
{this.props.children}
</div>
);
}
componentWillUnmount(): void {
// unbind scroll listener when unmounting component
document.removeEventListener('scroll', this.trackScrolling);
}
/**
* load previews to the container
* @param nr number of previews to load
*/
loadPreviewBlock(nr: number): void {
let ret = [];
for (let i = 0; i < nr; i++) {
// only add if not end
if (this.props.data.length > this.loadindex + i) {
ret.push(this.props.data[this.loadindex + i]);
}
}
this.setState({
loadeditems: [...this.state.loadeditems, ...ret]
});
this.loadindex += nr;
}
/**
* scroll event handler -> load new previews if on bottom
*/
trackScrolling = (): void => {
// comparison if current scroll position is on bottom --> 200 is bottom offset to trigger load
if (window.innerHeight + document.documentElement.scrollTop + 200 >= document.documentElement.offsetHeight) {
this.loadPreviewBlock(8);
}
};
}
export default DynamicContentContainer;

View File

@ -1,12 +1,12 @@
import React from "react"; import React from 'react';
import style from "../Popups/AddActorPopup/AddActorPopup.module.css"; import style from '../Popups/AddActorPopup/AddActorPopup.module.css';
import {Button} from "../GPElements/Button"; import {Button} from '../GPElements/Button';
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faFilter, faTimes} from "@fortawesome/free-solid-svg-icons"; import {faFilter, faTimes} from '@fortawesome/free-solid-svg-icons';
import {addKeyHandler, removeKeyHandler} from "../../utils/ShortkeyHandler"; import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler';
interface props { interface Props {
onFilterChange: (filter: string) => void onFilterChange: (filter: string) => void;
} }
interface state { interface state {
@ -14,18 +14,17 @@ interface state {
filter: string; filter: string;
} }
class FilterButton extends React.Component<props, state> { class FilterButton extends React.Component<Props, state> {
// filterfield anchor, needed to focus after filter btn click // filterfield anchor, needed to focus after filter btn click
private filterfield: HTMLInputElement | null | undefined; private filterfield: HTMLInputElement | null | undefined;
constructor(props: Props) {
constructor(props: props) {
super(props); super(props);
this.state = { this.state = {
filtervisible: false, filtervisible: false,
filter: '' filter: ''
} };
this.keypress = this.keypress.bind(this); this.keypress = this.keypress.bind(this);
this.enableFilterField = this.enableFilterField.bind(this); this.enableFilterField = this.enableFilterField.bind(this);
@ -43,34 +42,57 @@ class FilterButton extends React.Component<props, state> {
if (this.state.filtervisible) { if (this.state.filtervisible) {
return ( return (
<> <>
<input className={'form-control mr-sm-2 ' + style.searchinput} <input
type='text' placeholder='Filter' value={this.state.filter} className={'form-control mr-sm-2 ' + style.searchinput}
onChange={(e): void => { type='text'
this.props.onFilterChange(e.target.value); placeholder='Filter'
this.setState({filter: e.target.value}); value={this.state.filter}
}} onChange={(e): void => {
ref={(input): void => { this.props.onFilterChange(e.target.value);
this.filterfield = input; this.setState({filter: e.target.value});
}}/> }}
<Button title={<FontAwesomeIcon style={{ ref={(input): void => {
verticalAlign: 'middle', this.filterfield = input;
lineHeight: '130px' }}
}} icon={faTimes} size='1x'/>} color={{backgroundColor: 'red'}} onClick={(): void => { />
this.setState({filter: '', filtervisible: false}); <Button
}}/> title={
<FontAwesomeIcon
style={{
verticalAlign: 'middle',
lineHeight: '130px'
}}
icon={faTimes}
size='1x'
/>
}
color={{backgroundColor: 'red'}}
onClick={(): void => {
this.setState({filter: '', filtervisible: false});
}}
/>
</> </>
); );
} else { } else {
return (<Button return (
title={<span>Filter <FontAwesomeIcon <Button
style={{ title={
verticalAlign: 'middle', <span>
lineHeight: '130px' Filter{' '}
}} <FontAwesomeIcon
icon={faFilter} style={{
size='1x'/></span>} verticalAlign: 'middle',
color={{backgroundColor: 'cornflowerblue', color: 'white'}} lineHeight: '130px'
onClick={this.enableFilterField}/>) }}
icon={faFilter}
size='1x'
/>
</span>
}
color={{backgroundColor: 'cornflowerblue', color: 'white'}}
onClick={this.enableFilterField}
/>
);
} }
} }
@ -96,4 +118,4 @@ class FilterButton extends React.Component<props, state> {
} }
} }
export default FilterButton; export default FilterButton;

View File

@ -2,7 +2,10 @@
background-color: green; background-color: green;
border-radius: 5px; border-radius: 5px;
border-width: 0; border-width: 0;
color: white;
margin-right: 15px; margin-right: 15px;
padding: 6px; padding: 6px;
} }
.button:hover{
opacity: 0.7;
}

View File

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

View File

@ -1,5 +1,8 @@
import React from 'react'; import React from 'react';
import style from './Button.module.css'; import style from './Button.module.css';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {IconDefinition} from '@fortawesome/fontawesome-common-types';
import GlobalInfos from '../../utils/GlobalInfos';
interface ButtonProps { interface ButtonProps {
title: string | JSX.Element; title: string | JSX.Element;
@ -8,9 +11,30 @@ interface ButtonProps {
} }
export function Button(props: ButtonProps): JSX.Element { export function Button(props: ButtonProps): JSX.Element {
const theme = GlobalInfos.getThemeStyle();
return ( return (
<button className={style.button} style={props.color} onClick={props.onClick}> <button className={style.button + ' ' + theme.textcolor} style={props.color} onClick={props.onClick}>
{props.title} {props.title}
</button> </button>
); );
} }
interface IconButtonProps {
title: string | JSX.Element;
onClick?: () => void;
icon: IconDefinition;
}
export function IconButton(props: IconButtonProps): JSX.Element {
const theme = GlobalInfos.getThemeStyle();
return (
<button className={style.button + ' ' + theme.textcolor} style={{backgroundColor: '#00000000'}} onClick={props.onClick}>
<span style={{fontSize: 12}}>
<FontAwesomeIcon className={theme.textcolor} icon={props.icon} size='2x' />
</span>
<span style={{marginLeft: 10}}>{props.title}</span>
</button>
);
}

View File

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

View File

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

View File

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

View File

@ -6,11 +6,11 @@ import {NewActorPopupContent} from '../NewActorPopup/NewActorPopup';
import {APINode, callAPI} from '../../../utils/Api'; import {APINode, callAPI} from '../../../utils/Api';
import {ActorType} from '../../../types/VideoTypes'; import {ActorType} from '../../../types/VideoTypes';
import {GeneralSuccess} from '../../../types/GeneralTypes'; import {GeneralSuccess} from '../../../types/GeneralTypes';
import FilterButton from "../../FilterButton/FilterButton"; import FilterButton from '../../FilterButton/FilterButton';
interface props { interface Props {
onHide: () => void; onHide: () => void;
movie_id: number; movieId: number;
} }
interface state { interface state {
@ -22,11 +22,11 @@ interface state {
/** /**
* Popup for Adding a new Actor to a Video * Popup for Adding a new Actor to a Video
*/ */
class AddActorPopup extends React.Component<props, state> { class AddActorPopup extends React.Component<Props, state> {
// filterfield anchor, needed to focus after filter btn click // filterfield anchor, needed to focus after filter btn click
private filterfield: HTMLInputElement | null | undefined; private filterfield: HTMLInputElement | null | undefined;
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
@ -48,12 +48,19 @@ class AddActorPopup extends React.Component<props, state> {
return ( return (
<> <>
{/* todo render actor tiles here and add search field*/} {/* todo render actor tiles here and add search field*/}
<PopupBase title='Add new Actor to Video' onHide={this.props.onHide} banner={ <PopupBase
<button title='Add new Actor to Video'
className={style.newactorbutton} onHide={this.props.onHide}
onClick={(): void => { banner={
this.setState({contentDefault: false}); <button
}}>Create new Actor</button>} ParentSubmit={this.parentSubmit}> className={style.newactorbutton}
onClick={(): void => {
this.setState({contentDefault: false});
}}>
Create new Actor
</button>
}
ParentSubmit={this.parentSubmit}>
{this.resolvePage()} {this.resolvePage()}
</PopupBase> </PopupBase>
</> </>
@ -65,11 +72,18 @@ class AddActorPopup extends React.Component<props, state> {
* @returns {JSX.Element} * @returns {JSX.Element}
*/ */
resolvePage(): JSX.Element { resolvePage(): JSX.Element {
if (this.state.contentDefault) return (this.getContent()); if (this.state.contentDefault) {
else return (<NewActorPopupContent onHide={(): void => { return this.getContent();
this.loadActors(); } else {
this.setState({contentDefault: true}); return (
}}/>); <NewActorPopupContent
onHide={(): void => {
this.loadActors();
this.setState({contentDefault: true});
}}
/>
);
}
} }
/** /**
@ -81,15 +95,19 @@ class AddActorPopup extends React.Component<props, state> {
return ( return (
<> <>
<div className={style.searchbar}> <div className={style.searchbar}>
<FilterButton onFilterChange={(filter): void => { <FilterButton
this.setState({filter: filter}) onFilterChange={(filter): void => {
}}/> this.setState({filter: filter});
}}
/>
</div> </div>
{this.state.actors.filter(this.filterSearch).map((el) => (<ActorTile actor={el} onClick={this.tileClickHandler}/>))} {this.state.actors.filter(this.filterSearch).map((el) => (
<ActorTile actor={el} onClick={this.tileClickHandler} />
))}
</> </>
); );
} else { } else {
return (<div>somekind of loading</div>); return <div>somekind of loading</div>;
} }
} }
@ -98,25 +116,29 @@ class AddActorPopup extends React.Component<props, state> {
*/ */
tileClickHandler(actor: ActorType): void { tileClickHandler(actor: ActorType): void {
// fetch the available actors // fetch the available actors
callAPI<GeneralSuccess>(APINode.Actor, { callAPI<GeneralSuccess>(
action: 'addActorToVideo', APINode.Actor,
ActorId: actor.ActorId, {
MovieId: this.props.movie_id action: 'addActorToVideo',
}, result => { ActorId: actor.ActorId,
if (result.result === 'success') { MovieId: this.props.movieId
// return back to player page },
this.props.onHide(); (result) => {
} else { if (result.result === 'success') {
console.error('an error occured while fetching actors: ' + result); // return back to player page
this.props.onHide();
} else {
console.error('an error occured while fetching actors: ' + result);
}
} }
}); );
} }
/** /**
* load the actors from backend and set state * load the actors from backend and set state
*/ */
loadActors(): void { loadActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, result => { callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, (result) => {
this.setState({actors: result}); this.setState({actors: result});
}); });
} }

View File

@ -3,10 +3,10 @@ import Tag from '../../Tag/Tag';
import PopupBase from '../PopupBase'; import PopupBase from '../PopupBase';
import {APINode, callAPI} from '../../../utils/Api'; import {APINode, callAPI} from '../../../utils/Api';
import {TagType} from '../../../types/VideoTypes'; import {TagType} from '../../../types/VideoTypes';
import FilterButton from "../../FilterButton/FilterButton"; import FilterButton from '../../FilterButton/FilterButton';
import styles from './AddTagPopup.module.css' import styles from './AddTagPopup.module.css';
interface props { interface Props {
onHide: () => void; onHide: () => void;
submit: (tagId: number, tagName: string) => void; submit: (tagId: number, tagName: string) => void;
} }
@ -19,8 +19,8 @@ interface state {
/** /**
* component creates overlay to add a new tag to a video * component creates overlay to add a new tag to a video
*/ */
class AddTagPopup extends React.Component<props, state> { class AddTagPopup extends React.Component<Props, state> {
constructor(props: props) { constructor(props: Props) {
super(props); super(props);
this.state = {items: [], filter: ''}; this.state = {items: [], filter: ''};
@ -42,13 +42,11 @@ class AddTagPopup extends React.Component<props, state> {
return ( return (
<PopupBase title='Add a Tag to this Video:' onHide={this.props.onHide} ParentSubmit={this.parentSubmit}> <PopupBase title='Add a Tag to this Video:' onHide={this.props.onHide} ParentSubmit={this.parentSubmit}>
<div className={styles.actionbar}> <div className={styles.actionbar}>
<FilterButton onFilterChange={(filter): void => this.setState({filter: filter})}/> <FilterButton onFilterChange={(filter): void => this.setState({filter: filter})} />
</div> </div>
{this.state.items ? {this.state.items
this.state.items.filter(this.tagFilter).map((i) => ( ? this.state.items.filter(this.tagFilter).map((i) => <Tag tagInfo={i} onclick={(): void => this.onItemClick(i)} />)
<Tag tagInfo={i} : null}
onclick={(): void => this.onItemClick(i)}/>
)) : null}
</PopupBase> </PopupBase>
); );
} }

View File

@ -0,0 +1,51 @@
import {shallow} from 'enzyme';
import React from 'react';
import {ButtonPopup} from './ButtonPopup';
import exp from "constants";
describe('<ButtonPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<ButtonPopup/>);
wrapper.unmount();
});
it('renders two buttons', function () {
const wrapper = shallow(<ButtonPopup/>);
expect(wrapper.find('Button')).toHaveLength(2);
});
it('renders three buttons if alternative defined', function () {
const wrapper = shallow(<ButtonPopup AlternativeButtonTitle='alt'/>);
expect(wrapper.find('Button')).toHaveLength(3);
});
it('test click handlings', function () {
const althandler = jest.fn();
const denyhandler = jest.fn();
const submithandler = jest.fn();
const wrapper = shallow(<ButtonPopup DenyButtonTitle='deny' onDeny={denyhandler} SubmitButtonTitle='submit'
onSubmit={submithandler} AlternativeButtonTitle='alt'
onAlternativeButton={althandler}/>);
wrapper.find('Button').findWhere(e => e.props().title === "deny").simulate('click');
expect(denyhandler).toHaveBeenCalledTimes(1);
wrapper.find('Button').findWhere(e => e.props().title === "alt").simulate('click');
expect(althandler).toHaveBeenCalledTimes(1);
wrapper.find('Button').findWhere(e => e.props().title === "submit").simulate('click');
expect(submithandler).toHaveBeenCalledTimes(1);
});
it('test Parentsubmit and parenthide callbacks', function () {
const ondeny = jest.fn();
const onsubmit = jest.fn();
const wrapper = shallow(<ButtonPopup DenyButtonTitle='deny' SubmitButtonTitle='submit' onDeny={ondeny} onSubmit={onsubmit} AlternativeButtonTitle='alt'/>);
wrapper.find('PopupBase').props().onHide();
expect(ondeny).toHaveBeenCalledTimes(1);
wrapper.find('PopupBase').props().ParentSubmit();
expect(onsubmit).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,58 @@
import React from 'react';
import PopupBase from '../PopupBase';
import {Button} from '../../GPElements/Button';
/**
* Delete Video popup
* can only be rendered once!
* @constructor
*/
export const ButtonPopup = (props: {
onSubmit: () => void;
onDeny: () => void;
onAlternativeButton?: () => void;
SubmitButtonTitle: string;
DenyButtonTitle: string;
AlternativeButtonTitle?: string;
Title: string;
}): JSX.Element => {
return (
<>
<PopupBase
title={props.Title}
onHide={(): void => props.onDeny()}
height='200px'
width='400px'
ParentSubmit={(): void => {
props.onSubmit();
}}>
<Button
onClick={(): void => {
props.onSubmit();
}}
title={props.SubmitButtonTitle}
/>
{props.AlternativeButtonTitle ? (
<Button
color={{backgroundColor: 'darkorange'}}
onClick={(): void => {
props.onAlternativeButton ? props.onAlternativeButton() : null;
}}
title={props.AlternativeButtonTitle}
/>
) : (
<></>
)}
<Button
color={{backgroundColor: 'red'}}
onClick={(): void => {
props.onDeny();
}}
title={props.DenyButtonTitle}
/>
</PopupBase>
</>
);
};

View File

@ -25,7 +25,7 @@ describe('<NewActorPopupContent/>', () => {
const wrapper = shallow(<NewActorPopupContent onHide={() => {func();}}/>); const wrapper = shallow(<NewActorPopupContent onHide={() => {func();}}/>);
// manually set typed in actorname // manually set typed in actorname
wrapper.instance().value = 'testactorname'; wrapper.instance().nameValue = 'testactorname';
global.fetch = prepareFetchApi({}); global.fetch = prepareFetchApi({});
@ -55,6 +55,6 @@ describe('<NewActorPopupContent/>', () => {
wrapper.find('input').simulate('change', {target: {value: 'testinput'}}); wrapper.find('input').simulate('change', {target: {value: 'testinput'}});
expect(wrapper.instance().value).toBe('testinput'); expect(wrapper.instance().nameValue).toBe('testinput');
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@
font-size: smaller; font-size: smaller;
font-weight: bold; font-weight: bold;
height: 20px; height: 20px;
max-width: 266px;
text-align: center; text-align: center;
} }
@ -20,13 +19,6 @@
vertical-align: middle; vertical-align: middle;
} }
.previewimage {
max-height: 400px;
max-width: 410px;
min-height: 150px;
min-width: 266px;
}
.previewbottom { .previewbottom {
height: 20px; height: 20px;
} }

View File

@ -3,16 +3,19 @@ import style from './Preview.module.css';
import {Spinner} from 'react-bootstrap'; import {Spinner} from 'react-bootstrap';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import GlobalInfos from '../../utils/GlobalInfos'; import GlobalInfos from '../../utils/GlobalInfos';
import {APINode, callAPIPlain} from '../../utils/Api'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
interface PreviewProps { interface PreviewProps {
name: string; name: string;
movie_id: number; picLoader: (callback: (pic: string) => void) => void;
linkPath?: string;
onClick?: () => void; onClick?: () => void;
aspectRatio?: number;
} }
interface PreviewState { interface PreviewState {
previewpicture: string | null; picLoaded: boolean | null;
} }
/** /**
@ -20,42 +23,98 @@ interface PreviewState {
* floating side by side * floating side by side
*/ */
class Preview extends React.Component<PreviewProps, PreviewState> { class Preview extends React.Component<PreviewProps, PreviewState> {
// store the picture to display
pic?: string;
static readonly DefMinWidth = 266;
static readonly DefMaxWidth = 410;
static readonly DefMinHeight = 150;
static readonly DefMaxHeight = 400;
constructor(props: PreviewProps) { constructor(props: PreviewProps) {
super(props); super(props);
this.state = { this.state = {
previewpicture: null picLoaded: null
}; };
} }
componentDidMount(): void { componentDidMount(): void {
callAPIPlain(APINode.Video, {action: 'readThumbnail', movieid: this.props.movie_id}, (result) => { this.props.picLoader((result) => {
this.pic = result;
this.setState({ this.setState({
previewpicture: result picLoaded: result !== ''
}); });
}); });
} }
render(): JSX.Element { render(): JSX.Element {
if (this.props.linkPath != null) {
return <Link to={this.props.linkPath}>{this.content()}</Link>;
} else {
return this.content();
}
}
content(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
const ratio = this.props.aspectRatio;
let dimstyle = null;
// check if aspect ratio is passed
if (ratio != null) {
// if ratio is <1 we need to calc height
if (ratio < 1) {
const height = Preview.DefMaxWidth * ratio;
dimstyle = {height: height, width: Preview.DefMaxWidth};
} else {
const width = Preview.DefMaxHeight * ratio;
dimstyle = {width: width, height: Preview.DefMaxHeight};
}
}
return ( return (
<Link to={'/player/' + this.props.movie_id} onClick={this.props.onClick}> <div
<div className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}> className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
<div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div> onClick={this.props.onClick}>
<div className={style.previewpic}> <div
{this.state.previewpicture !== null ? style={{maxWidth: dimstyle !== null ? dimstyle.width : Preview.DefMaxWidth}}
<img className={style.previewimage} className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>
src={this.state.previewpicture} {this.props.name}
alt='Pic loading.'/> :
<span className={style.loadAnimation}><Spinner animation='border'/></span>}
</div>
<div className={style.previewbottom}>
</div>
</div> </div>
</Link> <div style={dimstyle !== null ? dimstyle : undefined} 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
style={
dimstyle !== null
? dimstyle
: {
minWidth: Preview.DefMinWidth,
maxWidth: Preview.DefMaxWidth,
minHeight: Preview.DefMinHeight,
maxHeight: Preview.DefMaxHeight
}
}
src={this.pic}
alt='Pic loading.'
/>
)}
</div>
<div className={style.previewbottom} />
</div>
); );
} }
} }
@ -63,15 +122,12 @@ class Preview extends React.Component<PreviewProps, PreviewState> {
/** /**
* Component for a Tag-name tile (used in category page) * Component for a Tag-name tile (used in category page)
*/ */
export class TagPreview extends React.Component<{ name: string }> { export class TagPreview extends React.Component<{name: string}> {
render(): JSX.Element { render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<div <div className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}>
className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}> <div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div>
<div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>
{this.props.name}
</div>
</div> </div>
); );
} }

View File

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

View File

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

View File

@ -5,8 +5,8 @@ import {Link} from 'react-router-dom';
import {TagType} from '../../types/VideoTypes'; import {TagType} from '../../types/VideoTypes';
interface props { interface props {
onclick?: (_: string) => void onclick?: (_: string) => void;
tagInfo: TagType tagInfo: TagType;
} }
/** /**
@ -17,18 +17,15 @@ class Tag extends React.Component<props> {
if (this.props.onclick) { if (this.props.onclick) {
return this.renderButton(); return this.renderButton();
} else { } else {
return ( return <Link to={'/media/categories/' + this.props.tagInfo.TagId}>{this.renderButton()}</Link>;
<Link to={'/categories/' + this.props.tagInfo.TagId}>
{this.renderButton()}
</Link>
);
} }
} }
renderButton(): JSX.Element { renderButton(): JSX.Element {
return ( return (
<button className={styles.tagbtn} onClick={(): void => this.TagClick()} <button className={styles.tagbtn} onClick={(): void => this.TagClick()} data-testid='Test-Tag'>
data-testid='Test-Tag'>{this.props.tagInfo.TagName}</button> {this.props.tagInfo.TagName}
</button>
); );
} }

View File

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

View File

@ -1,114 +1,39 @@
import React from 'react'; import React from 'react';
import Preview from '../Preview/Preview'; import Preview from '../Preview/Preview';
import style from './VideoContainer.module.css';
import {VideoTypes} from '../../types/ApiTypes'; import {VideoTypes} from '../../types/ApiTypes';
import DynamicContentContainer from '../DynamicContentContainer/DynamicContentContainer';
import {APINode, callAPIPlain} from '../../utils/Api';
interface props { interface Props {
data: VideoTypes.VideoUnloadedType[]; data: VideoTypes.VideoUnloadedType[];
onScrollPositionChange?: (scrollPos: number, loadedTiles: number) => void; children?: JSX.Element;
initialScrollPosition?: {scrollPos: number, loadedTiles: number};
} }
interface state { const VideoContainer = (props: Props): JSX.Element => {
loadeditems: VideoTypes.VideoUnloadedType[]; return (
selectionnr: number; <DynamicContentContainer
} renderElement={(el): JSX.Element => (
<Preview
/** key={el.MovieId}
* A videocontainer storing lots of Preview elements aspectRatio={el.Ratio > 0 ? el.Ratio : undefined}
* includes scroll handling and loading of preview infos picLoader={(callback: (pic: string) => void): void => {
*/ callAPIPlain(
class VideoContainer extends React.Component<props, state> { APINode.Video,
// stores current index of loaded elements {
loadindex: number = 0; action: 'readThumbnail',
Movieid: el.MovieId
constructor(props: props) { },
super(props); (result) => callback(result)
);
this.state = { }}
loadeditems: [], name={el.MovieName}
selectionnr: 0 linkPath={'/media/player/' + el.MovieId}
}; />
} )}
data={props.data}>
componentDidMount(): void { {props.children}
document.addEventListener('scroll', this.trackScrolling); </DynamicContentContainer>
);
console.log(this.props.initialScrollPosition) };
if(this.props.initialScrollPosition !== undefined){
this.loadPreviewBlock(this.props.initialScrollPosition.loadedTiles, () => {
if(this.props.initialScrollPosition !== undefined)
window.scrollTo(0, this.props.initialScrollPosition.scrollPos);
});
}else{
this.loadPreviewBlock(16);
}
}
render(): JSX.Element {
return (
<div className={style.maincontent}>
{this.state.loadeditems.map(elem => (
<Preview
key={elem.MovieId}
name={elem.MovieName}
movie_id={elem.MovieId}
onClick={(): void => {
if (this.props.onScrollPositionChange !== undefined)
this.props.onScrollPositionChange(window.pageYOffset - document.documentElement.clientHeight, this.loadindex)
}}/>
))}
{/*todo css for no items to show*/}
{this.state.loadeditems.length === 0 ?
'no items to show!' : null}
{this.props.children}
</div>
);
}
componentWillUnmount(): void {
this.setState({});
// unbind scroll listener when unmounting component
document.removeEventListener('scroll', this.trackScrolling);
}
/**
* load previews to the container
* @param nr number of previews to load
*/
loadPreviewBlock(nr: number, callback? : () => void): void {
console.log('loadpreviewblock called ...');
let ret = [];
for (let i = 0; i < nr; i++) {
// only add if not end
if (this.props.data.length > this.loadindex + i) {
ret.push(this.props.data[this.loadindex + i]);
}
}
this.setState({
loadeditems: [
...this.state.loadeditems,
...ret
]
}, callback);
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 (document.documentElement.clientHeight + document.documentElement.scrollTop + 200 >= document.documentElement.offsetHeight) {
this.loadPreviewBlock(8);
if (this.props.onScrollPositionChange !== undefined)
this.props.onScrollPositionChange(document.documentElement.clientHeight + window.pageYOffset, this.loadindex)
}
};
}
export default VideoContainer; export default VideoContainer;

View File

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

View File

@ -8,20 +8,6 @@ describe('<ActorOverviewPage/>', function () {
wrapper.unmount(); wrapper.unmount();
}); });
it('test inerstion of actor tiles', function () {
const wrapper = shallow(<ActorOverviewPage/>);
wrapper.setState({
actors: [{
thumbnail: '',
name: 'testname',
actor_id: 42
}]
});
expect(wrapper.find('ActorTile')).toHaveLength(1);
});
it('test newtagpopup visibility', function () { it('test newtagpopup visibility', function () {
const wrapper = shallow(<ActorOverviewPage/>); const wrapper = shallow(<ActorOverviewPage/>);

View File

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

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