Compare commits

..

134 Commits
PHP ... master

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
be40475615 use correct regex for the reverse proxy 2021-03-09 19:31:56 +01:00
b625a4d958 Merge branch 'Oauth_auth' into 'master'
OAuth2

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

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

See merge request lukas/openmediacenter!35
2021-02-23 16:01:30 +00:00
f2b5fb6587 implement full load of videos and startdata
modify api where necessary
2021-02-23 16:01:29 +00:00
163 changed files with 20725 additions and 22403 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,52 +1,90 @@
image: node:14
stages:
- prepare
- build
- build_frontend
- build_backend
- test
- packaging
- deploy
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .npm/
- node_modules/
include:
- template: Code-Quality.gitlab-ci.yml
variables:
SAST_DISABLE_DIND: "true"
Node_dependencies:
stage: prepare
Minimize_Frontend:
stage: build_frontend
before_script:
- yarn install --cache-folder .yarn
script:
- npm ci --cache .npm --prefer-offline
Minimize:
stage: build
script:
- npm run build
- yarn run build
- rm build/*/*/*.map
artifacts:
expire_in: 7 days
expire_in: 2 days
paths:
- build/
needs: ["Node_dependencies"]
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .yarn/
- node_modules/
Build_Backend:
image: luki42/go-ffmpeg:latest
stage: build_backend
script:
- cd apiGo
- go build -v -tags sharedffmpeg -o openmediacenter
- cp -r ../build/ ./static/
- go build -v -tags static -o openmediacenter_full
- env GOOS=windows GOARCH=amd64 go build -v -tags static -o openmediacenter.exe
needs:
- Minimize_Frontend
artifacts:
expire_in: 2 days
paths:
- "./apiGo/openmediacenter*"
Frontend_Tests:
stage: test
before_script:
- yarn install --cache-folder .yarn
script:
- npm run test
- yarn run test
needs: []
artifacts:
reports:
junit:
- ./junit.xml
needs: ["Node_dependencies"]
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .yarn/
- node_modules/
code_quality:
tags:
- dind
Backend_Tests:
image: golang:latest
stage: test
script:
- cd apiGo
- go install github.com/jstemmer/go-junit-report@v0.9.1
- go test -v ./... 2>&1 | go-junit-report -set-exit-code > report.xml
needs: []
artifacts:
when: always
reports:
junit: ./apiGo/report.xml
lint:
stage: test
before_script:
- yarn install --cache-folder .yarn
script:
- yarn run lint
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .yarn/
- ./node_modules/
artifacts:
reports:
codequality: gl-codequality.json
needs: []
Debian_Server:
stage: packaging
@ -55,33 +93,69 @@ Debian_Server:
- vers=$(grep -Po '"version":.*?[^\\]",' package.json | grep -Po '[0-9]+\.[0-9]+\.[0-9]+') # parse the version out of package .json
- cd deb
- mkdir -p "./OpenMediaCenter/var/www/openmediacenter/videos/"
- mkdir -p "./OpenMediaCenter/tmp/"
- mkdir -p "./OpenMediaCenter/usr/bin/"
- cp -r ../build/* ./OpenMediaCenter/var/www/openmediacenter/
- cp -r ../api ./OpenMediaCenter/var/www/openmediacenter/
- cp ../database.sql ./OpenMediaCenter/tmp/openmediacenter.sql
- cp ../apiGo/openmediacenter ./OpenMediaCenter/usr/bin/
- 'echo "Version: ${vers}" >> ./OpenMediaCenter/DEBIAN/control'
- chmod -R 0775 *
- dpkg-deb --build OpenMediaCenter
- mv OpenMediaCenter.deb OpenMediaCenter-${vers}_amd64.deb
artifacts:
expire_in: 7 days
paths:
- deb/OpenMediaCenter-*.deb
needs: ["Minimize"]
needs:
- Minimize_Frontend
- Build_Backend
Test_Server:
.Test_Server_Common:
stage: deploy
image: luki42/alpineopenssh:latest
image: luki42/ssh:latest
needs:
- Frontend_Tests
- Backend_Tests
- Debian_Server
only:
- master
script:
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY")
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- scp deb/OpenMediaCenter-*.deb root@192.168.0.42:/tmp/
- 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
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,101 +0,0 @@
<?php
require_once 'RequestBase.php';
/**
* Class Tags
* backend to handle Tag database interactions
*/
class Tags extends RequestBase {
function initHandlers() {
$this->addToDB();
$this->getFromDB();
$this->delete();
}
private function addToDB() {
/**
* creates a new tag
* query requirements:
* * tagname -- name of the new tag
*/
$this->addActionHandler("createTag", function () {
// skip tag create if already existing
$query = "INSERT IGNORE INTO tags (tag_name) VALUES ('" . $_POST['tagname'] . "')";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
/**
* adds a new tag to an existing video
*
* query requirements:
* * movieid -- the id of the video to add the tag to
* * id -- the tag id which tag to add
*/
$this->addActionHandler("addTag", function () {
$movieid = $_POST['movieid'];
$tagid = $_POST['id'];
// skip tag add if already assigned
$query = "INSERT IGNORE INTO video_tags(tag_id, video_id) VALUES ('$tagid','$movieid')";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
}
private function getFromDB() {
/**
* returns all available tags from database
*/
$this->addActionHandler("getAllTags", function () {
$query = "SELECT tag_name,tag_id from tags";
$result = $this->conn->query($query);
$rows = array();
while ($r = mysqli_fetch_assoc($result)) {
array_push($rows, $r);
}
$this->commitMessage(json_encode($rows));
});
}
private function delete() {
/**
* delete a Tag with specified id
*/
$this->addActionHandler("deleteTag", function () {
$tag_id = $_POST['tagId'];
$force = $_POST['force'];
// delete key constraints first
if ($force === "true") {
$query = "DELETE FROM video_tags WHERE tag_id=$tag_id";
if ($this->conn->query($query) !== TRUE) {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
}
$query = "DELETE FROM tags WHERE tag_id=$tag_id";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
// check if error is a constraint error
if (preg_match('/^.*a foreign key constraint fails.*$/i', $this->conn->error)) {
$this->commitMessage('{"result":"not empty tag"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->eror . '"}');
}
}
});
}
}

View File

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

View File

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

View File

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

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

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

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

@ -0,0 +1,157 @@
package api
import (
"fmt"
"openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/database"
)
func addActorsHandlers() {
saveActorsToDB()
getActorsFromDB()
}
func getActorsFromDB() {
/**
* @api {post} /api/actor [getAllActors]
* @apiDescription Get all available Actors
* @apiName getAllActors
* @apiGroup Actor
*
* @apiSuccess {Object[]} . Array of Actors available
* @apiSuccess {uint32} .ActorId Actor Id
* @apiSuccess {string} .Name Actor Name
* @apiSuccess {string} .Thumbnail Portrait Thumbnail
*/
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)))
})
/**
* @api {post} /api/actor [getActorsOfVideo]
* @apiDescription Get all actors playing in one video
* @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
JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=%d`, args.MovieId)
context.Json(readActorsFromResultset(database.Query(query)))
})
/**
* @api {post} /api/actor [getActorInfo]
* @apiDescription Get all infos for an actor
* @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
JOIN videos v on v.movie_id = actors_videos.video_id
WHERE actors_videos.actor_id=%d`, args.ActorId)
videos := readVideosFromResultset(database.Query(query))
query = fmt.Sprintf("SELECT actor_id, name, thumbnail FROM actors WHERE actor_id=%d", args.ActorId)
actor := readActorsFromResultset(database.Query(query))[0]
var result = struct {
Videos []types.VideoUnloadedType
Info types.Actor
}{
Videos: videos,
Info: actor,
}
context.Json(result)
})
}
func saveActorsToDB() {
/**
* @api {post} /api/video [createActor]
* @apiDescription Create a new Actor
* @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 (?)"
// todo bit ugly
context.Text(string(database.SuccessQuery(query, args.ActorName)))
})
/**
* @api {post} /api/video [addActorToVideo]
* @apiDescription Add Actor to Video
* @apiName addActorToVideo
* @apiGroup Actor
*
* @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)))
})
}

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"})
})
}

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

@ -0,0 +1,78 @@
package api
import (
"database/sql"
"fmt"
"openmediacenter/apiGo/api/types"
)
// MovieId - MovieName : pay attention to the order!
func readVideosFromResultset(rows *sql.Rows) []types.VideoUnloadedType {
result := []types.VideoUnloadedType{}
for rows.Next() {
var vid types.VideoUnloadedType
err := rows.Scan(&vid.MovieId, &vid.MovieName)
if err != nil {
fmt.Println(err.Error())
return nil
}
result = append(result, vid)
}
rows.Close()
return result
}
// TagID - TagName : pay attention to the order!
func readTagsFromResultset(rows *sql.Rows) []types.Tag {
// initialize with empty array!
result := []types.Tag{}
for rows.Next() {
var tag types.Tag
err := rows.Scan(&tag.TagId, &tag.TagName)
if err != nil {
panic(err.Error()) // proper Error handling instead of panic in your app
}
result = append(result, tag)
}
rows.Close()
return result
}
// ActorId - ActorName - Thumbnail : pay attention to the order!
func readActorsFromResultset(rows *sql.Rows) []types.Actor {
var result []types.Actor
for rows.Next() {
var actor types.Actor
var thumbnail []byte
err := rows.Scan(&actor.ActorId, &actor.Name, &thumbnail)
if len(thumbnail) != 0 {
actor.Thumbnail = string(thumbnail)
}
if err != nil {
panic(err.Error()) // proper Error handling instead of panic in your app
}
result = append(result, actor)
}
rows.Close()
return result
}
// ID - Name : pay attention to the order!
func readTVshowsFromResultset(rows *sql.Rows) []types.TVShow {
result := []types.TVShow{}
for rows.Next() {
var vid types.TVShow
err := rows.Scan(&vid.Id, &vid.Name)
if err != nil {
panic(err.Error()) // proper Error handling instead of panic in your app
}
result = append(result, vid)
}
rows.Close()
return result
}

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

@ -0,0 +1,176 @@
package api
import (
"openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/database/settings"
"openmediacenter/apiGo/videoparser"
"regexp"
"strings"
)
func addSettingsHandlers() {
saveSettingsToDB()
getSettingsFromDB()
reIndexHandling()
}
func getSettingsFromDB() {
/**
* @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()
var ret = struct {
Settings *types.SettingsType
Sizes *types.SettingsSizeType
}{
Settings: &result,
Sizes: &sizes,
}
context.Json(ret)
})
/**
* @api {post} /api/settings [loadInitialData]
* @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() {
/**
* @api {post} /api/settings [saveGeneralSettings]
* @apiDescription Save the global settings provided
* @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 := `
UPDATE settings SET
video_path=?,
episode_path=?,
password=?,
mediacenter_name=?,
TMDB_grabbing=?,
DarkMode=?
WHERE 1`
// todo avoid conversion
context.Text(string(database.SuccessQuery(query,
args.VideoPath, args.EpisodePath, args.Password,
args.MediacenterName, args.TMDBGrabbing, args.DarkMode)))
})
}
// methods for handling reindexing and cleanup of db gravity
func reIndexHandling() {
/**
* @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()
context.Text(string(database.ManualSuccessResponse(nil)))
})
/**
* @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()
})
}

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

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

@ -0,0 +1,136 @@
package api
import (
"fmt"
"openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/database"
"regexp"
)
func addTagHandlers() {
getFromDB()
addToDB()
deleteFromDB()
}
func deleteFromDB() {
/**
* @api {post} /api/tags [deleteTag]
* @apiDescription Start Database video reindex Job
* @apiName deleteTag
* @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
if args.Force {
query := fmt.Sprintf("DELETE FROM video_tags WHERE tag_id=%d", args.TagId)
err := database.Edit(query)
// respond only if result not successful
if err != nil {
context.Text(string(database.ManualSuccessResponse(err)))
return
}
}
query := fmt.Sprintf("DELETE FROM tags WHERE tag_id=%d", args.TagId)
err = database.Edit(query)
if err == nil {
// return if successful
context.Text(string(database.ManualSuccessResponse(err)))
} else {
// check with regex if its the key constraint Error
r := regexp.MustCompile("^.*a foreign key constraint fails.*$")
if r.MatchString(err.Error()) {
context.Text(string(database.ManualSuccessResponse(fmt.Errorf("not empty tag"))))
} else {
context.Text(string(database.ManualSuccessResponse(err)))
}
}
})
}
func getFromDB() {
/**
* @api {post} /api/tags [getAllTags]
* @apiDescription get all available Tags
* @apiName getAllTags
* @apiGroup Tags
*
* @apiSuccess {Object[]} array of tag objects
* @apiSuccess {uint32} TagId
* @apiSuccess {string} TagName name of the Tag
*/
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() {
/**
* @api {post} /api/tags [createTag]
* @apiDescription create a new tag
* @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 (?)"
context.Text(string(database.SuccessQuery(query, args.TagName)))
})
/**
* @api {post} /api/tags [addTag]
* @apiDescription Add new tag to video
* @apiName addTag
* @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 (?,?)"
context.Text(string(database.SuccessQuery(query, args.TagId, args.MovieId)))
})
}

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

@ -0,0 +1,485 @@
package api
import (
"encoding/json"
"fmt"
"net/url"
"openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/api/types"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database"
"os"
"strconv"
"strings"
)
func addVideoHandlers() {
getVideoHandlers()
loadVideosHandlers()
addToVideoHandlers()
}
func getVideoHandlers() {
/**
* @api {post} /api/video [getMovies]
* @apiDescription Request available Videos
* @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
// 1 is the id of the ALL tag
if args.Tag != 1 {
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 tags t on vt.tag_id = t.tag_id
WHERE t.tag_id = %d %s`, args.Tag, SortClause)
} else {
query = fmt.Sprintf("SELECT movie_id,movie_name,previewratio, (SELECT 'All' as tag_name) FROM videos %s", SortClause)
}
var result struct {
Videos []types.VideoUnloadedType
TagName string
}
rows := database.Query(query)
vids := []types.VideoUnloadedType{}
var name string
for rows.Next() {
var vid types.VideoUnloadedType
err := rows.Scan(&vid.MovieId, &vid.MovieName, &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)
})
/**
* @api {post} /api/video [readThumbnail]
* @apiDescription Load Thubnail of specific Video
* @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
query := fmt.Sprintf("SELECT thumbnail FROM videos WHERE movie_id=%d", args.Movieid)
err = database.QueryRow(query).Scan(&pic)
if err != nil {
fmt.Printf("the thumbnail of movie id %d couldn't be found", args.Movieid)
return
}
context.Text(string(pic))
})
/**
* @api {post} /api/video [getRandomMovies]
* @apiDescription Load random videos
* @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 {
Tags []types.Tag
Videos []types.VideoUnloadedType
}
whereclause := "WHERE 1"
if len(args.TagFilter) > 0 {
d, _ := json.Marshal(args.TagFilter)
vals := strings.Trim(string(d), "[]")
whereclause = fmt.Sprintf("WHERE tag_id IN (%s)", vals)
}
query := fmt.Sprintf(`
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
WHERE %s
GROUP BY t.tag_id`, ids)
rows := database.Query(query)
if rows != nil {
for rows.Next() {
var tag types.Tag
err := rows.Scan(&tag.TagName, &tag.TagId)
if err != nil {
panic(err.Error()) // proper Error handling instead of panic in your app
}
// append to final array
result.Tags = append(result.Tags, tag)
}
}
} else {
result.Tags = []types.Tag{}
}
context.Json(result)
})
/**
* @api {post} /api/video [getSearchKeyWord]
* @apiDescription Get videos for search keyword
* @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
WHERE movie_name LIKE '%%%s%%'
ORDER BY likes DESC, create_date DESC, movie_name`, args.KeyWord)
context.Json(readVideosFromResultset(database.Query(query)))
})
}
// function to handle stuff for loading specific videos and startdata
func loadVideosHandlers() {
/**
* @api {post} /api/video [loadVideo]
* @apiDescription Load all data for a specific video
* @apiName loadVideo
* @apiGroup video
*
* @apiParam {int} MovieId ID of video
*
* @apiSuccess {string} MovieName Videoname
* @apiSuccess {uint32} MovieId Video ID
* @apiSuccess {string} MovieUrl Url to video file
* @apiSuccess {string} Poster Base64 encoded Poster
* @apiSuccess {uint64} Likes Number of likes
* @apiSuccess {uint16} Quality Video FrameWidth
* @apiSuccess {uint16} Length Video Length in seconds
*
*
* @apiSuccess {Object[]} Tags Array of tags of video
* @apiSuccess {string} Tags.TagName Tagname
* @apiSuccess {uint32} Tags.TagId Tag ID
*
* @apiSuccess {Object[]} SuggestedTag Array of tags for quick add suggestions
* @apiSuccess {string} SuggestedTag.TagName Tagname
* @apiSuccess {uint32} SuggestedTag.TagId Tag ID
*
* @apiSuccess {Object[]} Actors Array of Actors playing in this video
* @apiSuccess {uint32} Actors.ActorId Actor Id
* @apiSuccess {string} Actors.Name Actor Name
* @apiSuccess {string} Actors.Thumbnail Portrait Thumbnail
*/
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 poster []byte
var thumbnail []byte
err := database.QueryRow(query).Scan(&res.MovieName, &res.MovieUrl, &res.MovieId, &thumbnail, &poster, &res.Likes, &res.Quality, &res.Length, &res.ReleaseDate)
if err != nil {
fmt.Printf("Error getting full data list of videoid - %d", args.MovieId)
fmt.Println(err.Error())
return
}
// we ned to urlencode the movieurl
res.MovieUrl = url.PathEscape(res.MovieUrl)
// we need to stringify the pic byte array
res.Poster = string(poster)
// if poster in db is empty we use the thumbnail
if res.Poster == "" {
res.Poster = string(thumbnail)
}
// now add the tags of this video
query = fmt.Sprintf(`SELECT t.tag_id, t.tag_name FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE video_tags.video_id=%d
GROUP BY t.tag_id`, args.MovieId)
res.Tags = readTagsFromResultset(database.Query(query))
query = fmt.Sprintf(`SELECT * FROM tags
WHERE tag_id NOT IN (
SELECT video_tags.tag_id FROM video_tags
WHERE video_id=%d)
ORDER BY rand()
LIMIT 5`, args.MovieId)
res.SuggestedTag = readTagsFromResultset(database.Query(query))
// query the actors corresponding to video
query = fmt.Sprintf(`SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=%d`, args.MovieId)
res.Actors = readActorsFromResultset(database.Query(query))
context.Json(res)
})
/**
* @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
// query settings and infotile values
query := `
SELECT (
SELECT COUNT(*) FROM videos
) AS videonr,
(
SELECT COUNT(*) FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
) AS tagged,
(
SELECT COUNT(*) FROM video_tags as vt
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='hd'
) AS hd,
(
SELECT COUNT(*) FROM video_tags as vt
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='fullhd'
) AS fullhd,
(
SELECT COUNT(*) FROM video_tags as vt
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name='lowquality'
) AS lq,
(
SELECT COUNT(*) as nr FROM tags
) as tags
LIMIT 1`
_ = database.QueryRow(query).Scan(&result.VideoNr, &result.Tagged, &result.HDNr, &result.FullHdNr, &result.SDNr, &result.DifferentTags)
context.Json(result)
})
}
func addToVideoHandlers() {
/**
* @api {post} /api/video [addLike]
* @apiDescription Add a like to a video
* @apiName addLike
* @apiGroup video
*
* @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)))
})
/**
* @api {post} /api/video [deleteVideo]
* @apiDescription Delete a specific video from database
* @apiName deleteVideo
* @apiGroup video
*
* @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)
}
}

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

@ -0,0 +1,63 @@
package types
type VideoUnloadedType struct {
MovieId int
MovieName string
Ratio float32
}
type FullVideoType struct {
MovieName string
MovieId uint32
MovieUrl string
Poster string
ReleaseDate *string
Likes uint64
Quality uint16
Length uint16
Tags []Tag
SuggestedTag []Tag
Actors []Actor
}
type Tag struct {
TagName string
TagId uint32
}
type Actor struct {
ActorId uint32
Name string
Thumbnail string
}
type StartData struct {
VideoNr uint32
FullHdNr uint32
HDNr uint32
SDNr uint32
DifferentTags uint32
Tagged uint32
}
type SettingsType struct {
VideoPath string
EpisodePath string
MediacenterName string
Password string
PasswordEnabled bool
TMDBGrabbing bool
DarkMode bool
}
type SettingsSizeType struct {
VideoNr uint32
DBSize float32
DifferentTags uint32
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()
}

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

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

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
}

14
apiGo/go.mod Normal file
View File

@ -0,0 +1,14 @@
module openmediacenter/apiGo
go 1.16
require (
github.com/3d0c/gmf v0.0.0-20210925211039-e278e6e53b16
github.com/dgrijalva/jwt-go v3.2.0+incompatible
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
)

104
apiGo/go.sum Normal file
View File

@ -0,0 +1,104 @@
github.com/3d0c/gmf v0.0.0-20210925211039-e278e6e53b16 h1:LX3XWmS88yKgWJcMXb8vusphpDBe9+6LTI9FyHeFFWQ=
github.com/3d0c/gmf v0.0.0-20210925211039-e278e6e53b16/go.mod h1:0QMRcUq2JsDECeAq7bj4h79k7XbhtTsrPUQf6G7qfPs=
github.com/ClickHouse/clickhouse-go v1.4.5/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
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.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/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.1.0 h1:V2Ulfm2XL9GtYNmrPUNFHieimf6diwADyMObnuuR2Mc=
github.com/pressly/goose/v3 v3.1.0/go.mod h1:tYsY0oL0yd48jg15POIZfOZiu66mqWpfDd/nJ28KWyU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942 h1:t0lM6y/M5IiUZyvbBTcngso8SZEZICH7is9B6g/obVU=
github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/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
}
}
}
}

63
apiGo/main.go Normal file
View File

@ -0,0 +1,63 @@
package main
import (
"flag"
"fmt"
"openmediacenter/apiGo/api"
api2 "openmediacenter/apiGo/api/api"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database"
"openmediacenter/apiGo/housekeeping"
"openmediacenter/apiGo/static"
"openmediacenter/apiGo/videoparser"
"os"
"os/signal"
)
func main() {
fmt.Println("init OpenMediaCenter server")
const port uint16 = 8081
errc := make(chan error, 1)
housekPTr := flag.Bool("HouseKeeping", false, "Run housekeeping tasks")
config.Init()
// 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)
err := database.InitDB()
if err != nil {
errc <- err
}
defer database.Close()
// check if we should run the housekeeping tasks
if *housekPTr {
housekeeping.RunHouseKeepingTasks()
return
}
api.AddHandlers()
videoparser.SetupSettingsWebsocket()
videoparser.InitFileWatcher()
// add the static files
static.ServeStaticFiles()
// init api
go func() {
errc <- api2.ServerInit(port)
}()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt)
select {
case err := <-errc:
fmt.Printf("failed to serve: %v\n", err)
case sig := <-sigs:
fmt.Printf("terminating server: %v\n", sig)
}
}

View File

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

View File

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

View File

@ -0,0 +1 @@
package videoparser

View File

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

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

@ -0,0 +1,139 @@
package videoparser
import (
"fmt"
"io/ioutil"
"openmediacenter/apiGo/config"
"openmediacenter/apiGo/database"
"os"
"strings"
)
type StatusMessage struct {
Messages []string
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 {
fmt.Println("starting reindex..")
SendEvent("start")
AppendMessage("starting reindex..")
mSettings, _, _ := database.GetSettings()
// add the path prefix to videopath
vidFolder := config.GetConfig().General.ReindexPrefix + mSettings.VideoPath
// check if path even exists
if _, err := os.Stat(vidFolder); os.IsNotExist(err) {
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
}
var files []string
for _, file := range filelist {
if !file.IsDir() && ValidVideoSuffix(file.Name()) {
files = append(files, file.Name())
}
}
// start reindex process
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)
}
}
if err != nil {
fmt.Println(err.Error())
}
// start reindex process
AppendMessage("Starting Reindexing!")
go startTVShowReindex(files)
}
func StartCleanup() {
// todo start cleanup
}

View File

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

View File

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

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

View File

@ -11,8 +11,8 @@ create table if not exists settings
(
video_path varchar(255) null,
episode_path varchar(255) null,
password varchar(32) null,
mediacenter_name varchar(32) null,
password varchar(32) default '-1' null,
mediacenter_name varchar(32) default 'OpenMediaCenter' null,
TMDB_grabbing tinyint null,
DarkMode tinyint default 0 null
);
@ -24,6 +24,29 @@ create table if not exists tags
tag_name varchar(50) null
);
create table if not exists tvshow
(
name varchar(100) null,
thumbnail mediumblob null,
id int auto_increment
primary key,
foldername varchar(100) null
);
create table if not exists tvshow_episodes
(
id int auto_increment
primary key,
name varchar(100) null,
season int null,
poster mediumblob null,
tvshow_id int null,
episode int null,
filename varchar(100) null,
constraint tvshow_episodes_tvshow_id_fk
foreign key (tvshow_id) references tvshow (id)
);
create table if not exists videos
(
movie_id int auto_increment
@ -31,11 +54,11 @@ create table if not exists videos
movie_name varchar(200) null,
movie_url varchar(250) null,
thumbnail mediumblob null,
poster mediumblob null,
likes int default 0 null,
create_date datetime default CURRENT_TIMESTAMP null,
quality int null,
length int null comment 'in seconds',
poster mediumblob null
quality int default 0 null,
length int default 0 null comment 'in seconds',
create_date datetime default current_timestamp() null
);
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)
);
create index actors_videos_actor_id_index
create index if not exists actors_videos_actor_id_index
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);
create table if not exists video_tags

View File

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

View File

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

View File

@ -13,9 +13,15 @@ server {
try_files $uri /index.html;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
location ~* ^/(api) {
client_max_body_size 10G;
proxy_pass http://127.0.0.1:8081;
}
location /subscribe {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}

View File

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

19522
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/logo_circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,6 @@
import {shallow} from 'enzyme';
import React from 'react';
import {Button} from './Button';
function prepareFetchApi(response) {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
import {Button, IconButton} from './Button';
describe('<Button/>', function () {
it('renders without crashing ', function () {
@ -30,3 +22,24 @@ describe('<Button/>', function () {
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 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 {
title: string | JSX.Element;
@ -8,9 +11,30 @@ interface ButtonProps {
}
export function Button(props: ButtonProps): JSX.Element {
const theme = GlobalInfos.getThemeStyle();
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}
</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';
interface props {
onClick?: () => void
backColor: string
icon: IconDefinition
text: string | number
subtext: string | number
onClick?: () => void;
backColor: string;
icon: IconDefinition;
text: string | number;
subtext: string | number;
}
/**
@ -18,23 +18,35 @@ interface props {
class InfoHeaderItem extends React.Component<props> {
render(): JSX.Element {
return (
<div onClick={(): void => {
<div
onClick={(): void => {
// call clicklistener if defined
if (this.props.onClick != null) this.props.onClick();
}} 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}>
<FontAwesomeIcon style={{
<FontAwesomeIcon
style={{
verticalAlign: 'middle',
lineHeight: '130px'
}} icon={this.props.icon} size='5x'/>
}}
icon={this.props.icon}
size='5x'
/>
</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.subtext}>{this.props.subtext}</div>
</>
: <span className={style.loadAnimation}><Spinner animation='border'/></span>
}
) : (
<span className={style.loadAnimation}>
<Spinner animation='border' />
</span>
)}
</div>
);
}

View File

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

View File

@ -31,7 +31,7 @@ describe('<AddActorPopup/>', function () {
});
it('test api call and insertion of actor tiles', function () {
global.callAPIMock([{id: 1, name: 'test'}, {id: 2, name: 'test2'}]);
global.callAPIMock([{Id: 1, Name: 'test'}, {Id: 2, Name: 'test2'}]);
const wrapper = shallow(<AddActorPopup/>);
@ -40,11 +40,11 @@ describe('<AddActorPopup/>', function () {
it('simulate actortile click', function () {
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'});
wrapper.setState({actors: [{actor_id: 1, name: 'test'}]}, () => {
wrapper.setState({actors: [{ActorId: 1, Name: 'test'}]}, () => {
wrapper.find('ActorTile').dive().simulate('click');
expect(callAPI).toHaveBeenCalledTimes(1);
@ -59,7 +59,7 @@ describe('<AddActorPopup/>', function () {
global.callAPIMock({result: 'nosuccess'});
wrapper.setState({actors: [{actor_id: 1, name: 'test'}]}, () => {
wrapper.setState({actors: [{ActorId: 1, Name: 'test'}]}, () => {
wrapper.find('ActorTile').dive().simulate('click');
expect(callAPI).toHaveBeenCalledTimes(1);
@ -80,7 +80,7 @@ describe('<AddActorPopup/>', function () {
callAPIMock({});
wrapper.setState({actors: [{name: 'test', actor_id: 1}]});
wrapper.setState({actors: [{Name: 'test', ActorId: 1}]});
wrapper.find('PopupBase').props().ParentSubmit();

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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();}}/>);
// manually set typed in actorname
wrapper.instance().value = 'testactorname';
wrapper.instance().nameValue = 'testactorname';
global.fetch = prepareFetchApi({});
@ -55,6 +55,6 @@ describe('<NewActorPopupContent/>', () => {
wrapper.find('input').simulate('change', {target: {value: 'testinput'}});
expect(wrapper.instance().value).toBe('testinput');
expect(wrapper.instance().nameValue).toBe('testinput');
});
});

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