Compare commits

...

221 Commits

Author SHA1 Message Date
efa77bf534 Update README.md 2025-01-11 21:33:22 +00:00
bdb4e617e6 Update system/install-player-rpi.sh 2025-01-11 20:33:58 +00:00
e159e20b7c Update version.txt
All checks were successful
Release build and push docker image / build-and-push-release (push) Successful in 13m2s
2024-10-14 19:29:23 +00:00
1bf1c56fd8 Update .github/workflows/build-release.yml 2024-10-14 16:39:15 +00:00
ffc2b28a09 Updated README.md link 2024-10-14 16:31:26 +00:00
12aab494ad Updated image location. 2024-10-14 16:28:54 +00:00
2b2a1ba8bd Updated git locations 2024-10-14 16:27:20 +00:00
4bd104a742 Updated image locations. 2024-10-14 16:25:54 +00:00
c70d9f35c1 Updated git location 2024-10-14 16:23:26 +00:00
95bd859236 Update .github/workflows/build-nightly.yml 2024-10-14 15:58:26 +00:00
cb89522936 Update .github/workflows/build-pr.yml 2024-10-14 15:58:00 +00:00
6146308106 Update .github/actions/common-docker-build/action.yml 2024-10-14 15:56:59 +00:00
6fd4c48cc7 Update version.txt
All checks were successful
Release build and push docker image / build-and-push-release (push) Successful in 13m10s
2024-10-14 15:38:54 +00:00
589aa591ee Update .github/workflows/build-release.yml 2024-10-14 15:38:32 +00:00
990925e85e Update version.txt
Some checks failed
Release build and push docker image / build-and-push-release (push) Failing after 1m44s
2024-10-14 15:34:50 +00:00
ab8221889a Update docker-compose.yml 2024-10-14 15:04:19 +00:00
9e5d65651c Update system/install-player-rpi.sh 2024-10-14 15:03:18 +00:00
edbc159fad Updated git location 2024-10-14 15:02:41 +00:00
JRK
e278b9df6a
Merge pull request #148 from jr-k/develop
Release v2.4.5
2024-08-27 03:09:11 +02:00
jr-k
daef56f970 bump 2.5.0 2024-08-27 03:08:54 +02:00
JRK
e3ca756aa7
Merge pull request #145 from jr-k/feature/composition-content-type
Composition content type
2024-08-27 03:08:44 +02:00
jr-k
6208eb0840 custom aspect ratio for composition 2024-08-27 03:07:47 +02:00
jr-k
0830af4f56 custom aspect ratio for composition 2024-08-27 03:06:55 +02:00
jr-k
236b9324f5 aspect ratio picker 2024-08-27 02:40:39 +02:00
jr-k
603354cc4b some fixes 2024-08-27 02:31:00 +02:00
jr-k
7effdfeccd fully working text 2024-08-27 02:02:00 +02:00
jr-k
bfcc60323e fully working text 2024-08-27 01:59:41 +02:00
jr-k
7b1d361d69 text wip 2024-08-27 01:31:16 +02:00
jr-k
a121a9b7c3 fix grid composition 2024-08-27 01:28:03 +02:00
jr-k
6306a88e55 merge conflicts 2024-08-27 01:19:47 +02:00
jr-k
9d8b915905 add windows & macos doc 2024-08-27 01:19:22 +02:00
jr-k
0b51c9825d add pymediaonfo to deps 2024-08-27 01:10:51 +02:00
jr-k
b829fc026a fix video check 2024-08-27 01:08:27 +02:00
jr-k
0dcebf88af src/util/UtilVideo.py 2024-08-27 01:07:32 +02:00
jr-k
1ada29c5e9 setup script 2024-08-27 00:46:13 +02:00
jr-k
9d85aea268 Merge branch 'develop't 2024-08-27 00:41:25 +02:00
jr-k
98dc34358b add setup py 2024-08-27 00:41:16 +02:00
jr-k
753053e586 text wip 2024-08-26 15:53:53 +02:00
jr-k
c9a35ac933 wip 2024-08-26 15:34:21 +02:00
jr-k
6290b79886 wip 2024-08-26 15:33:44 +02:00
jr-k
1f7bd30ddf wip 2024-08-26 15:30:00 +02:00
jr-k
ccf29b1117 wip 2024-08-26 14:56:33 +02:00
jr-k
cd6f360628 wip 2024-08-26 14:53:58 +02:00
jr-k
a57ce2d840 wip 2024-08-26 14:25:44 +02:00
jr-k
aa36a17ef9 wip 2024-08-26 14:19:57 +02:00
jr-k
83be75967c text content type wip 2024-08-26 14:16:08 +02:00
JRK
e5f88d4002
Update README.md 2024-08-26 12:07:47 +02:00
JRK
ee3720c59e
Update README.md 2024-08-26 12:02:59 +02:00
jr-k
b1d11308d7 prepare for text type 2024-08-25 19:25:13 +02:00
jr-k
0c5c3e7dd5 tv news preset composition 2024-08-25 02:32:58 +02:00
jr-k
3851c4ef3e wip 2024-08-25 02:16:35 +02:00
jr-k
d27adbd77a wip 2024-08-25 02:11:27 +02:00
jr-k
1ef39dc0fb basic text content type 2024-08-25 01:49:53 +02:00
jr-k
f1a2e290ae wording 2024-08-25 01:36:41 +02:00
jr-k
3b1e78ae02 prepare for dynamic ratio 2024-08-25 00:35:48 +02:00
jr-k
21162495cb rename 2024-08-24 23:59:10 +02:00
jr-k
bc85e0c33f remove 2024-08-24 23:55:23 +02:00
jr-k
f6ade2ed53 better run script to force resolution 2024-08-24 22:43:23 +02:00
jr-k
3f3f3e4887 remove constraints x/y 2024-08-24 21:52:13 +02:00
jr-k
16d856c7df better preview mode 2024-08-24 21:48:18 +02:00
jr-k
d862268a32 fix shortcut 2024-08-24 21:20:22 +02:00
jr-k
db03ef0018 fix unwanted sql requests 2024-08-24 21:15:16 +02:00
jr-k
c00d967159 minor 2024-08-24 20:58:47 +02:00
jr-k
d38c72e4d6 lightweight dimensions detectors for pictures 2024-08-24 20:53:45 +02:00
jr-k
2680ee600b keyboard shortcuts 2024-08-24 20:48:46 +02:00
jr-k
6f3ed2cd80 add translation & content metadata 2024-08-24 20:42:04 +02:00
jr-k
e36752c4f9 remove element containment 2024-08-24 12:29:30 +02:00
jr-k
db321bf76e preview composition 2024-08-24 00:26:44 +02:00
jr-k
89e676424a light mode adjusts 2024-08-24 00:17:18 +02:00
jr-k
1658fe3bb9 light mode adjusts 2024-08-24 00:16:51 +02:00
jr-k
7ed5d9005c Merge branch 'develop' into feature/composition-content-type 2024-08-24 00:09:11 +02:00
jr-k
0c27de5fc1 Merge branch 'develop' 2024-08-24 00:08:59 +02:00
jr-k
20dd5e0ae2 doc 2024-08-24 00:08:55 +02:00
jr-k
442f02ff37 composition is ok 2024-08-23 22:56:44 +02:00
jr-k
56c421ed63 composition ok frontend 2024-08-23 20:42:24 +02:00
jr-k
18a199d609 at least one fallback, even if edit 2024-08-23 19:05:54 +02:00
jr-k
1fd0d99342 Merge branch 'develop' into feature/composition-content-type 2024-08-23 18:45:27 +02:00
JRK
2d18dbf23b
Merge pull request #144 from jr-k/develop
Release v2.4.4
2024-08-23 17:53:23 +02:00
jr-k
cf9dffb05c bump 2.4.4 2024-08-23 17:53:11 +02:00
jr-k
3a054fb055 fix youtube 2024-08-23 17:53:00 +02:00
jr-k
7c3bbc5323 Merge branch 'develop' 2024-08-20 19:56:05 +02:00
jr-k
9d88e2dbf4 omg 2024-08-20 19:56:01 +02:00
jr-k
aebb509965 Merge branch 'develop' 2024-08-20 19:36:22 +02:00
jr-k
616bab254a chromecast for no https and on non loopback server 2024-08-20 19:36:17 +02:00
jr-k
47ec669a38 Merge branch 'develop't p 2024-08-20 18:21:48 +02:00
jr-k
812a9ab0f4 better chromium install 2024-08-20 18:21:42 +02:00
JRK
e3235dc802
Merge pull request #142 from jr-k/develop
Release v2.4.3
2024-08-19 15:59:43 +02:00
jr-k
d25563f94e auto refresh if no playlist 2024-08-19 15:59:36 +02:00
jr-k
4eb3f0c8b1 bump 2.4.3
Some checks failed
PR build and push docker image / build-and-push-pr (push) Has been cancelled
2024-08-19 15:46:50 +02:00
JRK
1af0f098d1
Merge pull request #141 from jr-k/core/armbian-install
Armbian installation
2024-08-19 15:46:41 +02:00
jr-k
320b9242d5 fix installation scripts 2024-08-19 15:46:24 +02:00
JRK
258abb3bf5
Merge pull request #140 from jr-k/develop
Release v2.4.2
2024-08-19 11:13:51 +02:00
jr-k
84fdc64070 bump 2.4.2
Some checks are pending
PR build and push docker image / build-and-push-pr (push) Waiting to run
2024-08-19 11:13:38 +02:00
JRK
9266906d15
Merge pull request #139 from jr-k/bugfix/replace-all-old-browsers
[Fix] Uncaught TypeError: slide.innerHTML.replaceAll is not a function
2024-08-19 11:13:25 +02:00
jr-k
d760a537c9 use replace 2024-08-19 11:12:19 +02:00
JRK
dc51487d50
Merge pull request #137 from jr-k/master
Merge
2024-08-14 13:57:29 +02:00
JRK
63de70611d
Update obscreen-studio.service 2024-08-14 13:56:38 +02:00
jr-k
46fad2bef2 Merge branch 'developt ' 2024-08-13 15:45:57 +02:00
jr-k
e07dbf81d2 qfix 2024-08-13 15:45:53 +02:00
jr-k
22fb006d7a Merge branch 'devet plop' 2024-08-13 13:15:35 +02:00
jr-k
05f335b105 fix playlist slug 2024-08-13 13:15:27 +02:00
JRK
e801485ec1
Merge pull request #136 from jr-k/develop
Release v2.4.1
2024-08-13 12:40:10 +02:00
jr-k
28c28191a2 custom 404 ui + playlist enablement fix 2024-08-13 12:40:00 +02:00
jr-k
c280160e8b minor fix
Some checks are pending
PR build and push docker image / build-and-push-pr (push) Waiting to run
2024-08-13 12:16:11 +02:00
jr-k
03fbb34d95 wip 2024-08-13 12:15:58 +02:00
jr-k
4712047015 wip 2024-08-12 14:19:55 +02:00
jr-k
010d57bf4c float duration and minor fixes 2024-08-11 22:32:25 +02:00
jr-k
c81706061b float duration and minor fixes 2024-08-11 22:27:56 +02:00
jr-k
746b4234d2 fix external storage delegate duration for first slide 2024-08-10 16:04:31 +02:00
jr-k
97b7943fb1 permfix 2024-08-10 16:01:35 +02:00
jr-k
54335422cf flags multiline 2024-08-10 15:47:40 +02:00
jr-k
006ad75070 add owner to player 2024-08-10 15:44:25 +02:00
jr-k
1d9b723053 fix install script 2024-08-10 15:23:41 +02:00
jr-k
b14359b421 doc 2024-08-10 15:15:28 +02:00
jr-k
4b228cccf0 wip 2024-08-10 15:14:58 +02:00
jr-k
0bb72c03fc resolve conflicts 2024-08-09 02:21:57 +02:00
JRK
f9ebb9a053
Update README.md 2024-08-09 02:05:01 +02:00
JRK
95aa547196
Update README.md 2024-08-09 02:03:58 +02:00
jr-k
90acace1a7 Merge branch 'master' of https://github.com/jr-k/obscreen 2024-08-08 17:13:48 +02:00
jr-k
23079fedd9 fix 2024-08-08 17:13:32 +02:00
JRK
7028260458
Merge pull request #134 from jr-k/develop
Release v2.3.2
2024-08-08 13:54:51 +02:00
jr-k
d6de71b1e8 bump 2.4.0
Some checks are pending
PR build and push docker image / build-and-push-pr (push) Waiting to run
2024-08-08 13:54:34 +02:00
JRK
85b42274e4
Merge pull request #133 from jr-k/feature/better-content-cache-serve
Better content cache options
2024-08-08 13:54:19 +02:00
jr-k
608ee01f55 fix serving file for external_storage player 2024-08-08 12:30:16 +02:00
jr-k
e040cecf68 working flow 2024-08-08 12:12:25 +02:00
jr-k
377baf1edd pr trigger 2024-08-08 12:01:06 +02:00
jr-k
b6d5445b31 pr trigger 2024-08-08 11:59:20 +02:00
jr-k
68519d8407 pr trigger 2024-08-08 11:58:43 +02:00
jr-k
a37bb58ef3 pr trigger 2024-08-08 11:58:41 +02:00
jr-k
38ad5a85a4 pr trigger 2024-08-08 11:54:23 +02:00
jr-k
a735c0f4e8 pr trigger 2024-08-08 04:27:37 +02:00
jr-k
79a31b84c6 pr trigger 2024-08-08 04:26:57 +02:00
jr-k
5e04493329 pr trigger 2024-08-08 04:26:39 +02:00
jr-k
da1ffeb47d pr trigger 2024-08-08 04:25:43 +02:00
jr-k
ed8177e05f pr trigger 2024-08-08 04:24:51 +02:00
jr-k
2362673c6f pr trigger 2024-08-08 04:24:11 +02:00
jr-k
cdc25a2c1e pr trigger 2024-08-08 04:21:05 +02:00
jr-k
f1f444de39 pr trigger 2024-08-08 04:20:01 +02:00
jr-k
ea8f3b1334 pr trigger 2024-08-08 04:18:48 +02:00
jr-k
4453115030 pr trigger 2024-08-08 04:15:50 +02:00
jr-k
62ecdaac3b pr trigger 2024-08-08 04:15:09 +02:00
jr-k
842a722df8 pr trigger 2024-08-08 04:14:00 +02:00
jr-k
d7385c5d06 pr trigger 2024-08-08 03:53:01 +02:00
jr-k
fa934d8c9b pr trigger 2024-08-08 03:52:19 +02:00
jr-k
4810e10343 pr trigger 2024-08-08 03:51:23 +02:00
jr-k
4a9fb8e505 pr trigger 2024-08-08 03:49:45 +02:00
jr-k
1c0cce52b5 pr trigger 2024-08-08 03:48:10 +02:00
jr-k
77557d1699 pr trigger 2024-08-08 03:43:28 +02:00
jr-k
f582612619 pr trigger 2024-08-08 03:41:51 +02:00
jr-k
4bbc5ea972 pr trigger 2024-08-08 03:40:44 +02:00
jr-k
5b5f374255 pr trigger 2024-08-08 03:39:54 +02:00
jr-k
11df35209a pr trigger 2024-08-08 03:35:52 +02:00
jr-k
37105f6aa6 pr trigger 2024-08-08 03:30:47 +02:00
jr-k
e1413972bf pr trigger 2024-08-08 03:28:24 +02:00
jr-k
4dbf6e08fc pr trigger 2024-08-08 03:26:17 +02:00
jr-k
6113327075 pr trigger 2024-08-08 03:23:11 +02:00
jr-k
0ff94bc4f5 add pr build 2024-08-08 03:22:50 +02:00
jr-k
cf1fad0509 add pr build 2024-08-08 03:19:49 +02:00
jr-k
48e021a532 add pr build 2024-08-08 03:07:00 +02:00
jr-k
55ea9e5963 add pr build 2024-08-08 03:06:00 +02:00
jr-k
671e402db4 add nightly build 2024-08-08 02:51:47 +02:00
jr-k
c8064a9eb1 add nightly build 2024-08-08 02:49:44 +02:00
jr-k
69f8872755 add nightly build 2024-08-08 02:49:03 +02:00
jr-k
f6c0965f85 add nightly build 2024-08-08 02:44:03 +02:00
jr-k
bdb4f1d7bd done 2024-08-08 02:07:14 +02:00
jr-k
70efdf877e minor min 2024-08-08 00:23:44 +02:00
jr-k
69d477eeb1 minor min 2024-08-08 00:21:45 +02:00
jr-k
9ec77ede6f minor min 2024-08-08 00:09:53 +02:00
jr-k
e2e38c0db9 fix 2024-08-06 17:27:01 +02:00
jr-k
ea71566121 comments 2024-08-06 17:22:16 +02:00
jr-k
e919f76ea4 Merge branch 'develop' 2024-08-06 17:15:58 +02:00
jr-k
7a5234dbce fix 2024-08-06 17:15:55 +02:00
jr-k
fc9818ae4b Merge branch 'develop' 2024-08-06 13:18:07 +02:00
jr-k
da8cfa9222 cast js 2024-08-06 13:18:02 +02:00
jr-k
7ec157cf62 minor logs 2024-08-06 12:55:51 +02:00
JRK
9e54506dcc
Merge pull request #131 from jr-k/develop
Release v2.3.1
2024-08-06 12:23:15 +02:00
JRK
6b6fc91b6a
Update version.txt 2024-08-06 12:23:06 +02:00
JRK
0a422f748d
Merge pull request #130 from jr-k/feature/cast
Cast to chromecast preview
2024-08-06 12:22:20 +02:00
jr-k
4b09c6c26b handle cast link with python 2024-08-06 11:51:58 +02:00
jr-k
f8336b2220 remove blank line 2024-08-06 00:36:58 +02:00
jr-k
0dc3b6330b remove test 2024-08-06 00:36:42 +02:00
jr-k
6633e3f5b1 add cast feature 2024-08-06 00:34:44 +02:00
jr-k
3f1c5398a0 remove sudo in script 2024-08-05 23:44:26 +02:00
jr-k
f548f37a59 fix installation script 2024-08-05 23:40:40 +02:00
jr-k
3fbd4739ff update dockerfile 2024-08-05 01:10:25 +02:00
jr-k
ae4fddbfaa update dockerfile 2024-08-05 01:03:22 +02:00
jr-k
6333caa0b5 update dockerfile 2024-08-05 00:59:02 +02:00
jr-k
4fc7e1baec update dockerfile 2024-08-04 22:41:45 +02:00
jr-k
c1d95bf569 update dockerfile 2024-08-04 22:41:10 +02:00
jr-k
e9c4215f88 update dockerfile 2024-08-04 22:40:36 +02:00
jr-k
44d99e2be5 update dockerfile 2024-08-04 19:46:25 +02:00
jr-k
6580bd73ed update dockerfile 2024-08-04 19:45:15 +02:00
jr-k
377d99c0f1 update dockerfile 2024-08-04 19:43:14 +02:00
jr-k
a92188ae1f update dockerfile 2024-08-04 19:35:31 +02:00
jr-k
9b86106278 update dockerfile 2024-08-04 19:27:58 +02:00
jr-k
6892a0ded5 update dockerfile 2024-08-04 19:20:58 +02:00
jr-k
e3d954287d Merge branch 'develop' 2024-08-04 19:16:03 +02:00
jr-k
3715776f6d update dockerfile 2024-08-04 19:14:26 +02:00
JRK
62fe7ee1fe
Merge pull request #127 from jr-k/develop
Release v2.2.4
2024-08-04 18:04:17 +02:00
JRK
b1483bbf45
Merge pull request #126 from jr-k/feature/api
Api
2024-08-04 18:04:02 +02:00
jr-k
f717c6a82f bump 2024-08-04 18:02:53 +02:00
jr-k
d88db002f1 fully working api 2024-08-04 18:02:42 +02:00
jr-k
5fba1a4300 conditionnal token if plugin enabled 2024-08-04 17:42:06 +02:00
jr-k
1e5e9f0209 token management ok 2024-08-04 17:38:30 +02:00
jr-k
443650b217 full api with flask-restx/swagger 2024-08-04 17:18:14 +02:00
jr-k
fbb194e44c flask ok 2024-08-04 05:03:46 +02:00
jr-k
c7fc29ba99 wip swagger 2024-08-04 03:55:54 +02:00
jr-k
46a05cde84 working content api 2024-08-04 02:43:57 +02:00
jr-k
48e2f5bdfb wip 2024-08-04 02:13:13 +02:00
JRK
d869589b47
Merge pull request #125 from jr-k/develop
Release v2.2.3
2024-08-01 10:41:29 +02:00
jr-k
ff051beae7 bump 2024-08-01 10:41:19 +02:00
jr-k
aef16fe812 volume localtime for docker 2024-08-01 10:41:12 +02:00
jr-k
8a41d2d278 fix docker + add pause to preview iframe 2024-08-01 10:38:21 +02:00
jr-k
6e7ca0a1dc ignore fix 2024-08-01 10:22:28 +02:00
jr-k
e3c4726cda ignore fix 2024-08-01 10:21:14 +02:00
jr-k
c742f86924 ignore fix 2024-08-01 10:20:32 +02:00
jr-k
5b19668b38 add storage dir 2024-08-01 10:19:05 +02:00
JRK
80d6b6ef92
Update README.md 2024-08-01 10:13:12 +02:00
JRK
8cc56243e6
Merge pull request #124 from jr-k/develop
Release v2.2.2
2024-08-01 10:09:49 +02:00
JRK
de8478ddea
Update FUNDING.yml 2024-08-01 09:19:18 +02:00
jr-k
bd85d39af5 wip 2024-07-26 17:34:06 +02:00
155 changed files with 5382 additions and 820 deletions

View File

@ -1,18 +1,27 @@
.DS_Store
.idea
*.iws
*.iml
*.ipr
out/
data/uploads/*
!data/uploads/.gitkeep
data/db/*
/data/uploads/*
!/data/uploads/.gitkeep
/data/db/*
!/data/db/.gitkeep
/plugins/user/*
!/plugins/user/.gitkeep
*.lock
__pycache__/
*.log
var/run/*
!var/run/.gitkeep
/var/run/*
!/var/run/.gitkeep
*.swp
.env
venv/
node_modules
tmp.py
!/plugins/user/Dashboard
/data/www/plugins/*
!/data/www/plugins/.gitkeep
/var/run/storage/*
!/var/run/storage/.gitkeep

View File

@ -5,12 +5,7 @@ SECRET_KEY=ANY_SECRET_KEY_HERE
# Application Server
PORT=5000
BIND=0.0.0.0
# HTTP External Storage Server
PORT_HTTP_EXTERNAL_STORAGE=5001
BIND_HTTP_EXTERNAL_STORAGE=0.0.0.0
CHROOT_HTTP_EXTERNAL_STORAGE=%application_dir%/var/run/storage
EXTERNAL_STORAGE_MOUNTPOINT=%application_dir%/var/run/storage
# Misc
DEMO=false

1
.github/FUNDING.yml vendored
View File

@ -1,2 +1 @@
github: jr-k
custom: https://paypal.me/jierka

View File

@ -0,0 +1,54 @@
name: 'Common Docker Build'
description: 'Reusable action for building and pushing Docker images'
inputs:
build_tags:
description: 'Tags for the Docker build'
required: true
manifest_tags:
description: 'Tags for the Docker manifest'
required: true
flavor:
description: 'Flavor for the Docker manifest'
required: true
docker_username:
description: 'DockerHub username'
required: true
docker_password:
description: 'DockerHub password'
required: true
runs:
using: 'composite'
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ inputs.docker_username }}
password: ${{ inputs.docker_password }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ inputs.build_tags }}
platforms: linux/amd64, linux/arm64/v8, linux/arm/v7
- name: Create and push manifest
uses: docker/metadata-action@v3
with:
images: csmith1865/obscreen
tags: ${{ inputs.manifest_tags }}
flavor: ${{ inputs.flavor }}

41
.github/workflows/build-nightly.yml vendored Executable file
View File

@ -0,0 +1,41 @@
name: Nightly build synced with develop and push docker image
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
build-and-push-nightly:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
ref: develop
- name: Set up Git
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
- name: Sync nightly branch
run: |
git checkout nightly
git merge develop --no-edit
git push origin nightly --force
- name: Checkout nightly branch
uses: actions/checkout@v2
with:
ref: nightly
- name: Call common build workflow
uses: ./.github/actions/common-docker-build
with:
build_tags: csmith1865/obscreen:nightly
manifest_tags: type=semver,pattern=nightly
flavor: ""
docker_username: ${{ secrets.DOCKER_USERNAME }}
docker_password: ${{ secrets.DOCKER_PASSWORD }}

28
.github/workflows/build-pr.yml vendored Executable file
View File

@ -0,0 +1,28 @@
name: PR build and push docker image
on:
pull_request:
types: [opened, synchronize, labeled]
push:
branches-ignore:
- master
- develop
- nightly
workflow_dispatch:
jobs:
build-and-push-pr:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'build-pr')
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Call common build workflow
uses: ./.github/actions/common-docker-build
with:
build_tags: csmith1865/obscreen:pr-${{ github.event.pull_request.number }}
manifest_tags: type=semver,pattern=pr
flavor: ""
docker_username: ${{ secrets.DOCKER_USERNAME }}
docker_password: ${{ secrets.DOCKER_PASSWORD }}

31
.github/workflows/build-release.yml vendored Executable file
View File

@ -0,0 +1,31 @@
name: Release build and push docker image
on:
push:
branches:
- '*'
paths:
- 'version.txt'
workflow_dispatch:
jobs:
build-and-push-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Read Version
id: version
run: echo "::set-output name=VERSION::$(cat version.txt)"
- name: Call common build workflow
uses: ./.github/actions/common-docker-build
with:
build_tags: |
csmith1865/obscreen:v${{ steps.version.outputs.VERSION }}
csmith1865/obscreen:latest
manifest_tags: type=semver,pattern=v${{ steps.version.outputs.VERSION }}
flavor: latest=true
docker_username: ${{ secrets.DOCKER_USERNAME }}
docker_password: ${{ secrets.DOCKER_PASSWORD }}

View File

@ -1,51 +0,0 @@
name: Build and Push Docker images
on:
push:
branches:
- master
paths:
- 'version.txt'
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Read Version
id: version
run: echo "::set-output name=VERSION::$(cat version.txt)"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: |
jierka/obscreen:v${{ steps.version.outputs.VERSION }}
jierka/obscreen:latest
platforms: linux/amd64, linux/arm64/v8, linux/arm/v7
- name: Create and push manifest
uses: docker/metadata-action@v3
with:
images: jierka/obscreen
tags: type=semver,pattern=v{{version}}
flavor: |
latest=true

19
.gitignore vendored
View File

@ -4,22 +4,27 @@
*.iml
*.ipr
out/
data/uploads/*
!data/uploads/.gitkeep
data/db/*
/data/uploads/*
!/data/uploads/.gitkeep
/data/db/*
!/data/db/.gitkeep
/plugins/user/*
!/plugins/user/.gitkeep
*.lock
__pycache__/
*.log
var/run/*
!var/run/.gitkeep
/var/run/*
!/var/run/.gitkeep
*.swp
.env
venv/
node_modules
tmp.py
!/plugins/user/Dashboard
data/www/plugins/*
!data/www/plugins/.gitkeep
/data/www/plugins/*
!/data/www/plugins/.gitkeep
/var/run/storage/*
!/var/run/storage/.gitkeep
*.egg-info
/build/
/dist/

View File

@ -1,13 +1,24 @@
FROM python:3.9.17-alpine3.17
FROM python:3.9-slim-bullseye
# Install ffmpeg and other dependencies
RUN apk add --no-cache gcc musl-dev sqlite-dev ntfs-3g ffmpeg build-base linux-headers
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libc6-dev \
sqlite3 \
libsqlite3-dev \
ntfs-3g \
ffmpeg \
build-essential \
curl \
tar \
bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
# Install Python dependencies
RUN pip install -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
ENTRYPOINT ["python", "/app/obscreen.py"]

4
MANIFEST.in Normal file
View File

@ -0,0 +1,4 @@
include README.md
include LICENSE
docs/setup-run-on-rpi.md
docs/setup-run-headless.md

View File

@ -6,10 +6,15 @@
Obscreen is a user-friendly self-hosted digital signage tool leveraging chromium browser.
<a target="_blank" href="https://github.com/jr-k/obscreen"><img src="https://img.shields.io/github/stars/jr-k/obscreen?style=flat" /></a> <a target="_blank" href="https://hub.docker.com/r/jierka/obscreen"><img src="https://img.shields.io/docker/pulls/jierka/obscreen" /></a> <a target="_blank" href="https://hub.docker.com/r/jierka/obscreen"><img src="https://img.shields.io/docker/v/jierka/obscreen/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://github.com/jr-k/obscreen"><img src="https://img.shields.io/github/last-commit/jr-k/obscreen" /></a>
<a target="_blank" href="https://git.sumisu.xyz/csmith1865/obscreen"><img src="https://img.shields.io/gitea/stars/csmith1865/obscreen?gitea_url=https%3A%2F%2Fgit.sumisu.xyz&style=flat" /></a> <a target="_blank" href="https://hub.docker.com/r/csmith1865/obscreen"><img src="https://img.shields.io/docker/pulls/csmith1865/obscreen" /></a> <a target="_blank" href="https://hub.docker.com/r/csmith1865/obscreen"><img src="https://img.shields.io/docker/v/csmith1865/obscreen/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://git.sumisu.xyz/csmith1865/obscreen"><img src="https://img.shields.io/gitea/last-commit/csmith1865/obscreen?gitea_url=https%3A%2F%2Fgit.sumisu.xyz&style=flat" /></a>
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-playlist-edit.png" width="700" alt="" />
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-playlist-edit.png" width="700" alt="" />
🧑‍🎄 Open to feature request and pull request. [Cast your vote for your preferred ones on the Canny platform](https://obscreen.canny.io/feature-requests)
⭐️ You liked it ? Give this repository a star, it's free :)
---
## 🕹️ Live Demo
@ -19,7 +24,7 @@ Demo Server (Location: Roubaix - France): [https://demo.obscreen.io](https://dem
It is a temporary live demo, all data will be deleted after 30 minutes (~30secs downtime).
## ⭐️ Features
## 🎉 Features
- Dead simple chromium webview inside
- Fancy graphical user interface
- Very few dependencies
@ -28,8 +33,8 @@ It is a temporary live demo, all data will be deleted after 30 minutes (~30secs
- Playlist management
- Authentication management
- Plays content from flashdrive in offline mode
- Plugin system to extend capabilities
- [Multi Languages](https://github.com/jr-k/obscreen/tree/master/lang)
- Core API & Plugin system to extend capabilities
- [Multi Languages](https://git.sumisu.xyz/csmith1865/obscreen/src/branch/master/lang)
- Cast pictures and iframes to Chromecast
- No costly monthly pricing plan per screen or whatever, no cloud, no telemetry
@ -42,19 +47,19 @@ It is a temporary live demo, all data will be deleted after 30 minutes (~30secs
Light Mode:
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-light-mode.png" width="512" alt="" />
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-light-mode.png" width="512" alt="" />
Content Explorer:
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-content-explorer.png" width="512" alt="" />
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-content-explorer.png" width="512" alt="" />
Settings Page:
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-settings.png" width="512" alt="" />
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-settings.png" width="512" alt="" />
Add Content Modal:
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-add-content.png" width="512" alt="" />
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-add-content.png" width="512" alt="" />
## 🫡 Motivation
@ -66,23 +71,30 @@ Add Content Modal:
If you value this project, please think about awarding it a ⭐. Thanks ! 🙏
## 🗺️ Short-term roadmap
- New `Composition` content type: Check out a [video demo here](https://demo.obscreen.io/data/uploads/compositions.mp4)
- New `Text` Content Type: Display text with customizable styles, including options for scrolling effects.
- New `HTML` Content Type: Display HTML snippets for more powerful text customization, giving you full control over the content.
- Fleet Studio Management: Reviving a legacy feature
- Remote Player Server: A new way to manage a player from the studio without needing SSH access to player
## 🛟 Discussion / Need help ?
### Join our Discord
[<img src="https://github.com/jr-k/obscreen/blob/master/docs/img/discord.png" width="64">](https://discord.obscreen.io)
[<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/img/discord.png" width="64">](https://discord.obscreen.io)
### Open an Issue
[<img src="https://github.com/jr-k/obscreen/blob/master/docs/img/github.png" width="64">](https://github.com/jr-k/obscreen/issues/new/choose)
[<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/img/github.png" width="64">](https://git.sumisu.xyz/csmith1865/obscreen/issues/new/choose)
### Troubleshoot
<details closed>
<summary><h3>Videos aren't playing why ?</h3></summary>
<summary><h3>Why aren't the videos starting?</h3></summary>
This is "normal" behavior. Videos do not play automatically in Chrome because it requires user interaction with the page (a simple click inside the webpage is enough). If you open the console, you'll see the error: [Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first...](https://goo.gl/xX8pDD)
To resolve this, you need to use the Chrome flag --autoplay-policy=no-user-gesture-required. When connecting a Raspberry Pi with Obscreen Player autorun, this issue doesn't occur because the flag is handled automatically for you.You need to enable this flag yourself otherwise.
To resolve this, you need to use the Chrome flag `--autoplay-policy=no-user-gesture-required`. When connecting a Raspberry Pi with Obscreen Player autorun, this issue doesn't occur because the flag is handled automatically for you. You need to enable this flag yourself otherwise.
---
@ -100,7 +112,7 @@ Check out the latest beta release here: https://github.com/jr-k/obscreen/release
### Translations
If you want to translate Obscreen into your language, please visit [Languages Files](https://github.com/jr-k/obscreen/blob/master/lang).
If you want to translate Obscreen into your language, please visit [Languages Files](https://git.sumisu.xyz/csmith1865/obscreen/src/branch/master/lang).
### Spelling & Grammar

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,8 @@
jQuery(document).ready(function ($) {
const main = function () {
$('.user-token-reveal').each(function() {
updateTokenReveal($(this), false);
});
};
$(document).on('click', '.user-add', function () {
@ -17,5 +19,26 @@ jQuery(document).ready(function ($) {
$('#user-edit-id').val(user.id);
});
const updateTokenReveal = function($btn, revealState) {
const $holder = $btn.parents('.user-item:eq(0)');
const $input = $holder.find('.input-token:eq(0)');
const $icon = $btn.find('i:eq(0)');
const isActive = revealState !== undefined ? !revealState : $icon.hasClass('fa-eye-slash');
if (isActive) {
$icon.removeClass('fa-eye-slash').addClass('fa-eye');
$btn.removeClass('btn-neutral').addClass('btn-other');
$input.val($input.attr('data-private'));
} else {
$icon.removeClass('fa-eye').addClass('fa-eye-slash');
$btn.removeClass('btn-other').addClass('btn-neutral');
$input.val($input.attr('data-public'));
}
};
$(document).on('click', '.user-token-reveal', function () {
updateTokenReveal($(this));
});
main();
});

74
data/www/js/cast-url.js Normal file
View File

@ -0,0 +1,74 @@
var applicationID = '81585E3E';
var namespace = 'urn:x-cast:com.jrk.obscreen';
var session = null;
if (!chrome.cast || !chrome.cast.isAvailable) {
setTimeout(initializeCastApi, 1000);
}
function initializeCastApi() {
var sessionRequest = new chrome.cast.SessionRequest(applicationID);
var apiConfig = new chrome.cast.ApiConfig(sessionRequest,
sessionListener,
receiverListener);
chrome.cast.initialize(apiConfig, onInitSuccess, onError);
}
function onInitSuccess() {
// console.log('onInitSuccess');
}
function onError(message) {
console.error('onError: ' + JSON.stringify(message));
}
function onSuccess(message) {
// console.log('onSuccess: ' + JSON.stringify(message));
}
// function onStopAppSuccess() {
// console.log('onStopAppSuccess');
// }
function sessionListener(e) {
console.log('New session ID: ' + e.sessionId);
session = e;
session.addUpdateListener(sessionUpdateListener);
}
function sessionUpdateListener(isAlive) {
console.log((isAlive ? 'Session Updated' : 'Session Removed') + ': ' + session.sessionId);
if (!isAlive) {
session = null;
}
}
function receiverListener(e) {
// Due to API changes just ignore this.
}
function sendMessage(message) {
if (session != null) {
session.sendMessage(namespace, message, onSuccess.bind(this, message), onError);
} else {
chrome.cast.requestSession(function (e) {
session = e;
sessionListener(e);
session.sendMessage(namespace, message, onSuccess.bind(this, message), onError);
}, onError);
}
}
// function stopApp() {
// session.stop(onStopAppSuccess, onError);
// }
jQuery(function ($) {
$(document).on('click', '.cast-url', function () {
sendMessage({
type: 'load',
url: $('#' + $(this).attr('data-target-id')).val()
});
});
});

View File

@ -10,13 +10,24 @@ jQuery(document).ready(function ($) {
});
$(document).on('click', '.node-player-group-preview', function () {
const $iframe = $('<iframe>', {
src: $(this).attr('data-url'),
frameborder: 0
});
const $icon = $(this).find('i');
const isPlay = $icon.hasClass('fa-play');
const $holder = $(this).parents('.preview:eq(0)');
$(this).parents('.preview:eq(0)').append($iframe);
$(this).remove();
if (isPlay) {
const $iframe = $('<iframe>', {
src: $(this).attr('data-url'),
frameborder: 0
});
$holder.append($iframe);
$(this).addClass('hover-only');
$icon.removeClass('fa-play').addClass('fa-pause');
} else {
$holder.find('iframe').remove();
$(this).removeClass('hover-only');
$icon.removeClass('fa-pause').addClass('fa-play');
}
});
$(document).on('click', '.node-player-group-player-assign', function () {

View File

@ -30,6 +30,10 @@ const hideDropdowns = function () {
$('.dropdown').removeClass('dropdown-show');
};
const classColorXor = function(color, fallback) {
return color === 'gscaleF' ? 'gscale0' : (color === 'gscale0' ? 'gscaleF' : fallback);
};
const showToast = function (text) {
const $toast = $(".toast");
$toast.addClass('show');
@ -162,5 +166,12 @@ jQuery(document).ready(function ($) {
showToast(l.js_common_copied);
});
$(window).on('beforeunload', function(event) {
$('.modal').each(function() {
$(this).find('button[type=submit]').removeClass('hidden');
$(this).find('.btn-loading').addClass('hidden');
});
});
});

View File

@ -0,0 +1,13 @@
(function(){/*
Copyright The Closure Library Authors.
SPDX-License-Identifier: Apache-2.0
*/
'use strict';var l=function(){var a=h,b=0;return function(){return b<a.length?{done:!1,value:a[b++]}:{done:!0}}},m=this||self,n=/^[\w+/_-]+[=]{0,2}$/,p=null,q=function(a){return(a=a.querySelector&&a.querySelector("script[nonce]"))&&(a=a.nonce||a.getAttribute("nonce"))&&n.test(a)?a:""},r=function(a,b){function e(){}e.prototype=b.prototype;a.i=b.prototype;a.prototype=new e;a.prototype.constructor=a;a.h=function(c,g,k){for(var f=Array(arguments.length-2),d=2;d<arguments.length;d++)f[d-2]=arguments[d];
return b.prototype[g].apply(c,f)}},t=function(a){return a};function u(a){if(Error.captureStackTrace)Error.captureStackTrace(this,u);else{var b=Error().stack;b&&(this.stack=b)}a&&(this.message=String(a))}r(u,Error);u.prototype.name="CustomError";var v=function(a,b){a=a.split("%s");for(var e="",c=a.length-1,g=0;g<c;g++)e+=a[g]+(g<b.length?b[g]:"%s");u.call(this,e+a[c])};r(v,u);v.prototype.name="AssertionError";var w=function(a,b){throw new v("Failure"+(a?": "+a:""),Array.prototype.slice.call(arguments,1));};var x;var A=function(a,b){this.g=b===z?a:""};A.prototype.toString=function(){return this.g+""};var z={};var B=function(){var a=window.navigator.userAgent.match(/Chrome\/([0-9]+)/);return a?parseInt(a[1],10):0},C=function(a){return!!document.currentScript&&(-1!=document.currentScript.src.indexOf("?"+a)||-1!=document.currentScript.src.indexOf("&"+a))},D=function(){return"function"==typeof window.__onGCastApiAvailable?window.__onGCastApiAvailable:null},F=function(a){a.length?E(a.shift(),function(){F(a)}):G()},H=function(a){return"chrome-extension://"+a+"/cast_sender.js"},E=function(a,b,e){var c=document.createElement("script");
c.onerror=b;e&&(c.onload=e);if(void 0===x)if(b=null,(e=m.trustedTypes)&&e.createPolicy){try{b=e.createPolicy("goog#html",{createHTML:t,createScript:t,createScriptURL:t})}catch(y){m.console&&m.console.error(y.message)}x=b}else x=b;a=(b=x)?b.createScriptURL(a):a;a=new A(a,z);a:{try{var g=c&&c.ownerDocument,k=g&&(g.defaultView||g.parentWindow);k=k||m;if(k.Element&&k.Location){var f=k;break a}}catch(y){}f=null}if(f&&"undefined"!=typeof f.HTMLScriptElement&&(!c||!(c instanceof f.HTMLScriptElement)&&(c instanceof
f.Location||c instanceof f.Element))){f=typeof c;if("object"==f&&null!=c||"function"==f)try{var d=c.constructor.displayName||c.constructor.name||Object.prototype.toString.call(c)}catch(y){d="<object could not be stringified>"}else d=void 0===c?"undefined":null===c?"null":typeof c;w("Argument is not a %s (or a non-Element, non-Location mock); got: %s","HTMLScriptElement",d)}a instanceof A&&a.constructor===A?d=a.g:(d=typeof a,w("expected object of type TrustedResourceUrl, got '"+a+"' of type "+("object"!=
d?d:a?Array.isArray(a)?"array":d:"null")),d="type_error:TrustedResourceUrl");c.src=d;(d=c.ownerDocument&&c.ownerDocument.defaultView)&&d!=m?d=q(d.document):(null===p&&(p=q(m.document)),d=p);d&&c.setAttribute("nonce",d);(document.head||document.documentElement).appendChild(c)},I=function(){var a=B(),b=[];if(1<a){var e=a-1;b.push("https://www.gstatic.com/eureka/clank/"+a+"/cast_sender.js");b.push("https://www.gstatic.com/eureka/clank/"+e+"/cast_sender.js")}return b},G=function(){var a=D();a&&a(!1,"No cast extension found")},
K=function(){if(J){var a=2,b=D(),e=function(){a--;0==a&&b&&b(!0)};window.__onGCastApiAvailable=e;E("https://www.gstatic.com/cast/sdk/libs/sender/1.0/cast_framework.js",G,e)}},J=C("loadCastFramework")||C("loadCastApplicationFramework"),L=["pkedcjkdefgpdelpbcmbmeomcjbeemfm","enhhojjnijigcajfphajepfemndkmdlo"];if(0<=window.navigator.userAgent.indexOf("Android")&&0<=window.navigator.userAgent.indexOf("Chrome/")&&window.navigator.presentation){if(60<=B()){K();var M=I();M.push("https://www.gstatic.com/eureka/clank/cast_sender.js");F(M)}}else if(!window.chrome||!window.navigator.presentation||0<=window.navigator.userAgent.indexOf("Edge"))G();else if(89<=B()){K();var N=I(),O=N.push,P=O.apply,h=L.map(H),Q;if(h instanceof Array)Q=h;else{var R,S="undefined"!=typeof Symbol&&Symbol.iterator&&h[Symbol.iterator];R=S?S.call(h):
{next:l()};for(var T,U=[];!(T=R.next()).done;)U.push(T.value);Q=U}P.call(O,N,Q);N.push("https://www.gstatic.com/eureka/clank/cast_sender.js");F(N)}else K(),F(L.map(H));}).call(this);

84
data/www/js/lib/jquery-more.js vendored Normal file
View File

@ -0,0 +1,84 @@
jQuery(function () {
$(document).ready(function () {
function adjustValue(inputElement, delta) {
const currentValue = parseInt(inputElement.value) || 0;
const newValue = currentValue + delta;
if (("" + newValue).length <= inputElement.maxLength) {
inputElement.value = newValue >= 0 ? newValue : 0;
$(inputElement).trigger('input');
}
}
$('.numeric-input').on('input', function () {
this.value = this.value.replace(/[^0-9]/g, '');
});
$('.numeric-input').on('keydown', function (e) {
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
adjustValue(this, e.shiftKey ? 10 : 1);
break;
case 'ArrowDown':
e.preventDefault();
adjustValue(this, e.shiftKey ? -10 : -1);
break;
}
});
function updateRadioActiveClass() {
$('.radio-group label').removeClass('active');
$('input[type="radio"]:checked').next('label').addClass('active');
}
updateRadioActiveClass();
$('.radio-group input[type="radio"]').change(function() {
updateRadioActiveClass();
});
function updateCheckboxActiveClass() {
$('.checkbox-group label').each(function() {
const checkbox = $(this).prev('input[type="checkbox"]');
if (checkbox.is(':checked')) {
$(this).addClass('active');
} else {
$(this).removeClass('active');
}
});
}
updateCheckboxActiveClass();
$('.checkbox-group input[type="checkbox"]').change(function() {
updateCheckboxActiveClass();
});
$.fn.serializeObject = function() {
const obj = {};
this.find('input, select, textarea').each(function() {
const field = $(this);
const name = field.attr('name');
if (!name) return; // Ignore fields without a name
if (field.is(':checkbox')) {
const isOnOff = field.val() === 'on' || field.val() === '1';
obj[name] = field.is(':checked') ? field.val() : (isOnOff ? false : null);
} else if (field.is(':radio')) {
if (field.is(':checked')) {
obj[name] = field.val();
} else if (!(name in obj)) {
obj[name] = false;
}
} else {
const tryInt = parseInt(field.val());
obj[name] = isNaN(tryInt) ? field.val() : tryInt;
}
});
return obj;
};
});
});

File diff suppressed because one or more lines are too long

1
data/www/js/lib/jscolor.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -21,14 +21,81 @@ jQuery(document).ready(function ($) {
});
$(document).on('click', '.playlist-preview', function () {
const $iframe = $('<iframe>', {
src: $(this).attr('data-url'),
frameborder: 0
});
const $icon = $(this).find('i');
const isPlay = $icon.hasClass('fa-play');
const $holder = $(this).parents('.preview:eq(0)');
$(this).parents('.preview:eq(0)').append($iframe);
$(this).remove();
if (isPlay) {
const $iframe = $('<iframe>', {
src: $(this).attr('data-url'),
frameborder: 0
});
$holder.append($iframe);
$(this).addClass('hover-only');
$icon.removeClass('fa-play').addClass('fa-pause');
} else {
$holder.find('iframe').remove();
$(this).removeClass('hover-only');
$icon.removeClass('fa-pause').addClass('fa-play');
}
});
//
// $(document).on('click', '.cast-scan', function () {
// showModal('modal-playlist-cast-scan');
// const $modal = $('.modal-playlist-cast-scan:visible');
// const $holder = $modal.find('.cast-devices');
// const $loading = $modal.find('.loading');
//
// $loading.removeClass('hidden');
// $holder.removeClass('hidden');
// $holder.html('');
// $loading.html($loading.attr('data-loading'));
//
// $.ajax({
// method: 'GET',
// url: route_cast_scan,
// headers: {'Content-Type': 'application/json'},
// success: function (response) {
// $loading.addClass('hidden')
//
// for (let i = 0; i < response.devices.length; i++) {
// const device = response.devices[i];
// $holder.append($('<li><a href="javascript:void(0)" class="cast-device" data-id="' + device.friendly_name + '"><i class="fa fa-brands fa-chromecast"></i>' + device.friendly_name + '</a></li>'));
// }
// }
// });
// });
// $(document).on('click', '.cast-device', function () {
// const $modal = $('.modal-playlist-cast-scan:visible');
// const $holder = $modal.find('.cast-devices');
// const $loading = $modal.find('.loading');
//
// $holder.addClass('hidden');
// $loading.removeClass('hidden');
// $loading.html($loading.attr('data-casting'));
//
// const id = $(this).attr('data-id');
//
// $.ajax({
// url: route_cast_url,
// method: 'POST',
// data: JSON.stringify({
// device: id,
// url: $('#playlist-preview-url').val()
// }),
// headers: {'Content-Type': 'application/json'},
// success: function (response) {
// $loading.addClass('hidden');
// hideModal();
// },
// error: function () {
// $loading.addClass('hidden');
// $holder.removeClass('hidden');
// }
// });
// });
main();
});

View File

@ -22,7 +22,7 @@ jQuery(document).ready(function ($) {
$('.modal-variable-edit input:visible:eq(0)').focus().select();
$('#variable-edit-name').val(variable.name);
$('#variable-edit-description').html(variable.description);
$('#variable-edit-description-edition').html(variable.description_edition).toggleClass('hidden', variable.description_edition === '');
$('#variable-edit-description-edition').html(variable.description_edition).toggleClass('hidden', variable.description_edition === '' || variable.description_edition === null);
$('#variable-edit-value').val(variable.value);
$('#variable-edit-id').val(variable.id);
});

View File

@ -0,0 +1,471 @@
jQuery(document).ready(function ($) {
const DEFAULT_RATIO = "16/9";
const contentData = JSON.parse($('#content-edit-location').val() || `{"ratio":"${DEFAULT_RATIO}", "layers":{}}`);
let currentElement = null;
let elementCounter = 0;
let screenRatio = 16/9;
const setRatio = function () {
const ratioString = $('#elem-screen-ratio').val() || DEFAULT_RATIO;
$('.ratio-value').text(ratioString.replace('/', ' / '));
screenRatio = evalStringRatio(ratioString);
$('.screen-holder').css({ 'padding-top': ( 1/ ( screenRatio ) * 100) + '%' });
$('.ratio-value').val(screenRatio);
$('#screen').css({
width: $('#screen').width(),
height: $('#screen').width() * (1/screenRatio),
position: 'relative',
}).parents('.screen-holder:eq(0)').css({
width: 'auto',
'padding-top': '0px'
});
};
setRatio();
$(document).on('input', '#elem-screen-ratio', function() {
setRatio();
});
function createElement(config = null) {
const screen = $('#screen');
const screenWidth = screen.width();
const screenHeight = screen.height();
const elementWidth = config ? (config.widthPercent / 100) * screenWidth : 100;
const elementHeight = config ? (config.heightPercent / 100) * screenHeight : 50;
let x = config ? (config.xPercent / 100) * screenWidth : Math.round(Math.random() * (screenWidth - elementWidth));
let y = config ? (config.yPercent / 100) * screenHeight : Math.round(Math.random() * (screenHeight - elementHeight));
const zIndex = config ? config.zIndex : elementCounter++;
//x = Math.round(Math.max(0, Math.min(x, screenWidth - elementWidth)));
//y = Math.round(Math.max(0, Math.min(y, screenHeight - elementHeight)));
const elementId = zIndex;
const element = $('<div class="element" id="element-' + zIndex + '" data-id="' + zIndex + '"><i class="fa fa-cog"></i></div>');
// const element = $('<div class="element" id="' + elementId + '"><button>Button</button><div class="rotate-handle"></div></div>');
element.css({
left: x,
top: y,
width: elementWidth,
height: elementHeight,
zIndex: zIndex,
transform: `rotate(0deg)`
});
element.draggable({
// containment: "#screen",
start: function (event, ui) {
focusElement(ui.helper);
},
drag: function (event, ui) {
updateForm(ui.helper);
}
});
element.resizable({
// containment: "#screen",
handles: 'n, s, e, w, nw, ne, sw, se',
start: function (event, ui) {
focusElement(ui.element);
},
resize: function (event, ui) {
updateForm(ui.element);
}
});
/*
element.rotatable({
handle: element.find('.rotate-handle'),
rotate: function(event, ui) {
updateForm(ui.element);
}
});
*/
element.click(function () {
focusElement($(this));
});
screen.append(element);
addElementToList(elementId);
if (config !== null && config.contentId !== null) {
element.attr('data-content-id', config.contentId);
element.attr('data-content-name', config.contentName);
element.attr('data-content-type', config.contentType);
element.attr('data-content-metadata', config.contentMetadata);
applyContentToElement({
id: config.contentId,
name: config.contentName,
type: config.contentType,
metadata: config.contentMetadata,
}, element);
updateForm(element);
unfocusElements();
} else {
setTimeout(function () {
focusElement(element);
}, 10);
}
return element;
}
$(document).on('click', '.element-adjust-aspect-ratio', function(){
const metadata = currentElement.data('content-metadata');
const ratio = metadata.height / metadata.width;
$('#elem-height').val($('#elem-width').val() * ratio).trigger('input');
$('#elem-width').val($('#elem-width').val()).trigger('input');
});
$(document).on('click', '.element-list-item', function(){
focusElement($('#element-' + $(this).attr('data-id')));
});
$(document).on('click', '.remove-element', function(){
if (confirm(l.js_common_are_you_sure)) {
removeElementById($(this).attr('data-id'));
}
});
function removeElementById(elementId) {
$('.element[data-id='+elementId+'], .element-list-item[data-id='+elementId+']').remove();
updateZIndexes();
}
function addElementToList(elementId) {
const listItem = `<div class="element-list-item" data-id="__ID__">
<i class="fa fa-cog"></i>
<div class="inner">
<label>__EMPTY__ __ID__ </label>
<button type="button" class="btn btn-naked remove-element" data-id="__ID__">
<i class="fa fa-trash"></i>
</button>
<button type="button" class="btn btn-neutral configure-element content-explr-picker" data-id="__ID__">
<i class="fa fa-cog"></i>
</button>
</div>
</div>`;
$('#elementList').append(
$(listItem
.replace(/__ID__/g, elementId)
.replace(/__EMPTY__/g, l.js_common_empty)
)
);
updateZIndexes();
}
function unfocusElements() {
$('.element, .element-list-item').removeClass('focused');
currentElement = null;
updateForm(null);
}
function focusElement($element) {
unfocusElements();
currentElement = $element;
$element.addClass('focused');
const listElement = $('.element-list-item[data-id="' + $element.attr('data-id') + '"]');
listElement.addClass('focused');
updateForm($element);
const contentType = $element.attr('data-content-type');
$('.element-tool').addClass('hidden');
if (contentType) {
if (contentType === 'picture' || contentType === 'video') {
const contentMetadata = $element.data('content-metadata');
if (contentMetadata.width && contentMetadata.height) {
$('.element-tool.element-adjust-aspect-ratio-container').removeClass('hidden');
}
}
}
}
function updateForm($element) {
if (!$element) {
$('form#elementForm input').val('').prop('disabled', true);
$('.form-element-properties').addClass('hidden');
return;
}
$('.form-element-properties').removeClass('hidden');
$('form#elementForm input').prop('disabled', false);
const offset = $element.position();
if (offset !== undefined) {
$('#elem-x').val(offset.left);
$('#elem-y').val(offset.top);
$('#elem-width').val($element.width());
$('#elem-height').val($element.height());
}
$element.find('i').css('font-size', Math.min($element.width(), $element.height()) / 3);
/*
const rotation = $element.css('transform');
const values = rotation.split('(')[1].split(')')[0].split(',');
const angle = Math.round(Math.atan2(values[1], values[0]) * (180/Math.PI));
$('#elem-rotate').val(angle);
*/
}
$(document).on('input', '#elementForm input', function () {
if (!currentElement) {
return;
}
const screenWidth = $('#screen').width();
const screenHeight = $('#screen').height();
let x = Math.round(parseInt($('#elem-x').val()));
let y = Math.round(parseInt($('#elem-y').val()));
let width = Math.round(parseInt($('#elem-width').val()));
let height = Math.round(parseInt($('#elem-height').val()));
// let rotation = parseInt($('#elem-rotate').val());
// Constrain x and y
// x = Math.max(0, Math.min(x, screenWidth - width));
// y = Math.max(0, Math.min(y, screenHeight - height));
// Constrain width and height
width = Math.min(width, screenWidth - x);
height = Math.min(height, screenHeight - y);
currentElement.css({
left: x,
top: y,
width: width,
height: height
// transform: `rotate(${rotation}deg)`
});
// Update form values to reflect clamped values
$('#elem-x').val(x);
$('#elem-y').val(y);
$('#elem-width').val(width);
$('#elem-height').val(height);
});
// $(document).on('click', '#addElement', function () {
// createElement();
// });
$(document).on('click', '#removeAllElements', function () {
if (confirm(l.js_common_are_you_sure)) {
$('.element, .element-list-item').remove();
updateZIndexes();
}
});
$(document).on('dblclick', '.element', function (e) {
$('.content-explr-picker[data-id='+$(this).attr('data-id')+']').click();
});
$(document).on('mousedown', function (e) {
const keepFocusedElement = $(e.target).hasClass('element')
|| $(e.target).hasClass('element-list-item')
|| $(e.target).parents('.element:eq(0)').length !== 0
|| $(e.target).parents('.element-list-item:eq(0)').length !== 0
|| $(e.target).is('input,select,textarea')
|| $(e.target).is('.page-panel.right-panel button,a,.btn')
if (!keepFocusedElement) {
unfocusElements();
}
});
$(document).on('click', '#presetGrid2x2', function () {
const screenWidth = $('#screen').width();
const screenHeight = $('#screen').height();
let elements = $('.element');
if (elements.length < 4) {
while (elements.length < 4) {
createElement();
elements = $('.element');
}
}
elements = $('.element-list-item').map(function() {
return $('.element[data-id='+$(this).attr('data-id')+']');
}).slice(0, 4);
const gridPositions = [
{x: 0, y: 0},
{x: screenWidth / 2, y: 0},
{x: 0, y: screenHeight / 2},
{x: screenWidth / 2, y: screenHeight / 2}
];
elements.each(function (index) {
const position = gridPositions[index];
$(this).css({
left: position.x,
top: position.y,
width: screenWidth / 2,
height: screenHeight / 2
});
updateForm($(this));
});
unfocusElements();
});
$(document).on('click', '#presetTvNews1x1', function () {
const screenWidth = $('#screen').width();
const screenHeight = $('#screen').height();
let elements = $('.element');
if (elements.length === 0) {
createElement();
}
if (!currentElement) {
return;
}
const height = (screenHeight / 7);
currentElement.css({
left: 0,
top: screenHeight - height,
width: screenWidth,
height: height
});
updateForm(currentElement);
unfocusElements();
});
$(document).keydown(function (e) {
if (e.key === "Escape") {
unfocusElements();
}
const hasFocusInInput = $('input,textarea').is(':focus');
if (!currentElement || hasFocusInInput) {
return;
}
if (e.key === "ArrowLeft") {
$('#elem-x').val(parseInt($('#elem-x').val()) - (e.shiftKey ? 10 : 1)).trigger('input');
} else if (e.key === "ArrowRight") {
$('#elem-x').val(parseInt($('#elem-x').val()) + (e.shiftKey ? 10 : 1)).trigger('input');
} else if (e.key === "ArrowUp") {
$('#elem-y').val(parseInt($('#elem-y').val()) - (e.shiftKey ? 10 : 1)).trigger('input');
} else if (e.key === "ArrowDown") {
$('#elem-y').val(parseInt($('#elem-y').val()) + (e.shiftKey ? 10 : 1)).trigger('input');
} else if (e.key === "Backspace") {
if (confirm(l.js_common_are_you_sure)) {
removeElementById(currentElement.attr('data-id'));
}
}
});
$(document).on('click', '.content-explr-picker', function () {
const elementId = $(this).attr('data-id');
const isNew = !elementId;
const $element = isNew ? $(createElement()) : $('#element-'+elementId);
showPickers('modal-content-explr-picker', function (content) {
applyContentToElement(content, $element)
});
});
const applyContentToElement = function (content, $element) {
$element.attr('data-content-id', content.id);
$element.attr('data-content-name', content.name);
$element.attr('data-content-type', content.type);
$element.data('content-metadata', content.metadata);
const $elementList = $('.element-list-item[data-id='+$element.attr('data-id')+']');
const iconClasses = [
'fa',
content_type_icon_classes[content.type],
content_type_color_classes[content.type]
].join(' ');
$element.find('i').get(0).classList = iconClasses;
$elementList.find('label').text(content.name);
$elementList.find('i:eq(0)').get(0).classList = iconClasses;
};
$(document).on('submit', 'form.form', function (e) {
unfocusElements();
const location = getLocationPayload();
$('#content-edit-location').val(JSON.stringify(location));
});
function updateZIndexes() {
const zindex = $('.element-list-item').length + 1;
$('.element-list-item').each(function(index) {
const id = $(this).attr('data-id');
$('#element-' + id).css('z-index', zindex - index);
});
}
$('#elementList').sortable({
update: function(event, ui) {
updateZIndexes();
}
});
const applyElementsFromContent = function() {
for (let i = 0; i < contentData.layers.length; i++) {
createElement(contentData.layers[i]);
}
};
applyElementsFromContent();
const getLocationPayload = function() {
const screen = $('#screen');
const screenWidth = screen.width();
const screenHeight = screen.height();
const layers = [];
$('.element').each(function () {
const $element = $(this);
const offset = $element.position();
const x = offset.left;
const y = offset.top;
const width = $element.width();
const height = $element.height();
const xPercent = (x / screenWidth) * 100;
const yPercent = (y / screenHeight) * 100;
const widthPercent = (width / screenWidth) * 100;
const heightPercent = (height / screenHeight) * 100;
const contentId = $element.attr('data-content-id');
const contentName = $element.attr('data-content-name');
const contentType = $element.attr('data-content-type');
const contentMetadata = $element.data('content-metadata');
const layer = {
xPercent: xPercent,
yPercent: yPercent,
widthPercent: widthPercent,
heightPercent: heightPercent,
zIndex: parseInt($element.css('zIndex')),
contentId: contentId ? parseInt(contentId) : null,
contentName: contentName ? contentName : null,
contentType: contentType ? contentType : null,
contentMetadata: contentMetadata && contentMetadata !== "null" ? contentMetadata : null,
};
layers.push(layer);
});
layers.sort(function(a, b) {
return parseInt(b.zIndex) - parseInt(a.zIndex);
});
return {
ratio: $('#elem-screen-ratio').val(),
layers: layers
};
};
});

View File

@ -0,0 +1,79 @@
jQuery(document).ready(function ($) {
const contentData = JSON.parse($('#content-edit-location').val() || '{}');
const screenRatio = 16/9;
$('.screen-holder').css({
'padding-top': ( 1/ ( screenRatio ) * 100) + '%'
});
$('.ratio-value').val(screenRatio);
$('#screen').css({
width: $('#screen').width(),
height: $('#screen').height(),
position: 'relative',
}).parents('.screen-holder:eq(0)').css({
width: 'auto',
'padding-top': '0px'
});
const draw = function() {
const $screen = $('#screen');
const $text = $('<div class="text">');
let insideText = $('#elem-text').val();
if ($('#elem-scroll-enable').is(':checked')) {
const $wrapper = $('<marquee>');
$wrapper.attr({
scrollamount: $('#elem-scroll-speed').val(),
direction: $('[name=scrollDirection]:checked').val(),
behavior: 'scroll',
loop: -1
});
$wrapper.append(insideText);
insideText = $wrapper;
}
$text.append(insideText);
let justifyContent = 'center';
switch($('[name=textAlign]:checked').val()) {
case 'left': justifyContent = 'flex-start'; break;
case 'right': justifyContent = 'flex-end'; break;
}
$text.css({
padding: $('#elem-container-margin').val() + 'px',
color: $('#elem-fg-color').val(),
textAlign: $('[name=textAlign]:checked').val(),
textDecoration: $('#elem-text-underline').is(':checked') ? 'underline' : 'normal',
fontSize: $('#elem-font-size').val() + 'px',
fontWeight: $('#elem-font-bold').is(':checked') ? 'bold' : 'normal',
fontStyle: $('#elem-font-italic').is(':checked') ? 'italic' : 'normal',
fontFamily: $('#elem-font-family').val() + ", 'Arial', 'sans-serif'",
whiteSpace: $('#elem-single-line').is(':checked') ? 'nowrap' : 'normal',
justifyContent: justifyContent
});
$screen.css({
backgroundColor: $('#elem-bg-color').val(),
});
$screen.html($text);
};
$(document).on('input', '#elementForm input, #elementForm select', function () {
draw();
});
draw();
$(document).on('submit', 'form.form', function (e) {
const location = $('form#elementForm').serializeObject();
$('#content-edit-location').val(JSON.stringify(location));
});
});

View File

@ -21,7 +21,11 @@ jQuery(document).ready(function ($) {
$form.find('.object-label:visible').html(optionAttributes['data-object-label'].value);
$('.type-icon').attr('class', 'type-icon fa ' + optionAttributes['data-icon'].value);
$('.tab-select .widget').attr('class', 'widget ' + ('border-' + color) + ' ' + color);
$form.find('button[type=submit]').attr('class', 'btn ' + ('btn-' + color));
$form.find('button[type=submit]').attr('class', [
'btn',
`btn-${color}`,
classColorXor(color, '')
].join(' '));
};
const main = function () {

View File

@ -31,13 +31,20 @@ jQuery(function ($) {
},
always: function (e, data) {
const response = data._response.jqXHR;
let statusCode = response.status;
$button.removeClass('uploading').removeClass('btn-naked btn-super-upload-busy').addClass('btn-info btn-super-upload');
if (response.status != 200) {
const $alert = $('.alert-danger').removeClass('hidden');
if (response.status == 413) {
$alert.text(l.js_common_http_error_413);
let errorComment = response.responseText.match(/<!--\s*error=(\d+);\s*-->/);
if (errorComment && errorComment[1]) {
statusCode = parseInt(errorComment[1], 10);
}
if (statusCode !== 200) {
const $alert = $('.alert-upload').removeClass('hidden');
if (statusCode === 413) {
$alert.html(`<i class="fa fa-warning"></i>${l.js_common_http_error_413}`);
} else {
$alert.text(l.js_common_http_error_occured.replace('%code%', response.status));
$alert.html(`<i class="fa fa-warning"></i>${l.js_common_http_error_occured.replace('%code%', statusCode)}`);
}
} else {
document.location.reload();

View File

@ -81,3 +81,9 @@ const secondsToHHMMSS = function (seconds) {
const secs = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const evalStringRatio = function(str) {
return str.replace(/(\d+)\/(\d+)/g, function(match, p1, p2) {
return (parseInt(p1) / parseInt(p2)).toString();
});
};

View File

@ -31,8 +31,16 @@ main {
margin-right: 20px;
}
.contex-tail {
margin-right: 20px;
.context-tail {
margin-right: 30px;
.btn {
margin-right: 0;
}
}
.context-tail-auth {
margin-right: 10px;
.btn {
margin-right: 0;

View File

@ -26,6 +26,10 @@ body, html {
align-items: flex-start;
flex: 1;
align-self: stretch;
&.fx-end {
justify-content: flex-end;
}
}
.vertical {

View File

@ -0,0 +1,12 @@
@keyframes blink{50%{opacity:0;}}
.cfx-blink{animation:1.5s linear infinite blink;}
.cfx-ffff-speed {animation-delay: 0.1s;}
.cfx-fff-speed {animation-delay: 0.3s;}
.cfx-ff-speed {animation-delay: 0.5s;}
.cfx-f-speed {animation-delay: 0.8s;}
.cfx-m-speed {animation-delay: 1s;}
.cfx-s-speed {animation-delay: 1.3s;}
.cfx-ss-speed {animation-delay: 1.5s;}
.cfx-sss-speed {animation-delay: 1.8s;}
.cfx-ssss-speed {animation-delay: 2s;}
.cfx-sssss-speed {animation-delay: 3s;}

View File

@ -1,32 +1,10 @@
a.badge,
.badge {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 5px 5px;
border-radius: $baseRadius;
font-size: 12px;
background: rgba($gscaleF, .1);
border: 1px solid transparent;
color: $gscaleF;
}
a.badge:hover {
color: $gscaleF;
border: 1px solid rgba($gscaleF, .4);
}
.panel-inactive .badge {
background: rgba($gscale7, .1);
color: $gscale7;
}
.panel-inactive a.badge:hover {
color: $gscale7;
border: 1px solid rgba($gscale7,.2);
}
.badge.anonymous {
opacity: .2;
.badge-inset {
display: inline;
color: $gscaleA;
font-size: 12px;
margin-left: 5px;
background: $gscale0;
border: 1px solid $gscale3;
border-radius: $baseRadius;
padding: 3px 7px;
}

View File

@ -1,3 +1,4 @@
button,
.btn {
$shadowOffset: 2px;
@ -56,6 +57,7 @@ button,
box-shadow: 0 $shadowOffset 0 0 darken($gscale5, 10%);
border: 1px solid transparent;
&.active,
&:hover {
box-shadow: 0 $shadowOffset 0 1px $gkscale2 inset;
background: darken($gscale5, 10%);
@ -141,4 +143,3 @@ button,
cursor: default;
}
}

View File

@ -19,3 +19,17 @@
transform: rotate(2deg);
}
}
@keyframes blinkfade {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@ -139,6 +139,30 @@
}
}
}
&.highlighted:hover,
&.highlighted {
background-color: $seaBlue;
td {
font-weight: bold;
color: $gscaleF;
i.icon-legend {
color: $gscaleF;
}
span,
i.icon-value {
background-color: rgba($gscaleF, .3);
color: $gscaleF;
}
&.description {
color: $white;
}
}
}
}
}
}

View File

@ -81,6 +81,24 @@ form {
}
}
.checkbox-group,
.radio-group {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
input {
display: none;
}
label {
margin: 0 5px 0 0 !important;
justify-content: center !important;
text-align: center;
}
}
.widget {
margin-top: 10px;
align-self: stretch;
@ -100,12 +118,11 @@ form {
}
}
.btn {
input + .btn + .btn {
margin-left: 10px;
}
&.widget-unit {
select,
input {
flex-grow: 0;
@ -131,6 +148,33 @@ form {
}
}
select,
input {
&.size-m {
max-width: 122px;
}
&.color-picker {
max-width: 125px;
}
&.chars-4 {
max-width: 50px;
}
&.chars-3 {
max-width: 40px;
}
&.chars-2 {
max-width: 20px;
}
&.chars-1 {
max-width: 15px;
}
}
div {
color: rgba($gscaleF, .7);
font-size: 14px;
@ -155,23 +199,17 @@ form {
color: $gscale5;
background: none;
box-shadow: none;
border: none;
border-bottom: 1px solid $gscale3;
border-radius: 0;
}
&.input-naked {
padding-left: 0;
color: $gscaleB;
}
&.disabled,
&[disabled] {
border: none;
background: $gscale0;
border-radius: $baseRadius;
padding-left: 10px;
padding-right: 10px;
}
}
}

View File

@ -18,19 +18,20 @@
@import 'components/modals';
@import 'components/toast';
@import 'components/dragdrop';
// Legacy
@import 'components/animation';
@import 'components/panes';
@import 'components/tiles';
@import 'components/empty';
@import 'components/switches';
//@import 'components/badges';
@import 'components/badges';
// Import form styles
@import 'forms/forms';
// Import pages styles
@import 'pages/content';
@import 'pages/content-composition';
@import 'pages/content-text';
@import 'pages/logs';
@import 'pages/node-player';
@import 'pages/playlist';

View File

@ -43,6 +43,25 @@ button,
&.btn-neutral:hover {
box-shadow: 0 2px 0 1px $gkscale6 inset;
}
&.btn-neutral {
$shadowOffset: 2;
color: $gkscale5;
background: $white;
box-shadow: none !important;
border: 1px solid transparent;
&.active,
&:hover {
box-shadow: 0 $shadowOffset 0 1px $gkscale2 inset;
background: $gkscaleC;
}
&:focus {
background: darken($gscale5, 20%);
border: 1px solid $gscaleA;
}
}
}
.tiles .tiles-inner .tile-item {

View File

@ -0,0 +1,364 @@
.view-content-edit.view-content-edit-composition main .main-container {
.page-panel.left-panel {
flex: 1;
.form-holder {
margin: 20px 20px 20px 10px;
flex: 1;
}
}
.page-content {
flex: 2;
}
.page-panel.right-panel {
flex: 1;
}
h3.main {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
margin-top: 5px;
border-bottom: 1px solid $gscale2;
margin-bottom: 20px;
padding-bottom: 10px;
align-self: stretch;
}
.toolbar {
margin-bottom: 20px;
}
.presets {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
margin-bottom: 20px;
h4 {
margin-right: 5px;
font-weight: normal;
font-size: 14px;
text-decoration: underline;
}
button:focus,
button {
padding: 3px 15px;
margin:0 3px;
font-size: 12px;
font-weight: normal;
min-height: initial;
border: 1px solid $gkscale3;
}
}
.screen-holder {
//display: flex;
//flex-direction: row;
display: flex;
flex-direction: column;
width: 100%;
position: relative;
padding-top: 56.25%; /* 16:9 aspect ratio */
overflow: hidden;
border-radius: $baseRadius;
outline: 4px solid rgba($gscaleF, .1);
.screen {
background-color: #ddd;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
.element {
position: absolute !important;
background-color: $gkscaleE;
outline: 1px solid $gkscaleC;
text-align: center;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
&.focused {
border: none;
outline: 2px solid $seaBlue;
z-index: 89 !important;
.ui-resizable-handle {
display: block;
}
}
i {
font-size: 20px;
color: $gkscaleC;
&.fa-cog {
text-shadow: 0 -2px $gkscaleB, 0 0px 2px $gkscaleB;
}
&.gscaleF {
color: black !important;
}
}
.rotate-handle {
width: 10px;
height: 10px;
background-color: red;
position: absolute;
top: 50%;
right: -15px;
cursor: pointer;
transform: translateY(-50%);
}
.ui-resizable-handle {
$size: 10px;
$sizeOffset: -1*calc($size/2);
background: $gkscaleA;
border: 1px solid $gkscale5;
width: $size;
height: $size;
z-index: 90;
display: none;
position: absolute;
&.ui-resizable-n {
cursor: n-resize;
top: $sizeOffset;
left: 50%;
margin-left: $sizeOffset;
}
&.ui-resizable-s {
cursor: s-resize;
bottom: $sizeOffset;
left: 50%;
margin-left: $sizeOffset;
}
&.ui-resizable-w {
cursor: w-resize;
left: $sizeOffset;
top: 50%;
margin-top: $sizeOffset;
}
&.ui-resizable-e {
cursor: e-resize;
right: $sizeOffset;
top: 50%;
margin-top: $sizeOffset;
}
&.ui-resizable-nw {
cursor: nw-resize;
top: $sizeOffset;
left: $sizeOffset;
}
&.ui-resizable-ne {
cursor: ne-resize;
top: $sizeOffset;
right: $sizeOffset;
}
&.ui-resizable-sw {
cursor: sw-resize;
bottom: $sizeOffset;
left: $sizeOffset;
}
&.ui-resizable-se {
cursor: se-resize;
bottom: $sizeOffset;
right: $sizeOffset;
}
}
}
}
}
.elements-holder {
align-self: stretch;
h3 {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
margin: 0 0 20px 0;
&.divide {
border-top: 1px solid $gscale2;
margin-top: 10px;
padding-top: 20px;
}
}
.form-elements-list {
padding: 10px;
background: $gscale2;
border-radius: $baseRadius;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-self: flex-start;
.element-list-item {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
> i {
color: $gscaleE;
margin:0 10px 0 0;
cursor: move;
width: 30px;
text-align: center;
}
.inner:hover,
&.focused .inner {
background-color: $seaBlue;
color: white;
font-weight: bold;
button.btn-naked {
color: $white;
}
}
.inner {
cursor: pointer;
padding: 5px 5px 5px 10px;
margin-bottom: 5px;
background: $gkscaleE;
border-radius: $baseRadius;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
align-self: stretch;
color: $gkscale2;
min-height: 46px;
flex: 1;
label {
flex: 1;
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 219px;
overflow: hidden;
}
button {
display: none;
margin-left: 5px;
}
button.btn-naked {
color: $gscale5;
}
&:hover {
label {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
display: block;
}
}
}
}
}
}
.form-element-properties {
flex: 1;
align-self: stretch;
form {
display: flex;
flex-direction: column;
h3 {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
border-bottom: 1px solid $gscale2;
margin-bottom: 20px;
padding-bottom: 10px;
align-self: stretch;
}
.divide {
margin-top: 30px;
margin-bottom: 10px;
}
.form-group {
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
label {
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
font-weight: bold;
margin-right: 10px;
}
.widget {
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
margin: 0;
input {
flex: 1;
margin: 0;
&[disabled] {
padding: 8px 0 5px 8px;
border: 1px solid rgba(255, 255, 255, .05);
}
}
}
}
}
}
}

View File

@ -0,0 +1,155 @@
.view-content-edit.view-content-edit-text main .main-container {
.page-panel.left-panel {
flex: 1;
.form-holder {
margin: 20px 20px 20px 10px;
flex: 1;
}
}
.page-content {
flex: 2;
}
.page-panel.right-panel {
flex: 1;
}
h3.main {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
margin-top: 5px;
border-bottom: 1px solid $gscale2;
margin-bottom: 20px;
padding-bottom: 10px;
align-self: stretch;
}
.toolbar {
margin-bottom: 20px;
}
.screen-holder {
//display: flex;
//flex-direction: row;
display: flex;
flex-direction: column;
width: 100%;
position: relative;
padding-top: 56.25%; /* 16:9 aspect ratio */
overflow: hidden;
border-radius: $baseRadius;
outline: 4px solid rgba($gscaleF, .1);
background: repeating-conic-gradient(#EEE 0% 25%, white 0% 50%) 50% / 20px 20px;
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
display: flex;
.text {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
flex: 1;
align-self: stretch;
text-align: center;
max-width: 100%;
word-break: break-all;
marquee {
display: flex;
align-items: center;
justify-content: flex-start;
flex-direction: row;
flex: 1;
height: 100%;
width: 100%;
}
}
}
}
.form-element-properties {
flex: 1;
align-self: stretch;
form {
display: flex;
flex-direction: column;
h3 {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
border-bottom: 1px solid $gscale2;
margin-bottom: 20px;
padding-bottom: 10px;
align-self: stretch;
}
.divide {
margin-top: 30px;
margin-bottom: 10px;
}
.bar {
width: 100%;
height: 1px;
background: #333;
margin-bottom: 20px;
}
.form-group {
label {
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
font-weight: bold;
margin-right: 10px;
margin-bottom: 5px;
}
.widget {
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
margin: 0;
input {
flex: 1;
margin: 0;
&[disabled] {
padding: 8px 0 5px 8px;
border: 1px solid rgba(255, 255, 255, .05);
}
}
}
}
}
}
}

View File

@ -23,6 +23,29 @@
.view-content-edit main .main-container {
.top-content {
h3 {
color: $gscaleF;
padding: 10px 10px 10px 0;
font-size: 16px;
align-self: stretch;
flex: 1;
text-align: right;
span {
border-width: 1px;
border-style: solid;
border-radius: $baseRadius;
padding: 4px 10px;
margin-left: 5px;
}
i {
font-size: 16px;
}
}
}
.bottom-content {
.page-content {
flex: 1;
@ -38,32 +61,11 @@
align-self: stretch;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: auto;
justify-content: flex-start;
align-items: center;
padding: 20px;
h3 {
color: $gscaleF;
padding: 10px 10px 10px 0;
margin-bottom: 20px;
font-size: 16px;
align-self: stretch;
margin-left: -8px;
span {
border-width: 1px;
border-style: solid;
border-radius: $baseRadius;
padding: 4px 10px;
margin-left: 5px;
}
i {
font-size: 16px;
}
}
.iframe-wrapper {
display: flex;
flex-direction: column;
@ -87,7 +89,3 @@
}
}

View File

@ -75,12 +75,27 @@
}
}
.preview-holder {
position: relative;
.form-group {
flex-grow: 0;
margin-bottom: 0;
}
.hover-only {
display: none;
}
&:hover {
.hover-only {
display: flex;
position: absolute;
&:hover {
background: $gkscaleC;
}
}
}
}
h4 {

View File

@ -11,25 +11,75 @@
align-self: stretch;
color: $gscale6;
}
.modal-playlist-qrcode {
h2 {
text-align: center;
}
.qrcode-pic {
text-align: center;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
img {
border: 4px solid $gscale5;
border-radius: $baseRadius;
}
}
}
//
//.modal-playlist-cast-scan {
// h2 {
// text-align: left;
// }
//
// .alert {
// padding: 10px;
// font-size: 12px;
// margin-bottom: 20px;
// display: block;
// text-align: center;
//
// i {
// margin-right: 5px;
// }
//
// a {
// margin: 0;
// }
// }
//
// .loading {
// color: $gscaleF;
// animation-duration: 2s;
// animation-iteration-count: infinite;
// animation-name: blinkfade;
// }
//
// ul.cast-devices {
// list-style: none;
// margin: 0;
// padding: 0;
//
// li {
// display: flex;
// flex-direction: row;
// justify-content: flex-start;
// align-items: center;
// list-style: none;
// border-bottom: 1px solid $gscale2;
// border-radius: $baseRadius;
//
// a {
// flex: 1;
// display: flex;
// flex-direction: row;
// justify-content: flex-start;
// align-items: center;
// padding: 20px 15px;
// color: $gscaleF;
// align-self: stretch;
//
// i {
//
// margin-right: 10px;
// }
// }
//
// &:hover {
// background: $gscale2;
// }
// }
//
// li:last-child {
// border: none;
// }
// }
//}
.modal-slide {
h2 {
@ -98,12 +148,28 @@
}
}
.preview-holder {
position: relative;
.form-group {
flex-grow: 0;
margin-bottom: 0;
}
.hover-only {
display: none;
}
&:hover {
.hover-only {
display: flex;
position: absolute;
&:hover {
background: $gkscaleC;
}
}
}
}
h4 {

View File

@ -15,9 +15,35 @@
}
.tile-tail {
a:last-child {
.btn {
margin-left: 10px;
}
.btn:first-child {
margin-left: 0;
}
}
.tile-metrics {
flex: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
.widget,
.form-group {
margin: 0;
}
label {
flex-grow: 0;
}
input {
margin-left: 10px;
margin-right: 10px;
max-width: 320px;
}
}
}
}

View File

@ -14,6 +14,7 @@ $layoutBorder: 1px solid $gscale2;
// Packs
$colors: (
warning: $warning,
orange: $orange,
info: $info,
info-alt: $bitterBlue,
success: $success,
@ -39,6 +40,8 @@ $colors: (
redhat:$redhat,
centos:$centos,
other:$other,
gscale0:$gscale0,
gscaleF:$gscaleF,
);
// Classes

View File

@ -10,11 +10,8 @@ services:
- DEBUG=false
- SECRET_KEY=ANY_SECRET_KEY_HERE
- PORT=5000
- PORT_HTTP_EXTERNAL_STORAGE=5001
volumes:
- ./data/db:/app/data/db
- ./data/uploads:/app/data/uploads
- ./var/run/storage:/app/var/run/storage
- /etc/localtime:/etc/localtime:ro
- ./:/app/
ports:
- 5000:5000
- 5001:5001

View File

@ -2,17 +2,16 @@ services:
webapp:
container_name: obscreen
restart: unless-stopped
image: jierka/obscreen:latest
image: csmith1865/obscreen:latest
environment:
- DEMO=false
- DEBUG=false
- SECRET_KEY=ANY_SECRET_KEY_HERE
- PORT=5000
- PORT_HTTP_EXTERNAL_STORAGE=5001
volumes:
- /etc/localtime:/etc/localtime:ro
- ./data/db:/app/data/db
- ./data/uploads:/app/data/uploads
- ./var/run/storage:/app/var/run/storage
ports:
- 5000:5000
- 5001:5001

42
docker/nginx/nginx.conf Normal file
View File

@ -0,0 +1,42 @@
user nginx;
worker_processes 4;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
client_max_body_size 200G;
autoindex on;
server {
root /var/www/html/public;
listen 80 default_server;
listen 443 ssl default_server;
ssl_certificate /ssl/ssl-cert-snakeoil.pem;
ssl_certificate_key /ssl/ssl-cert-snakeoil.key;
location / {
proxy_connect_timeout 60;
proxy_read_timeout 60;
proxy_send_timeout 60;
proxy_intercept_errors on;
proxy_http_version 1.1;
proxy_pass http://localhost:5000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@ -62,10 +62,20 @@ docker compose up --detach --pull=always
#### Install
- Install studio by executing following script
##### Linux
```bash
curl -fsSL https://raw.githubusercontent.com/jr-k/obscreen/master/system/install-studio.sh -o /tmp/install-studio.sh && chmod +x /tmp/install-studio.sh && sudo /bin/bash /tmp/install-studio.sh $USER $HOME
sudo reboot
```
##### Windows & MacOS
```bash
git clone https://github.com/jr-k/obscreen.git
cd obscreen
python3 -m venv venv
source ./venv/bin/activate
pip install .
cp .env.dist .env
```
#### Configure
- Server configuration is editable in `.env` file.
@ -110,7 +120,23 @@ When you run the browser yourself, don't forget to use these flags for chromium
```bash
# chromium or chromium-browser or even chrome
# replace http://localhost:5000 with your obscreen-studio instance url
chromium --disable-features=Translate --ignore-certificate-errors --disable-web-security --disable-restore-session-state --autoplay-policy=no-user-gesture-required --start-maximized --allow-running-insecure-content --remember-cert-error-decisions --noerrdialogs --kiosk --incognito --window-position=0,0 --window-size=1920,1080 --display=:0 http://localhost:5000
chromium \
--disk-cache-size=2147483648 \
--disable-features=Translate \
--ignore-certificate-errors \
--disable-web-security \
--disable-restore-session-state \
--autoplay-policy=no-user-gesture-required \
--start-maximized \
--allow-running-insecure-content \
--remember-cert-error-decisions \
--noerrdialogs \
--kiosk \
--incognito \
--window-position=0,0 \
--window-size=1920,1080 \
--display=:0 \
http://localhost:5000
```
---

View File

@ -1,6 +1,6 @@
# <img src="https://github.com/jr-k/obscreen/blob/master/docs/img/obscreen.png" width="22"> Obscreen - Autorun on RaspberryPi
# <img src="https://raw.githubusercontent.com/csmith1865/obscreen/refs/heads/master/docs/img/obscreen.png" width="22"> Obscreen - Autorun on RaspberryPi
> #### 👈 [back to readme](/README.md)
> #### 👈 [back to readme](../README.md)
#### 🔴 You want to power RaspberryPi and automatically see your slideshow on a screen connected to it and manage your slideshow ? You're in the right place.
@ -20,10 +20,20 @@
#### Install
- Install studio by executing following script
##### Linux
```bash
curl -fsSL https://raw.githubusercontent.com/jr-k/obscreen/master/system/install-studio.sh -o /tmp/install-studio.sh && chmod +x /tmp/install-studio.sh && sudo /bin/bash /tmp/install-studio.sh $USER $HOME
curl -fsSL https://raw.githubusercontent.com/csmith1865/obscreen/master/system/install-studio.sh -o /tmp/install-studio.sh && chmod +x /tmp/install-studio.sh && sudo /bin/bash /tmp/install-studio.sh $USER $HOME
sudo reboot
```
##### Windows & MacOS
```bash
git clone https://github.com/csmith1865/obscreen.git
cd obscreen
python3 -m venv venv
source ./venv/bin/activate
pip install .
cp .env.dist .env
```
#### Configure
- Server configuration is editable in `.env` file.
@ -72,7 +82,7 @@ docker run --restart=always --name obscreen --pull=always \
cd ~ && mkdir -p obscreen/data/db obscreen/data/uploads && cd obscreen
# Download docker-compose.yml
curl https://raw.githubusercontent.com/jr-k/obscreen/master/docker-compose.yml > docker-compose.yml
curl https://raw.githubusercontent.com/csmith1865/obscreen/master/docker-compose.yml > docker-compose.yml
# Run
docker compose up --detach --pull=always
@ -96,7 +106,7 @@ docker compose up --detach --pull=always
#### How to install
- Install player autorun by executing following script (will install chromium, x11, pulseaudio and obscreen-player systemd service)
```bash
curl -fsSL https://raw.githubusercontent.com/jr-k/obscreen/master/system/install-player-rpi.sh -o /tmp/install-player-rpi.sh && chmod +x /tmp/install-player-rpi.sh && sudo /bin/bash /tmp/install-player-rpi.sh $USER $HOME
curl -fsSL https://raw.githubusercontent.com/csmith1865/obscreen/master/system/install-player-rpi.sh -o /tmp/install-player-rpi.sh && chmod +x /tmp/install-player-rpi.sh && sudo /bin/bash /tmp/install-player-rpi.sh $USER $HOME
sudo reboot
```
@ -104,7 +114,7 @@ sudo reboot
1. Just use systemctl `sudo systemctl restart obscreen-player.service`
#### How to enable sound
1. First you have to reboot your device with `sudo reboot`
1. First you have to reboot your device if you never did after obscreen player installation; with command `sudo reboot`
2. You have to set audio channel to HDMI `sudo raspi-config nonint do_audio 1` (0 is for jack 3.5 output)
---
@ -118,7 +128,23 @@ When you run the browser yourself, don't forget to use these flags for chromium
```bash
# chromium or chromium-browser or even chrome
# replace http://localhost:5000 with your obscreen-studio instance url
chromium --disable-features=Translate --ignore-certificate-errors --disable-web-security --disable-restore-session-state --autoplay-policy=no-user-gesture-required --start-maximized --allow-running-insecure-content --remember-cert-error-decisions --noerrdialogs --kiosk --incognito --window-position=0,0 --window-size=1920,1080 --display=:0 http://localhost:5000
chromium \
--disk-cache-size=2147483648 \
--disable-features=Translate \
--ignore-certificate-errors \
--disable-web-security \
--disable-restore-session-state \
--autoplay-policy=no-user-gesture-required \
--start-maximized \
--allow-running-insecure-content \
--remember-cert-error-decisions \
--noerrdialogs \
--kiosk \
--incognito \
--window-position=0,0 \
--window-size=1920,1080 \
--display=:0 \
http://localhost:5000
```
---

View File

@ -59,7 +59,7 @@
"js_slideshow_slide_delete_confirmation": "Are you sure?",
"slideshow_content_page_title": "Content Library",
"slideshow_content_button_add": "New Content",
"slideshow_content_referenced_in_slide_error": "Content is referenced in a slide, remove slide first",
"slideshow_content_referenced_in_slide_error": "Content '%contentName%' is referenced in a slide, remove slide first",
"slideshow_content_panel_active": "Content",
"slideshow_content_panel_empty": "Currently, there are no content. %link% now.",
"slideshow_content_panel_th_name": "Name",
@ -105,6 +105,7 @@
"js_playlist_delete_confirmation": "Are you sure?",
"playlist_delete_has_slides": "Playlist has slides, please remove them before and retry",
"playlist_delete_has_node_player_groups": "Playlist is linked to a playgroup",
"playlist_cast_warning": "Your <a href=\"%href%\" target=\"_blank\">external URL</a> must be served over https for this to work",
"fleet_node_player_page_title": "Players",
"fleet_node_player_button_add": "Add a player",
"fleet_node_player_panel_active": "Active players",
@ -183,12 +184,12 @@
"settings_variable_desc_auth_enabled": "Enable auth management",
"settings_variable_desc_edition_auth_enabled": "Default user credentials will be %username%/%password%",
"settings_variable_desc_external_url": "External url (i.e: https://studio-01.company.com or http://10.10.3.100)",
"settings_variable_desc_external_storage_url": "External url for external storage (i.e: https://studio-01.company.com or http://10.10.3.100)",
"settings_variable_desc_slide_upload_limit": "Slide upload limit (in megabytes)",
"settings_variable_desc_dark_mode": "Dark mode",
"settings_variable_desc_intro_slide_duration": "Introduction slide duration (in seconds)",
"settings_variable_desc_default_slide_time_with_seconds": "Show the seconds on the clock in the introduction slide",
"settings_variable_desc_polling_interval": "Refresh interval applied for settings to the player (in seconds)",
"settings_variable_desc_player_content_cache": "Enable cache",
"settings_variable_desc_slide_animation_enabled": "Enable animation effect between slides",
"settings_variable_desc_slide_animation_entrance_effect": "Slide animation entrance effect",
"settings_variable_desc_slide_animation_exit_effect": "Slide animation exit effect (generally better off without it)",
@ -236,6 +237,7 @@
"common_pick_element": "Pick an element",
"common_untitled": "<untitled>",
"common_loading": "Loading...",
"common_casting": "Casting...",
"common_default_node_player_group": "Default Playgroup",
"common_default_playlist": "Default Playlist",
"common_unknown_ipaddr": "Unknown IP address",
@ -253,12 +255,27 @@
"common_apply": "Apply",
"common_saved": "Changes have been saved",
"common_new_folder": "New Folder",
"common_folder_not_empty_error": "Folder isn't empty, you must delete its content first",
"common_folder_not_empty_error": "Folder '%folderName%' isn't empty, you must delete its content first",
"common_copied": "Element copied in clipboard!",
"common_host_placeholder": "raspberrypi.local or 192.168.1.85",
"common_reachable_at": "Host",
"common_http_error_occured": "Error %code% occured",
"common_http_error_413": "Files are too large",
"common_width": "Width",
"common_height": "Height",
"common_position": "Position",
"common_angle": "Angle",
"common_size": "Dimensions",
"composition_elements_heading": "Elements",
"composition_element_add": "Add element",
"composition_elements_delete_all": "Delete all",
"composition_presets": "Presets",
"composition_presets_grid_2x2": "Grid 2x2",
"composition_presets_tvnews_1x1": "TV news 1x1",
"composition_monitor": "Screen",
"composition_element_x_axis": "X axis",
"composition_element_y_axis": "Y axis",
"composition_element_match_content_aspect_ratio": "Match content aspect ratio",
"logout": "Logout",
"login_error_not_found": "Bad credentials",
"login_error_bad_credentials": "Bad credentials",
@ -290,6 +307,10 @@
"enum_content_type_external_storage": "External Storage",
"enum_content_type_external_storage_object_label": "Specify an existing directory relative to the following path",
"enum_content_type_external_storage_flashdrive_label": "Path relative to a removeable device",
"enum_content_type_composition": "Composition",
"enum_content_type_composition_object_label": "Screen aspect ratio",
"enum_content_type_text": "Text",
"enum_content_type_text_object_label": "Displayed text",
"enum_content_type_url": "URL",
"enum_content_type_video": "Video",
"enum_content_type_picture": "Picture",
@ -308,8 +329,8 @@
"enum_operating_system_redhat": "RedHat",
"enum_operating_system_centos": "CentOS",
"enum_operating_system_other": "Other",
"sysinfo_rpi_model": "Raspberry Pi Model",
"sysinfo_rpi_model_unknown": "Not a Raspberry Pi or model information not available",
"sysinfo_device_model": "Device model",
"sysinfo_device_model_unknown": "Unknown model",
"sysinfo_storage_free_space": "Storage Free Space",
"sysinfo_memory_usage": "Memory Usage",
"sysinfo_os_version": "OS Version",

View File

@ -59,7 +59,7 @@
"js_slideshow_slide_delete_confirmation": "¿Estás seguro?",
"slideshow_content_page_title": "Biblioteca de contenidos",
"slideshow_content_button_add": "Nuevo Contenido",
"slideshow_content_referenced_in_slide_error": "Se hace referencia al contenido en una diapositiva; elimine la diapositiva primero",
"slideshow_content_referenced_in_slide_error": "Se hace referencia al contenido '%contentName%' en una diapositiva; elimine la diapositiva primero",
"slideshow_content_panel_active": "Contenido",
"slideshow_content_panel_empty": "Actualmente, no hay contenido. %link% ahora.",
"slideshow_content_panel_th_name": "Nombre",
@ -105,6 +105,7 @@
"js_playlist_delete_confirmation": "¿Estás seguro?",
"playlist_delete_has_slides": "La playlist tiene diapositivas, por favor elimínelas antes y reintente",
"playlist_delete_has_node_player_groups": "La playlist está asignada a un playgroup",
"playlist_cast_warning": "Tu <a href=\"%href%\" target=\"_blank\">URL externa</a> debe ser entregada en https para que esto funcione",
"fleet_node_player_page_title": "Reproductores",
"fleet_node_player_button_add": "Agregar un reproductor",
"fleet_node_player_panel_active": "Reproductores activos",
@ -184,12 +185,12 @@
"settings_variable_desc_auth_enabled": "Habilitar gestión de autenticación",
"settings_variable_desc_edition_auth_enabled": "Las credenciales predeterminadas del usuario serán %username%/%password%",
"settings_variable_desc_external_url": "URL externa (ej.: https://studio-01.company.com o http://10.10.3.100)",
"settings_variable_desc_external_storage_url": "URL externa para almacenamiento externo(ej.: https://studio-01.company.com o http://10.10.3.100)",
"settings_variable_desc_slide_upload_limit": "Límite de carga de diapositivas (en megabytes)",
"settings_variable_desc_dark_mode": "Modo oscuro",
"settings_variable_desc_intro_slide_duration": "Duración de la diapositiva de introducción (en segundos)",
"settings_variable_desc_default_slide_time_with_seconds": "Mostrar los segundos en el reloj de la diapositiva de introducción",
"settings_variable_desc_polling_interval": "Intervalo de actualización aplicado para configuraciones del reproductor (en segundos)",
"settings_variable_desc_player_content_cache": "Habilitar la caché",
"settings_variable_desc_slide_animation_enabled": "Habilitar efecto de animación entre diapositivas",
"settings_variable_desc_slide_animation_entrance_effect": "Efecto de entrada de animación de diapositiva",
"settings_variable_desc_slide_animation_exit_effect": "Efecto de salida de animación de diapositiva (generalmente mejor sin él)",
@ -237,6 +238,7 @@
"common_pick_element": "Elige un elemento",
"common_untitled": "<sin-título>",
"common_loading": "Cargando...",
"common_casting": "Casting...",
"common_default_node_player_group": "Playgroup predeterminado",
"common_default_playlist": "Lista de reproducción predeterminada",
"common_unknown_ipaddr": "Dirección IP desconocida",
@ -254,12 +256,27 @@
"common_apply": "Aplicar",
"common_saved": "Los cambios se han guardado",
"common_new_folder": "Nuevo Carpeta",
"common_folder_not_empty_error": "La carpeta no está vacía, primero debes eliminar su contenido",
"common_folder_not_empty_error": "La carpeta '%folderName%' no está vacía, primero debes eliminar su contenido",
"common_copied": "¡Elemento copiado!",
"common_host_placeholder": "raspberrypi.local o 192.168.1.85",
"common_reachable_at": "Host",
"common_http_error_occured": "Se ha producido un error %code%",
"common_http_error_413": "Los archivos son demasiado grandes",
"common_width": "Ancho",
"common_height": "Altura",
"common_position": "Posición",
"common_angle": "Ángulo",
"common_size": "Dimensiones",
"composition_elements_heading": "Elementos",
"composition_element_add": "Añadir elemento",
"composition_elements_delete_all": "Eliminar todo",
"composition_presets": "Preajustes",
"composition_presets_grid_2x2": "Cuadrícula 2x2",
"composition_presets_tvnews_1x1": "TV news 1x1",
"composition_monitor": "Pantalla",
"composition_element_x_axis": "Eje X",
"composition_element_y_axis": "Eje Y",
"composition_element_match_content_aspect_ratio": "Ajustar la escala del contenido",
"logout": "Cerrar sesión",
"login_error_not_found": "Credenciales incorrectas",
"login_error_bad_credentials": "Credenciales incorrectas",
@ -291,6 +308,10 @@
"enum_content_type_external_storage": "Almacenamiento externo",
"enum_content_type_external_storage_object_label": "Especifique un directorio existente relativo a la siguiente ruta",
"enum_content_type_external_storage_flashdrive_label": "Ruta relativa a un dispositivo extraíble",
"enum_content_type_composition": "Composición",
"enum_content_type_composition_object_label": "Relación de aspecto de la pantalla",
"enum_content_type_text": "Texto",
"enum_content_type_text_object_label": "Texto mostrado",
"enum_content_type_url": "URL",
"enum_content_type_video": "Video",
"enum_content_type_picture": "Imagen",
@ -309,8 +330,8 @@
"enum_operating_system_redhat": "RedHat",
"enum_operating_system_centos": "CentOS",
"enum_operating_system_other": "Otro",
"sysinfo_rpi_model": "Modelo de Raspberry Pi",
"sysinfo_rpi_model_unknown": "No es una Raspberry Pi o la información del modelo no está disponible",
"sysinfo_device_model": "Modelo del dispositivo",
"sysinfo_device_model_unknown": "Modelo desconocido",
"sysinfo_storage_free_space": "Espacio de almacenamiento libre",
"sysinfo_memory_usage": "Uso de memoria",
"sysinfo_os_version": "Versión del SO",

View File

@ -59,7 +59,7 @@
"js_slideshow_slide_delete_confirmation": "Êtes-vous sûr ?",
"slideshow_content_page_title": "Bibliothèque de contenus",
"slideshow_content_button_add": "Nouveau Contenu",
"slideshow_content_referenced_in_slide_error": "Le contenu est référencé dans une slide, supprimez d'abord la slide",
"slideshow_content_referenced_in_slide_error": "Le contenu '%contentName%' est référencé dans une slide, supprimez d'abord la slide",
"slideshow_content_panel_active": "Contenus",
"slideshow_content_panel_empty": "Actuellement, il n'y a aucun contenu. %link% maintenant.",
"slideshow_content_panel_th_name": "Nom",
@ -106,6 +106,7 @@
"js_playlist_delete_confirmation": "Êtes-vous sûr ?",
"playlist_delete_has_slides": "La playlist contient des slides, supprimez-les avant et réessayez",
"playlist_delete_has_node_player_groups": "La playlist est attribuée à un playgroup",
"playlist_cast_warning": "Votre <a href=\"%href%\" target=\"_blank\">URL externe</a> doit être servi en https pour que ça fonctionne",
"fleet_node_player_page_title": "Lecteurs",
"fleet_node_player_button_add": "Ajouter un lecteur",
"fleet_node_player_panel_active": "Players actifs",
@ -185,12 +186,12 @@
"settings_variable_desc_auth_enabled": "Activer la gestion de l'authentification",
"settings_variable_desc_edition_auth_enabled": "Les identifiants de l'utilisateur par défaut seront %username%/%password%",
"settings_variable_desc_external_url": "URL externe (i.e: https://studio-01.company.com or http://10.10.3.100)",
"settings_variable_desc_external_storage_url": "URL externe pour le stockage externe (i.e: https://studio-01.company.com or http://10.10.3.100)",
"settings_variable_desc_slide_upload_limit": "Limite d'upload du fichier d'une slide (en mégaoctets)",
"settings_variable_desc_dark_mode": "Mdoe sombre",
"settings_variable_desc_intro_slide_duration": "Durée de la slide d'introduction (en secondes)",
"settings_variable_desc_default_slide_time_with_seconds": "Afficher les secondes de l'horloge de la slide d'introduction",
"settings_variable_desc_polling_interval": "Intervalle de rafraîchissement des paramètres à appliquer au lecteur (en secondes)",
"settings_variable_desc_player_content_cache": "Activer le cache",
"settings_variable_desc_slide_animation_enabled": "Activer les effets d'animation entre les slides",
"settings_variable_desc_slide_animation_entrance_effect": "Effet d'animation d'arrivée de la slide",
"settings_variable_desc_slide_animation_exit_effect": "Effet d'animation de sortie de la slide (généralement mieux sans)",
@ -238,6 +239,7 @@
"common_pick_element": "Choisissez un élément",
"common_untitled": "<sans-titre>",
"common_loading": "Chargement...",
"common_casting": "Casting...",
"common_default_node_player_group": "Playgroup par défaut",
"common_default_playlist": "Playlist par défaut",
"common_unknown_ipaddr": "Adresse IP inconnue",
@ -255,12 +257,27 @@
"common_apply": "Appliquer",
"common_saved": "Les modifications ont été enregistrées",
"common_new_folder": "Nouveau Dossier",
"common_folder_not_empty_error": "Le dossier n'est pas vide, vous devez d'abord supprimer son contenu",
"common_folder_not_empty_error": "Le dossier '%folderName%' n'est pas vide, vous devez d'abord supprimer son contenu",
"common_copied": "Element copié !",
"common_host_placeholder": "raspberrypi.local ou 192.168.1.85",
"common_reachable_at": "Hôte",
"common_http_error_occured": "Une erreur %code% est apparue",
"common_http_error_413": "Les fichiers sont trop volumineux",
"common_width": "Largeur",
"common_height": "Hauteur",
"common_position": "Position",
"common_angle": "Angle",
"common_size": "Dimensions",
"composition_elements_heading": "Éléments",
"composition_element_add": "Ajouter un élément",
"composition_elements_delete_all": "Tout supprimer",
"composition_presets": "Préréglages",
"composition_presets_grid_2x2": "Grille 2x2",
"composition_presets_tvnews_1x1": "TV news 1x1",
"composition_monitor": "Écran",
"composition_element_x_axis": "Axe X",
"composition_element_y_axis": "Axe Y",
"composition_element_match_content_aspect_ratio": "Ajuster l'échelle du contenu",
"logout": "Déconnexion",
"login_error_not_found": "Identifiants invalides",
"login_error_bad_credentials": "Identifiants invalides",
@ -292,6 +309,10 @@
"enum_content_type_external_storage": "Stockage externe",
"enum_content_type_external_storage_object_label": "Spécifiez un répertoire existant par rapport au chemin suivant",
"enum_content_type_external_storage_flashdrive_label": "Chemin relatif à un périphérique amovible",
"enum_content_type_composition": "Composition",
"enum_content_type_composition_object_label": "Rapport hauteur/largeur de l'écran",
"enum_content_type_text": "Texte",
"enum_content_type_text_object_label": "Texte affiché",
"enum_content_type_url": "URL",
"enum_content_type_video": "Vidéo",
"enum_content_type_picture": "Image",
@ -310,8 +331,8 @@
"enum_operating_system_redhat": "RedHat",
"enum_operating_system_centos": "CentOS",
"enum_operating_system_other": "Autre",
"sysinfo_rpi_model": "Modèle du Raspberry Pi",
"sysinfo_rpi_model_unknown": "Le modèle n'est pas un Raspberry Pi",
"sysinfo_device_model": "Modèle de l'appareil",
"sysinfo_device_model_unknown": "Modèle inconnu",
"sysinfo_storage_free_space": "Stockage Disponible",
"sysinfo_memory_usage": "Utilisation Mémoire",
"sysinfo_os_version": "Version SE",

View File

@ -59,7 +59,7 @@
"js_slideshow_slide_delete_confirmation": "Sei sicuro?",
"slideshow_content_page_title": "Libreria dei contenuti",
"slideshow_content_button_add": "Nuovo Contenuto",
"slideshow_content_referenced_in_slide_error": "Si fa riferimento al contenuto in una diapositiva, rimuovere prima la diapositiva",
"slideshow_content_referenced_in_slide_error": "Si fa riferimento al contenuto '%contentName%' in una diapositiva, rimuovere prima la diapositiva",
"slideshow_content_panel_active": "Contenuti",
"slideshow_content_panel_empty": "Attualmente non ci sono contenuti. %link% adesso.",
"slideshow_content_panel_th_name": "Nome",
@ -105,6 +105,7 @@
"js_playlist_delete_confirmation": "Sei sicuro?",
"playlist_delete_has_slides": "Sono presenti slide nella playlist, annullale e riprova",
"playlist_delete_has_node_player_groups": "La playlist è collegata ad un playgroup",
"playlist_cast_warning": "Il tuo <a href=\"%href%\" target=\"_blank\">URL esterno</a> deve essere servito in https affinché funzioni",
"fleet_node_player_page_title": "Schermi",
"fleet_node_player_button_add": "Aggiungi allo schermo",
"fleet_node_player_panel_active": "Schermi attivi",
@ -184,12 +185,12 @@
"settings_variable_desc_auth_enabled": "Abilita la gestione autenticazione",
"settings_variable_desc_edition_auth_enabled": "Le credenziali utente predefinite sono %username%/%password%",
"settings_variable_desc_external_url": "Url esterno (esempio: https://studio-01.company.com or http://10.10.3.100)",
"settings_variable_desc_external_storage_url": "Url esterno per l'archiviazione esterna (esempio: https://studio-01.company.com or http://10.10.3.100)",
"settings_variable_desc_slide_upload_limit": "Limite upload slide (in megabytes)",
"settings_variable_desc_dark_mode": "Modalità scura",
"settings_variable_desc_intro_slide_duration": "Durata introduzione slide (in secondi)",
"settings_variable_desc_default_slide_time_with_seconds": "Mostra secondi introduzione slide",
"settings_variable_desc_polling_interval": "Intervallo di aggiornamento applicato per le impostazioni del monitor (in secondi)",
"settings_variable_desc_player_content_cache": "Abilita la cache",
"settings_variable_desc_slide_animation_enabled": "Abilita l'effetto di animazione tra le diapositive",
"settings_variable_desc_slide_animation_entrance_effect": "Effetto ingresso diapositiva",
"settings_variable_desc_slide_animation_exit_effect": "Effetto di uscita della diapositiva (meglio senza)",
@ -237,6 +238,7 @@
"common_pick_element": "Scegli un elemento",
"common_untitled": "<senza-titolo>",
"common_loading": "Caricamento...",
"common_casting": "Casting...",
"common_default_node_player_group": "Playgroup di default",
"common_default_playlist": "Default playlist",
"common_unknown_ipaddr": "IP sconosciuto",
@ -254,12 +256,27 @@
"common_apply": "Applica",
"common_saved": "Le modifiche sono state salvate",
"common_new_folder": "Nuovo Cartella",
"common_folder_not_empty_error": "La cartella non è vuota, devi prima eliminarne il contenuto",
"common_folder_not_empty_error": "La cartella '%folderName%' non è vuota, devi prima eliminarne il contenuto",
"common_copied": "Elemento copiato!",
"common_host_placeholder": "raspberrypi.local o 192.168.1.85",
"common_reachable_at": "Host",
"common_http_error_occured": "Si è verificato un errore %code%",
"common_http_error_413": "I file sono troppo grandi",
"common_width": "Larghezza",
"common_height": "Altezza",
"common_position": "Posizione",
"common_angle": "Angolo",
"common_size": "Dimensioni",
"composition_elements_heading": "Elementi",
"composition_element_add": "Aggiungi elemento",
"composition_elements_delete_all": "Elimina tutto",
"composition_presets": "Preimpostazioni",
"composition_presets_grid_2x2": "Griglia 2x2",
"composition_presets_tvnews_1x1": "TV news 1x1",
"composition_monitor": "Schermo",
"composition_element_x_axis": "Asse X",
"composition_element_y_axis": "Asse Y",
"composition_element_match_content_aspect_ratio": "Regola la scala del contenuto",
"logout": "Logout",
"login_error_not_found": "Credenziali errate",
"login_error_bad_credentials": "Credenziali errate",
@ -291,6 +308,10 @@
"enum_content_type_external_storage": "Archiviazione esterna",
"enum_content_type_external_storage_object_label": "Specificare una directory esistente relativi al seguente percorso",
"enum_content_type_external_storage_flashdrive_label": "Percorso relativo ad un dispositivo rimovibile",
"enum_content_type_composition": "Composizione",
"enum_content_type_composition_object_label": "Rapporto di aspetto dello schermo",
"enum_content_type_text": "Testo",
"enum_content_type_text_object_label": "Testo visualizzato",
"enum_content_type_url": "URL",
"enum_content_type_video": "Video",
"enum_content_type_picture": "Immagine",
@ -309,8 +330,8 @@
"enum_operating_system_redhat": "RedHat",
"enum_operating_system_centos": "CentOS",
"enum_operating_system_other": "Altro",
"sysinfo_rpi_model": "Raspberry Pi Model",
"sysinfo_rpi_model_unknown": "Informazioni Raspberry Pi non disponibili",
"sysinfo_device_model": "Modello del dispositivo",
"sysinfo_device_model_unknown": "Modello sconosciuto",
"sysinfo_storage_free_space": "Spazio libero",
"sysinfo_memory_usage": "Memoria usata",
"sysinfo_os_version": "OS Version",

View File

@ -0,0 +1,30 @@
from src.interface.ObPlugin import ObPlugin
from typing import List, Dict
from src.model.entity.Variable import Variable
from src.model.enum.HookType import HookType
from src.model.hook.HookRegistration import HookRegistration
class CoreApi(ObPlugin):
def get_version(self) -> str:
return '1.0'
def use_id(self):
return 'core_api'
def use_title(self):
return self.translate('plugin_title')
def use_description(self):
return self.translate('plugin_description')
def use_help_on_activation(self):
return self.translate('plugin_help_on_activation')
def use_variables(self) -> List[Variable]:
return []
def use_hooks_registrations(self) -> List[HookRegistration]:
return []

View File

@ -0,0 +1,375 @@
import os
import time
import logging
from flask import request, abort, jsonify
from flask_restx import Resource, Namespace, fields, reqparse
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
from src.model.entity.Content import Content
from src.manager.FolderManager import FolderManager
from src.model.enum.ContentType import ContentType, ContentInputType
from src.model.enum.FolderEntity import FolderEntity, FOLDER_ROOT_PATH
from src.interface.ObController import ObController
from src.util.utils import str_to_enum
from plugins.system.CoreApi.exception.ContentPathMissingException import ContentPathMissingException
from plugins.system.CoreApi.exception.ContentNotFoundException import ContentNotFoundException
from plugins.system.CoreApi.exception.FolderNotFoundException import FolderNotFoundException
from plugins.system.CoreApi.exception.FolderNotEmptyException import FolderNotEmptyException
from src.service.WebServer import create_require_api_key_decorator
# Namespace for content operations
content_ns = Namespace('contents', description='Operations on contents')
# Output model for content
content_output_model = content_ns.model('ContentOutput', {
'id': fields.Integer(readOnly=True, description='Unique identifier of the content'),
'name': fields.String(description='Name of the content'),
'type': fields.String(description='Type of the content'),
'location': fields.String(description='Location of the content'),
'folder_id': fields.Integer(description='Folder ID where the content is stored')
})
# Model for folder operations
folder_model = content_ns.model('Folder', {
'name': fields.String(required=True, description='Name of the folder'),
'path': fields.String(required=False, description='Path context (with path starting with /)'),
'folder_id': fields.Integer(required=False, description='Path context (with folder id)')
})
# Parser for bulk move operations
bulk_move_parser = content_ns.parser()
bulk_move_parser.add_argument('entity_ids', type=int, action='append', required=True, help='List of content IDs to move')
bulk_move_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
bulk_move_parser.add_argument('folder_id', type=int, required=False, help='Path context (with folder id)')
# Parser for content add/upload (single file)
content_upload_parser = content_ns.parser()
content_upload_parser.add_argument('name', type=str, required=True, help='Name of the content')
content_upload_parser.add_argument('type', type=str, required=True, help='Type of the content')
content_upload_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
content_upload_parser.add_argument('folder_id', type=str, required=False, help='Path context (with folder id)')
content_upload_parser.add_argument('location', type=str, required=False, help="Content location (valid for types: {}, {} and {})".format(
ContentType.URL.value,
ContentType.YOUTUBE.value,
ContentType.EXTERNAL_STORAGE.value
))
content_upload_parser.add_argument('object', type=FileStorage, location='files', required=False, help="Content location (valid for types: {} and {})".format(
ContentType.PICTURE.value,
ContentType.VIDEO.value
))
# Parser for content add/bulk uploads (multiple files)
bulk_upload_parser = content_ns.parser()
bulk_upload_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
bulk_upload_parser.add_argument('folder_id', type=str, required=False, help='Path context (with folder id)')
bulk_upload_parser.add_argument('object', type=FileStorage, location='files', action='append', required=True, help='Files to be uploaded')
# Parser for content edit
content_edit_parser = content_ns.parser()
content_edit_parser.add_argument('name', type=str, required=True, help='Name of the content')
# Parser for content path context actions
path_parser = content_ns.parser()
path_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
path_parser.add_argument('folder_id', type=str, required=False, help='Path context (with folder id)')
# Parser for folder add/edit
folder_parser = content_ns.parser()
folder_parser.add_argument('name', type=str, required=True, help='Name of the folder')
folder_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
folder_parser.add_argument('folder_id', type=str, required=False, help='Path context (with folder id)')
class ContentApiController(ObController):
def register(self):
self.api().add_namespace(content_ns, path='/api/contents')
content_ns.add_resource(self.create_resource(ContentListResource), '/')
content_ns.add_resource(self.create_resource(ContentResource), '/<int:content_id>')
content_ns.add_resource(self.create_resource(ContentLocationResource), '/location/<int:content_id>')
content_ns.add_resource(self.create_resource(ContentBulkUploadResource), '/upload-bulk')
content_ns.add_resource(self.create_resource(FolderBulkMoveResource), '/folder/move-bulk')
content_ns.add_resource(self.create_resource(FolderResource), '/folder')
def create_resource(self, resource_class):
# Function to inject dependencies into resources
return type(f'{resource_class.__name__}WithDependencies', (resource_class,), {
'_model_store': self._model_store,
'_controller': self,
'require_api_key': create_require_api_key_decorator(self._web_server)
})
def _get_folder_context(self, data):
path = data.get('path', None)
folder_id = data.get('folder_id', None)
if folder_id:
folder = self._model_store.folder().get(id=folder_id)
if not folder:
raise FolderNotFoundException()
return path, folder
if not path:
raise ContentPathMissingException()
path = "{}/{}".format(FOLDER_ROOT_PATH, path.strip('/')) if not path.startswith(FOLDER_ROOT_PATH) else path
folder = self._model_store.folder().get_one_by_path(path=path, entity=FolderEntity.CONTENT)
is_root_drive = FolderManager.is_root_drive(path)
if not folder and not is_root_drive:
raise FolderNotFoundException()
return FOLDER_ROOT_PATH if is_root_drive else path, folder
def _post_update(self):
self._model_store.variable().update_by_name("last_content_update", time.time())
class ContentListResource(Resource):
@content_ns.expect(path_parser)
@content_ns.marshal_list_with(content_output_model)
def get(self):
"""List all contents"""
self.require_api_key()
data = path_parser.parse_args()
working_folder_path = None
working_folder = None
folder_id = None
try:
working_folder_path, working_folder = self._controller._get_folder_context(data)
folder_id = data.get('folder_id', 0 if not working_folder else working_folder.id)
except FolderNotFoundException:
pass
except ContentPathMissingException:
pass
contents = self._model_store.content().get_contents(
folder_id=folder_id,
slide_id=data.get('slide_id', None),
)
result = [content.to_dict() for content in contents]
return result
@content_ns.expect(content_upload_parser)
@content_ns.marshal_with(content_output_model, code=201)
def post(self):
"""Add new content"""
self.require_api_key()
data = content_upload_parser.parse_args()
working_folder_path, working_folder = self._controller._get_folder_context(data)
location = data.get('location', None)
content_type = None
# Handle content type conversion
try:
content_type = str_to_enum(data.get('type'), ContentType)
except ValueError as e:
abort(400, description=str(e))
# Handle file upload
file = data.get('object', None)
if ContentType.get_input(content_type) == ContentInputType.UPLOAD:
if not file:
abort(400, description="File is required")
content = self._model_store.content().add_form_raw(
name=data.get('name'),
type=content_type,
request_files=file,
upload_dir=self._controller._app.config['UPLOAD_FOLDER'],
location=location,
folder_id=working_folder.id if working_folder else None
)
if not content:
abort(400, description="Failed to add content")
return content.to_dict(), 201
class ContentResource(Resource):
@content_ns.marshal_with(content_output_model)
def get(self, content_id: int):
"""Get content by ID"""
self.require_api_key()
content = self._model_store.content().get(content_id)
if not content:
raise ContentNotFoundException()
return content.to_dict()
@content_ns.expect(content_edit_parser)
@content_ns.marshal_with(content_output_model)
def put(self, content_id: int):
"""Update existing content"""
self.require_api_key()
data = content_edit_parser.parse_args()
content = self._model_store.content().get(content_id)
if not content:
raise ContentNotFoundException()
if 'name' not in data:
abort(400, description="Name is required")
content = self._model_store.content().update_form(
id=content.id,
name=data.get('name'),
)
self._controller._post_update()
return content.to_dict()
def delete(self, content_id: int):
"""Delete content"""
self.require_api_key()
content = self._model_store.content().get(content_id)
if not content:
raise ContentNotFoundException()
if self._model_store.slide().count_slides_for_content(content.id) > 0:
abort(400, description="Content is referenced in slides")
self._model_store.content().delete(content.id)
self._controller._post_update()
return {'status': 'ok'}, 204
class ContentLocationResource(Resource):
def get(self, content_id: int):
"""Get content location by ID"""
self.require_api_key()
content = self._model_store.content().get(content_id)
if not content:
raise ContentNotFoundException()
content_location = self._model_store.content().resolve_content_location(content)
return {'location': content_location}
class ContentBulkUploadResource(Resource):
@content_ns.expect(bulk_upload_parser)
def post(self):
"""Upload multiple content files"""
self.require_api_key()
data = bulk_upload_parser.parse_args()
working_folder_path, working_folder = self._controller._get_folder_context(data)
for file in data.get('object'):
content_type = ContentType.guess_content_type_file(file.filename)
name = file.filename.rsplit('.', 1)[0]
if content_type:
self._model_store.content().add_form_raw(
name=name,
type=content_type,
request_files=file,
upload_dir=self._controller._app.config['UPLOAD_FOLDER'],
folder_id=working_folder.id if working_folder else None
)
return {'status': 'ok'}, 201
class FolderBulkMoveResource(Resource):
@content_ns.expect(bulk_move_parser)
def post(self):
"""Move multiple content to another folder"""
self.require_api_key()
data = bulk_move_parser.parse_args()
working_folder_path, working_folder = self._controller._get_folder_context(data)
if 'entity_ids' not in data:
abort(400, description="Content IDs are required under 'entity_ids' field")
entity_ids = data.get('entity_ids')
for entity_id in entity_ids:
self._model_store.folder().move_to_folder(
entity_id=entity_id,
folder_id=working_folder.id if working_folder else None,
entity_is_folder=False,
entity=FolderEntity.CONTENT
)
return {'status': 'ok'}
class FolderResource(Resource):
@content_ns.expect(folder_parser)
@content_ns.marshal_with(folder_model, code=201)
def post(self):
"""Add a new folder"""
self.require_api_key()
data = folder_parser.parse_args()
working_folder_path, working_folder = self._controller._get_folder_context(data)
if 'name' not in data:
abort(400, description="Name is required")
folder = self._model_store.folder().add_folder(
entity=FolderEntity.CONTENT,
name=data.get('name'),
working_folder_path=working_folder_path
)
return folder.to_dict(), 201
@content_ns.expect(path_parser)
def delete(self):
"""Delete a folder"""
self.require_api_key()
data = path_parser.parse_args()
working_folder_path, working_folder = self._controller._get_folder_context(data)
if not working_folder:
abort(400, description="You can't delete this folder")
content_counter = self._model_store.content().count_contents_for_folder(working_folder.id)
folder_counter = self._model_store.folder().count_subfolders_for_folder(working_folder.id)
if content_counter > 0 or folder_counter:
raise FolderNotEmptyException()
self._model_store.folder().delete(id=working_folder.id)
self._controller._post_update()
return {'status': 'ok'}, 204
@content_ns.expect(folder_parser)
def put(self):
"""Update a folder"""
self.require_api_key()
data = folder_parser.parse_args()
working_folder_path, working_folder = self._controller._get_folder_context(data)
if 'name' not in data:
abort(400, description="Name is required")
if not working_folder:
abort(400, description="You can't update this folder")
self._model_store.folder().rename_folder(
folder_id=working_folder.id,
name=data.get('name')
)
return {'status': 'ok'}

View File

@ -0,0 +1,161 @@
from flask import request, abort, jsonify
from flask_restx import Resource, Namespace, fields
from src.model.entity.Playlist import Playlist
from src.interface.ObController import ObController
from src.util.utils import str_to_bool
from src.service.WebServer import create_require_api_key_decorator
# Namespace for playlists operations
playlist_ns = Namespace('playlists', description='Operations on playlist')
# Output model for a playlist
playlist_output_model = playlist_ns.model('PlaylistOutput', {
'id': fields.Integer(readOnly=True, description='The unique identifier of a playlist'),
'name': fields.String(required=True, description='The playlist name'),
'enabled': fields.Boolean(description='Is the playlist enabled?'),
'time_sync': fields.Boolean(description='Is time synchronization enabled?')
})
# Parser for playlist attributes (add)
playlist_parser = playlist_ns.parser()
playlist_parser.add_argument('name', type=str, required=True, help='The playlist name')
playlist_parser.add_argument('enabled', type=str_to_bool, default=None, help='Is the playlist enabled?')
playlist_parser.add_argument('time_sync', type=str_to_bool, default=None, help='Is time synchronization enabled for slideshow?')
# Parser for playlist attributes (update)
playlist_edit_parser = playlist_parser.copy()
playlist_edit_parser.replace_argument('name', type=str, required=False, help='The playlist name')
class PlaylistApiController(ObController):
def register(self):
self.api().add_namespace(playlist_ns, path='/api/playlists')
playlist_ns.add_resource(self.create_resource(PlaylistResource), '/<int:playlist_id>')
playlist_ns.add_resource(self.create_resource(PlaylistListResource), '/')
playlist_ns.add_resource(self.create_resource(PlaylistSlidesResource), '/<int:playlist_id>/slides')
playlist_ns.add_resource(self.create_resource(PlaylistNotificationsResource), '/<int:playlist_id>/notifications')
def create_resource(self, resource_class):
# Function to inject dependencies into resources
return type(f'{resource_class.__name__}WithDependencies', (resource_class,), {
'_model_store': self._model_store,
'_controller': self,
'require_api_key': create_require_api_key_decorator(self._web_server)
})
class PlaylistListResource(Resource):
@playlist_ns.marshal_list_with(playlist_output_model)
def get(self):
"""List all playlists"""
self.require_api_key()
playlists = self._model_store.playlist().get_all(sort="created_at", ascending=True)
result = [playlist.to_dict() for playlist in playlists]
return result
@playlist_ns.expect(playlist_parser)
@playlist_ns.marshal_with(playlist_output_model, code=201)
def post(self):
"""Create a new playlist"""
self.require_api_key()
data = playlist_parser.parse_args()
if not data.get('name'):
abort(400, description="Invalid input")
playlist = Playlist(
name=data.get('name'),
enabled=data.get('enabled') if data.get('enabled') is not None else True,
time_sync=data.get('time_sync') if data.get('time_sync') is not None else False,
)
try:
playlist = self._model_store.playlist().add_form(playlist)
except Exception as e:
abort(409, description=str(e))
return playlist.to_dict(), 201
class PlaylistResource(Resource):
@playlist_ns.marshal_with(playlist_output_model)
def get(self, playlist_id):
"""Get a playlist by its ID"""
self.require_api_key()
playlist = self._model_store.playlist().get(playlist_id)
if not playlist:
abort(404, description="Playlist not found")
return playlist.to_dict()
@playlist_ns.expect(playlist_edit_parser)
@playlist_ns.marshal_with(playlist_output_model)
def put(self, playlist_id):
"""Update an existing playlist"""
self.require_api_key()
data = playlist_edit_parser.parse_args()
playlist = self._model_store.playlist().get(playlist_id)
if not playlist:
abort(404, description="Playlist not found")
self._model_store.playlist().update_form(
id=playlist_id,
name=data.get('name', playlist.name),
time_sync=data.get('time_sync', playlist.time_sync),
enabled=data.get('enabled', playlist.enabled)
)
updated_playlist = self._model_store.playlist().get(playlist_id)
return updated_playlist.to_dict()
def delete(self, playlist_id):
"""Delete a playlist"""
self.require_api_key()
playlist = self._model_store.playlist().get(playlist_id)
if not playlist:
abort(404, description="Playlist not found")
if self._model_store.slide().count_slides_for_playlist(playlist_id) > 0:
abort(400, description="Playlist cannot be deleted because it has slides")
if self._model_store.node_player_group().count_node_player_groups_for_playlist(playlist_id) > 0:
abort(400, description="Playlist cannot be deleted because it is associated with node player groups")
self._model_store.playlist().delete(playlist_id)
return '', 204
class PlaylistSlidesResource(Resource):
def get(self, playlist_id):
"""Get slides associated with a playlist"""
self.require_api_key()
playlist = self._model_store.playlist().get(playlist_id)
if not playlist:
abort(404, description="Playlist not found")
slides = self._model_store.slide().get_slides(is_notification=False, playlist_id=playlist_id)
result = [slide.to_dict() for slide in slides]
return jsonify(result)
class PlaylistNotificationsResource(Resource):
def get(self, playlist_id):
"""Get notifications associated with a playlist"""
self.require_api_key()
playlist = self._model_store.playlist().get(playlist_id)
if not playlist:
abort(404, description="Playlist not found")
slides = self._model_store.slide().get_slides(is_notification=True, playlist_id=playlist_id)
result = [slide.to_dict() for slide in slides]
return jsonify(result)

View File

@ -0,0 +1,322 @@
import time
from flask import request, abort, jsonify
from flask_restx import Resource, Namespace, fields
from src.model.entity.Slide import Slide
from src.interface.ObController import ObController
from src.util.utils import str_datetime_to_cron, str_weekdaytime_to_cron, str_to_bool
from src.service.WebServer import create_require_api_key_decorator
# Namespace for slide operations
slide_ns = Namespace('slides', description='Operations on slides')
# Output model for a slide
slide_output_model = slide_ns.model('SlideOutput', {
'id': fields.Integer(readOnly=True, description='The unique identifier of a slide'),
'content_id': fields.Integer(description='The content ID for the slide'),
'playlist_id': fields.Integer(description='The playlist ID to which the slide belongs'),
'enabled': fields.Boolean(description='Is the slide enabled?'),
'delegate_duration': fields.Boolean(description='Should the duration be delegated?'),
'duration': fields.Integer(description='Duration of the slide'),
'position': fields.Integer(description='Position of the slide'),
'is_notification': fields.Boolean(description='Is the slide a notification?'),
'cron_schedule': fields.String(description='Cron expression for scheduling start'),
'cron_schedule_end': fields.String(description='Cron expression for scheduling end'),
})
# Input model for updating slide positions
positions_model = slide_ns.model('SlidePositions', {
'positions': fields.Raw(required=True, description='A dictionary where keys are slide IDs and values are their new positions')
})
# Parser for basic slide attributes
slide_base_parser = slide_ns.parser()
slide_base_parser.add_argument('content_id', type=int, required=True, help='The content ID for the slide')
slide_base_parser.add_argument('playlist_id', type=int, required=True, help='The playlist ID to which the slide belongs')
slide_base_parser.add_argument('enabled', type=str_to_bool, default=None, help='Is the slide enabled?')
slide_base_parser.add_argument('duration', type=int, default=3, help='Duration of the slide')
slide_base_parser.add_argument('position', type=int, default=999, help='Position of the slide')
# Parser for slide attributes (add)
slide_parser = slide_base_parser.copy()
slide_parser.add_argument('scheduling', type=str, required=True, help='Scheduling type: loop, datetime or inweek')
slide_parser.add_argument('delegate_duration', type=str_to_bool, default=None, help='Should the duration be delegated to video\'s duration?')
slide_parser.add_argument('datetime_start', type=str, required=False, help='Start datetime for scheduling (format: Y-m-d H:M)')
slide_parser.add_argument('datetime_end', type=str, required=False, help='End datetime for scheduling (format: Y-m-d H:M)')
slide_parser.add_argument('day_start', type=int, required=False, help='Start day for inweek scheduling (format: 1 for Monday to 7 for Sunday)')
slide_parser.add_argument('time_start', type=str, required=False, help='Start time for inweek scheduling (format: H:M)')
slide_parser.add_argument('day_end', type=int, required=False, help='End day for inweek scheduling (format: 1 for Monday to 7 for Sunday)')
slide_parser.add_argument('time_end', type=str, required=False, help='End time for inweek scheduling (format: H:M)')
# Parser for slide notification attributes (add)
slide_notification_parser = slide_base_parser.copy()
slide_notification_parser.add_argument('scheduling', type=str, required=True, help='Scheduling type: datetime or cron')
slide_notification_parser.add_argument('datetime_start', type=str, required=False, help='Start datetime for notification scheduling (format: Y-m-d H:M)')
slide_notification_parser.add_argument('datetime_end', type=str, required=False, help='End datetime for notification scheduling (format: Y-m-d H:M)')
slide_notification_parser.add_argument('cron_start', type=str, required=False, help='Cron expression for notification scheduling start (format: * * * * * * *)')
slide_notification_parser.add_argument('cron_end', type=str, required=False, help='Cron expression for notification scheduling end (format: * * * * * * *)')
# Parser for slide attributes (update)
slide_edit_parser = slide_parser.copy()
slide_edit_parser.replace_argument('scheduling', type=str, required=False, help='Scheduling type: loop, datetime, or inweek')
slide_edit_parser.replace_argument('content_id', type=int, required=False, help='The content ID for the slide')
slide_edit_parser.replace_argument('playlist_id', type=int, required=False, help='The playlist ID to which the slide belongs')
# Parser for slide notification attributes (update)
slide_notification_edit_parser = slide_notification_parser.copy()
slide_notification_edit_parser.replace_argument('scheduling', type=str, required=False, help='Scheduling type: datetime or cron')
slide_notification_edit_parser.replace_argument('content_id', type=int, required=False, help='The content ID for the slide')
slide_notification_edit_parser.replace_argument('playlist_id', type=int, required=False, help='The playlist ID to which the slide belongs')
class SlideApiController(ObController):
def register(self):
self.api().add_namespace(slide_ns, path='/api/slides')
slide_ns.add_resource(self.create_resource(SlideNotificationResource), '/notifications/<int:slide_id>')
slide_ns.add_resource(self.create_resource(SlideResource), '/<int:slide_id>')
slide_ns.add_resource(self.create_resource(SlideAddResource), '/')
slide_ns.add_resource(self.create_resource(SlideAddNotificationResource), '/notifications')
slide_ns.add_resource(self.create_resource(SlidePositionResource), '/positions')
def create_resource(self, resource_class):
# Function to inject dependencies into resources
return type(f'{resource_class.__name__}WithDependencies', (resource_class,), {
'_model_store': self._model_store,
'_controller': self,
'require_api_key': create_require_api_key_decorator(self._web_server)
})
def _add_slide_or_notification(self, data, is_notification=False):
if not data or 'content_id' not in data:
abort(400, description="Valid Content ID is required")
if not self._model_store.content().get(data.get('content_id')):
abort(404, description="Content not found")
if not data or 'playlist_id' not in data:
abort(400, description="Valid Playlist ID is required")
if not self._model_store.playlist().get(data.get('playlist_id')):
abort(404, description="Playlist not found")
cron_schedule_start, cron_schedule_end = self._resolve_scheduling(data, is_notification=is_notification)
slide = Slide(
content_id=data.get('content_id'),
enabled=data.get('enabled') if data.get('enabled') is not None else True,
delegate_duration=data.get('delegate_duration') if data.get('delegate_duration') is not None else False,
duration=data.get('duration', 3),
position=data.get('position', 999),
is_notification=is_notification,
playlist_id=data.get('playlist_id', None),
cron_schedule=cron_schedule_start,
cron_schedule_end=cron_schedule_end
)
slide = self._model_store.slide().add_form(slide)
self._post_update()
return slide.to_dict(), 201
def _resolve_scheduling(self, data, is_notification=False):
try:
return self._resolve_scheduling_for_notification(data) if is_notification else self._resolve_scheduling_for_slide(data)
except ValueError as ve:
abort(400, description=str(ve))
def _resolve_scheduling_for_slide(self, data):
scheduling = data.get('scheduling', 'loop')
cron_schedule_start = None
cron_schedule_end = None
if scheduling == 'loop':
pass
elif scheduling == 'datetime':
datetime_start = data.get('datetime_start')
datetime_end = data.get('datetime_end')
if not datetime_start:
abort(400, description="Field datetime_start is required for scheduling='datetime'")
cron_schedule_start = str_datetime_to_cron(datetime_str=datetime_start)
if datetime_end:
cron_schedule_end = str_datetime_to_cron(datetime_str=datetime_end)
elif scheduling == 'inweek':
day_start = data.get('day_start')
time_start = data.get('time_start')
day_end = data.get('day_end')
time_end = data.get('time_end')
if not (day_start and time_start and day_end and time_end):
abort(400, description="day_start, time_start, day_end, and time_end are required for scheduling='inweek'")
cron_schedule_start = str_weekdaytime_to_cron(weekday=int(day_start), time_str=time_start)
cron_schedule_end = str_weekdaytime_to_cron(weekday=int(day_end), time_str=time_end)
else:
abort(400, description="Invalid value for slide scheduling. Expected 'loop', 'datetime', or 'inweek'.")
return cron_schedule_start, cron_schedule_end
def _resolve_scheduling_for_notification(self, data):
scheduling = data.get('scheduling', 'datetime')
cron_schedule_start = None
cron_schedule_end = None
if scheduling == 'datetime':
datetime_start = data.get('datetime_start')
datetime_end = data.get('datetime_end')
if not datetime_start:
abort(400, description="Field datetime_start is required for scheduling='datetime'")
cron_schedule_start = str_datetime_to_cron(datetime_str=datetime_start)
if datetime_end:
cron_schedule_end = str_datetime_to_cron(datetime_str=datetime_end)
elif scheduling == 'cron':
cron_schedule_start = data.get('cron_start')
if not cron_schedule_start:
abort(400, description="Field cron_start is required for scheduling='cron'")
else:
abort(400, description="Invalid value for notification scheduling. Expected 'datetime' or 'cron'.")
return cron_schedule_start, cron_schedule_end
def _post_update(self):
self._model_store.variable().update_by_name("last_slide_update", time.time())
class SlideAddResource(Resource):
@slide_ns.expect(slide_parser)
@slide_ns.marshal_with(slide_output_model, code=201)
def post(self):
"""Add a new slide"""
self.require_api_key()
data = slide_parser.parse_args()
return self._controller._add_slide_or_notification(data, is_notification=False)
class SlideAddNotificationResource(Resource):
@slide_ns.expect(slide_notification_parser)
@slide_ns.marshal_with(slide_output_model, code=201)
def post(self):
"""Add a new slide notification"""
self.require_api_key()
data = slide_notification_parser.parse_args()
return self._controller._add_slide_or_notification(data, is_notification=True)
class SlideResource(Resource):
@slide_ns.marshal_with(slide_output_model)
def get(self, slide_id):
"""Get a slide by its ID"""
self.require_api_key()
slide = self._model_store.slide().get(slide_id)
if not slide:
abort(404, description="Slide not found")
return slide.to_dict()
@slide_ns.expect(slide_edit_parser)
@slide_ns.marshal_with(slide_output_model)
def put(self, slide_id):
"""Edit an existing slide"""
self.require_api_key()
data = slide_edit_parser.parse_args()
slide = self._model_store.slide().get(slide_id)
if not slide:
abort(404, description="Slide not found")
cron_schedule_start = slide.cron_schedule
cron_schedule_end = slide.cron_schedule_end
if data.get('scheduling'):
cron_schedule_start, cron_schedule_end = self._controller._resolve_scheduling(data, is_notification=slide.is_notification)
self._model_store.slide().update_form(
id=slide_id,
content_id=data.get('content_id', slide.content_id),
enabled=data.get('enabled', slide.enabled),
position=data.get('position', slide.position),
duration=data.get('duration', slide.duration),
cron_schedule=cron_schedule_start,
cron_schedule_end=cron_schedule_end
)
self._controller._post_update()
updated_slide = self._model_store.slide().get(slide_id)
return updated_slide.to_dict()
def delete(self, slide_id):
"""Delete a slide"""
self.require_api_key()
slide = self._model_store.slide().get(slide_id)
if not slide:
abort(404, description="Slide not found")
self._model_store.slide().delete(slide_id)
self._controller._post_update()
return '', 204
class SlideNotificationResource(Resource):
@slide_ns.expect(slide_notification_edit_parser)
@slide_ns.marshal_with(slide_output_model)
def put(self, slide_id):
"""Edit an existing slide notification"""
self.require_api_key()
data = slide_notification_edit_parser.parse_args()
slide = self._model_store.slide().get(slide_id)
if not slide:
abort(404, description="Slide not found")
cron_schedule_start = slide.cron_schedule
cron_schedule_end = slide.cron_schedule_end
if data.get('scheduling'):
cron_schedule_start, cron_schedule_end = self._controller._resolve_scheduling(data, is_notification=slide.is_notification)
self._model_store.slide().update_form(
id=slide_id,
content_id=data.get('content_id', slide.content_id),
enabled=data.get('enabled', slide.enabled),
position=data.get('position', slide.position),
delegate_duration=data.get('delegate_duration', slide.delegate_duration),
duration=data.get('duration', slide.duration),
cron_schedule=cron_schedule_start,
cron_schedule_end=cron_schedule_end
)
self._controller._post_update()
updated_slide = self._model_store.slide().get(slide_id)
return updated_slide.to_dict()
class SlidePositionResource(Resource):
@slide_ns.expect(positions_model)
def post(self):
"""Update positions of multiple slides"""
self.require_api_key()
data = request.get_json()
positions = data.get('positions', None) if data else None
if not positions:
abort(400, description="Positions data are required")
# Ensure the input is a dictionary with integer keys and values
if not isinstance(data, dict) or not all(isinstance(k, str) and isinstance(v, int) for k, v in positions.items()):
abort(400, description="Input must be a dictionary with string keys as slide IDs and integer values as positions")
self._model_store.slide().update_positions(positions)
self._controller._post_update()
return jsonify({'status': 'ok'})

View File

@ -0,0 +1,6 @@
from src.exceptions.HttpClientException import HttpClientException
class ContentNotFoundException(HttpClientException):
code = 404
description = "Content not found"

View File

@ -0,0 +1,6 @@
from src.exceptions.HttpClientException import HttpClientException
class ContentPathMissingException(HttpClientException):
code = 400
description = "Path is required"

View File

@ -0,0 +1,6 @@
from src.exceptions.HttpClientException import HttpClientException
class FolderNotEmptyException(HttpClientException):
code = 400
description = "Folder is not empty"

View File

@ -0,0 +1,6 @@
from src.exceptions.HttpClientException import HttpClientException
class FolderNotFoundException(HttpClientException):
code = 404
description = "Folder not found"

View File

@ -0,0 +1,5 @@
{
"plugin_title": "Core API",
"plugin_description": "Adds api feature wrapping core features",
"plugin_help_on_activation": "Documentation will be available on the /api page"
}

View File

@ -0,0 +1,5 @@
{
"plugin_title": "Core API",
"plugin_description": "Agrega características de API que envuelven las características principales",
"plugin_help_on_activation": "La documentación estará disponible en la página /api"
}

View File

@ -0,0 +1,5 @@
{
"plugin_title": "Core API",
"plugin_description": "Ajoute des fonctionnalités d'API englobant les fonctionnalités principales",
"plugin_help_on_activation": "La documentation sera disponible sur la page /api"
}

View File

@ -0,0 +1,5 @@
{
"plugin_title": "Core API",
"plugin_description": "Aggiunge funzionalità API che racchiudono le funzionalità di base",
"plugin_help_on_activation": "La documentazione sarà disponibile nella pagina /api"
}

View File

@ -10,8 +10,11 @@ from src.util.utils import am_i_in_docker
class GitUpdater(ObPlugin):
def get_version(self) -> str:
return '1.0'
def use_id(self):
return 'git_updater'
return 'core_updater'
def use_title(self):
return self.translate('plugin_title')
@ -19,6 +22,9 @@ class GitUpdater(ObPlugin):
def use_description(self):
return self.translate('plugin_description')
def use_help_on_activation(self):
return None
def use_variables(self) -> List[Variable]:
return []

View File

@ -10,10 +10,10 @@ from src.util.utils import run_system_command, sudo_run_system_command, get_work
from src.Application import Application
class GitUpdaterController(ObController):
class CoreUpdaterController(ObController):
def register(self):
self._app.add_url_rule('/git-updater/update/now', 'git_updater_update_now', self._auth(self.update_now), methods=['GET'])
self._app.add_url_rule('/core-updater/update/now', 'core_updater_update_now', self._auth(self.update_now), methods=['GET'])
def update_now(self):
debug = self._model_store.config().map().get('debug')

View File

@ -1,5 +1,5 @@
{
"plugin_title": "Git Updater Button",
"plugin_title": "Core Updater Button",
"plugin_description": "Adds an update button (only for system-wide installations)",
"button_update": "Update"
}

View File

@ -1,5 +1,5 @@
{
"plugin_title": "Botón de Actualización de Git",
"plugin_title": "Core Updater Button",
"plugin_description": "Añade un botón de actualización (solo para instalaciones a nivel del sistema)",
"button_update": "Actualizar"
}

View File

@ -1,5 +1,5 @@
{
"plugin_title": "Bouton de mise à jour",
"plugin_title": "Core Updater Button",
"plugin_description": "Ajoute un bouton de mise à jour (seulement pour les installations système)",
"button_update": "Mettre à jour"
}

View File

@ -1,5 +1,5 @@
{
"plugin_title": "Pulsante di aggiornamento",
"plugin_title": "Core Updater Button",
"plugin_description": "Aggiunge un pulsante di aggiornamento (solo per installazioni di sistema)",
"button_update": "Aggiorna"
}

View File

@ -0,0 +1,4 @@
{% if not am_i_in_docker() %}
<a href="{{ url_for('core_updater_update_now') }}" class="btn sysinfo-update protected"><i class="fa fa-cloud-arrow-down icon-left"></i>{{ l.core_updater_button_update }}</a>
{% endif %}

View File

@ -1,4 +0,0 @@
{% if not am_i_in_docker() %}
<a href="{{ url_for('git_updater_update_now') }}" class="btn sysinfo-update protected"><i class="fa fa-cloud-arrow-down icon-left"></i>{{ l.git_updater_button_update }}</a>
{% endif %}

View File

@ -8,6 +8,9 @@ from src.model.hook.HookRegistration import HookRegistration
class Dashboard(ObPlugin):
def get_version(self) -> str:
return '1.0'
def use_id(self):
return 'dashboard'
@ -17,6 +20,9 @@ class Dashboard(ObPlugin):
def use_description(self):
return self.translate('plugin_description')
def use_help_on_activation(self):
return None
def use_variables(self) -> List[Variable]:
return []

View File

@ -22,8 +22,7 @@
{% endwith %}
{% endblock %}
{% block page %}
{% block top_page %}
<div class="top-content">
<div class="top-actions">
@ -32,7 +31,9 @@
</button>
</div>
</div>
{% endblock %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">

View File

@ -1,7 +1,9 @@
flask==2.3.3
flask-restx==1.3.0
python-dotenv
cron-descriptor
waitress
flask-login
pysqlite3
psutil
pymediainfo

56
setup.py Normal file
View File

@ -0,0 +1,56 @@
# obscreen
# ---------------
# A fancy self-hosted digital signage tool. Free, simple and working.
#
# Author: jr-k (c) 2024
# Website: https://github.com/jr-k/obscreen
# License: GPLv2 (see LICENSE file)
import os
import sys
import logging
from setuptools import setup, find_packages
common_dependencies = [
'flask==2.3.3',
'flask-restx==1.3.0',
'python-dotenv',
'cron-descriptor',
'waitress',
'flask-login',
'psutil',
'pymediainfo',
'pysqlite3',
]
if sys.platform == "win32":
common_dependencies.remove('pysqlite3')
if sys.platform == "darwin":
common_dependencies.remove('pysqlite3')
os.environ['PYTHONUTF8'] = '1'
os.environ['PYTHONIOENCODING'] = 'utf-8'
setup(
name='obscreen',
version=open('version.txt').read(),
description='A fancy self-hosted digital signage tool. Free, simple and working.',
long_description=open('README.md').read(),
long_description_content_type='text/markdown',
author='JRK',
author_email='jrk@jierka.com',
url='https://github.com/jr-k/obscreen',
packages=find_packages(),
platforms='any',
classifiers=[
'Programming Language :: Python :: 3',
'License :: OSI Approved :: GNU General Public License v2 (GPLv2)',
'Operating System :: OS Independent',
'Topic :: Desktop Environment :: Screen Savers',
'Topic :: Multimedia :: Graphics'
],
python_requires='>=3.6',
install_requires=common_dependencies,
)

View File

@ -6,7 +6,6 @@ import threading
from src.service.ModelStore import ModelStore
from src.service.PluginStore import PluginStore
from src.service.TemplateRenderer import TemplateRenderer
from src.service.ExternalStorageServer import ExternalStorageServer
from src.service.WebServer import WebServer
from src.model.enum.HookType import HookType
@ -19,7 +18,6 @@ class Application:
self._model_store = ModelStore(self, self.get_plugins)
self._template_renderer = TemplateRenderer(kernel=self, model_store=self._model_store, render_hook=self.render_hook)
self._web_server = WebServer(kernel=self, model_store=self._model_store, template_renderer=self._template_renderer)
self._external_storage_server = ExternalStorageServer(kernel=self, model_store=self._model_store)
logging.info("[{}] Starting application v{}...".format(self.get_name(), self.get_version()))
self._plugin_store = PluginStore(kernel=self, model_store=self._model_store, template_renderer=self._template_renderer, web_server=self._web_server)
@ -31,7 +29,6 @@ class Application:
if variable:
self._model_store.variable().update_by_name(variable.name, variable.as_int() + 1)
self._external_storage_server.run()
self._web_server.run()
def signal_handler(self, signal, frame) -> None:
@ -62,7 +59,3 @@ class Application:
self._model_store.lang().set_lang(lang)
self._model_store.variable().reload()
self._plugin_store.reload_lang()
@property
def external_storage_server(self):
return self._external_storage_server

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify
from flask import Flask, render_template, redirect, request, url_for, jsonify, flash
from flask_login import login_user, logout_user, current_user
from src.service.ModelStore import ModelStore
from src.model.entity.User import User
@ -26,8 +26,6 @@ class AuthController(ObController):
self._app.add_url_rule('/auth/user/delete/<user_id>', 'auth_user_delete', self.guard_auth(self._auth(self.auth_user_delete)), methods=['GET'])
def login(self):
login_error = None
if current_user.is_authenticated:
return redirect(url_for('playlist'))
@ -41,13 +39,12 @@ class AuthController(ObController):
login_user(user)
return redirect(url_for('playlist'))
else:
login_error = 'bad_credentials'
flash(self.t('login_error_bad_credentials'), 'error')
else:
login_error = 'not_found'
flash(self.t('login_error_not_found'), 'error')
return render_template(
'auth/login.jinja.html',
login_error=login_error,
last_username=request.form['username'] if 'username' in request.form else None
)
@ -67,8 +64,8 @@ class AuthController(ObController):
return render_template(
'auth/list.jinja.html',
error=request.args.get('error', None),
users=self._model_store.user().get_users(exclude=User.DEFAULT_USER if demo else None),
plugin_core_api_enabled=self._model_store.variable().map().get('plugin_core_api_enabled').as_bool()
)
def auth_user_add(self):
@ -95,10 +92,12 @@ class AuthController(ObController):
return redirect(url_for('auth_user_list'))
if user.id == str(current_user.id):
return redirect(url_for('auth_user_list', error='auth_user_delete_cant_delete_yourself'))
flash(self.t('auth_user_delete_cant_delete_yourself'), 'error')
return redirect(url_for('auth_user_list'))
if self._model_store.user().count_all_enabled() == 1:
return redirect(url_for('auth_user_list', error='auth_user_delete_at_least_one_account'))
flash(self.t('auth_user_delete_at_least_one_account'), 'error')
return redirect(url_for('auth_user_list'))
self._model_store.user().delete(user_id)
return redirect(url_for('auth_user_list'))

View File

@ -2,14 +2,14 @@ import json
import os
import time
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort, flash
from werkzeug.utils import secure_filename
from src.service.ModelStore import ModelStore
from src.model.entity.Content import Content
from src.model.enum.ContentType import ContentType
from src.model.enum.ContentMetadata import ContentMetadata
from src.model.enum.FolderEntity import FolderEntity, FOLDER_ROOT_PATH
from src.interface.ObController import ObController
from src.service.ExternalStorageServer import ExternalStorageServer
from src.util.utils import str_to_enum, get_optional_string
from src.util.UtilFile import randomize_filename
@ -28,11 +28,10 @@ class ContentController(ObController):
self._app.add_url_rule('/slideshow/content/move-folder', 'slideshow_content_folder_move', self._auth(self.slideshow_content_folder_move), methods=['POST'])
self._app.add_url_rule('/slideshow/content/rename-folder', 'slideshow_content_folder_rename', self._auth(self.slideshow_content_folder_rename), methods=['POST'])
self._app.add_url_rule('/slideshow/content/delete-folder', 'slideshow_content_folder_delete', self._auth(self.slideshow_content_folder_delete), methods=['GET'])
self._app.add_url_rule('/slideshow/content/show/<content_id>', 'slideshow_content_show', self._auth(self.slideshow_content_show), methods=['GET'])
self._app.add_url_rule('/slideshow/content/upload-bulk', 'slideshow_content_upload_bulk', self._auth(self.slideshow_content_upload_bulk), methods=['POST'])
self._app.add_url_rule('/slideshow/content/delete-bulk-explr', 'slideshow_content_delete_bulk_explr', self._auth(self.slideshow_content_delete_bulk_explr), methods=['GET'])
def get_working_folder(self):
def get_folder_context(self):
working_folder_path = request.args.get('path', None)
working_folder = None
@ -47,7 +46,7 @@ class ContentController(ObController):
def slideshow_content_list(self):
self._model_store.variable().update_by_name('last_pillmenu_slideshow', 'slideshow_content_list')
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
slides_with_content = self._model_store.slide().get_all_indexed(attribute='content_id', multiple=True)
return render_template(
@ -60,11 +59,11 @@ class ContentController(ObController):
working_folder_children=self._model_store.folder().get_children(folder=working_folder, entity=FolderEntity.CONTENT, sort='created_at', ascending=False),
enum_content_type=ContentType,
enum_folder_entity=FolderEntity,
chroot_http_external_storage=self.get_external_storage_server().get_directory(),
external_storage_mountpoint=self._model_store.config().map().get('external_storage_mountpoint'),
)
def slideshow_content_add(self):
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
route_args = {
"path": working_folder_path,
}
@ -86,7 +85,7 @@ class ContentController(ObController):
return redirect(url_for('slideshow_content_list', **route_args))
def slideshow_content_upload_bulk(self):
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
for key in request.files:
files = request.files.getlist(key)
@ -111,19 +110,30 @@ class ContentController(ObController):
if not content:
return abort(404)
working_folder_path, working_folder = self.get_working_folder()
vargs = {}
working_folder_path, working_folder = self.get_folder_context()
edit_view = 'slideshow/contents/edit.jinja.html'
if content.type == ContentType.COMPOSITION:
edit_view = 'slideshow/contents/edit-composition.jinja.html'
vargs['folders_tree'] = self._model_store.folder().get_folder_tree(FolderEntity.CONTENT)
vargs['foldered_contents'] = self._model_store.content().get_all_indexed('folder_id', multiple=True)
elif content.type == ContentType.TEXT:
edit_view = 'slideshow/contents/edit-text.jinja.html'
return render_template(
'slideshow/contents/edit.jinja.html',
edit_view,
content=content,
working_folder_path=working_folder_path,
working_folder=working_folder,
enum_content_type=ContentType,
chroot_http_external_storage=self.get_external_storage_server().get_directory(),
enum_content_metadata=ContentMetadata,
external_storage_mountpoint=self._model_store.config().map().get('external_storage_mountpoint'),
**vargs
)
def slideshow_content_save(self, content_id: int = 0):
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
content = self._model_store.content().get(content_id)
if not content:
@ -136,22 +146,24 @@ class ContentController(ObController):
)
self._post_update()
return redirect(url_for('slideshow_content_edit', content_id=content_id, saved=1))
flash(self.t('common_saved'), 'success')
return redirect(url_for('slideshow_content_edit', content_id=content_id))
def slideshow_content_delete(self):
working_folder_path, working_folder = self.get_working_folder()
error_tuple = self.delete_content_by_id(request.args.get('id'))
working_folder_path, working_folder = self.get_folder_context()
error = self.delete_content_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('slideshow_content_list', **route_args))
def slideshow_content_rename(self):
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
self._model_store.content().update_form(
id=request.form['id'],
name=request.form['name'],
@ -182,7 +194,7 @@ class ContentController(ObController):
return redirect(url_for('slideshow_content_list', path=path))
def slideshow_content_folder_add(self):
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
self._model_store.folder().add_folder(
entity=FolderEntity.CONTENT,
@ -193,7 +205,7 @@ class ContentController(ObController):
return redirect(url_for('slideshow_content_list', path=working_folder_path))
def slideshow_content_folder_rename(self):
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
self._model_store.folder().rename_folder(
folder_id=request.form['id'],
name=request.form['name'],
@ -202,7 +214,7 @@ class ContentController(ObController):
return redirect(url_for('slideshow_content_list', path=working_folder_path))
def slideshow_content_folder_move(self):
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
entity_ids = request.form['entity_ids'].split(',')
folder_ids = request.form['folder_ids'].split(',')
@ -225,44 +237,36 @@ class ContentController(ObController):
return redirect(url_for('slideshow_content_list', path=working_folder_path))
def slideshow_content_folder_delete(self):
working_folder_path, working_folder = self.get_working_folder()
error_tuple = self.delete_folder_by_id(request.args.get('id'))
working_folder_path, working_folder = self.get_folder_context()
error = self.delete_folder_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
if error:
flash(self.t(error), 'error')
return redirect(url_for('slideshow_content_list', **route_args))
def slideshow_content_show(self, content_id: int = 0):
content = self._model_store.content().get(content_id)
if not content:
return abort(404)
return redirect(self._model_store.content().resolve_content_location(content))
def slideshow_content_delete_bulk_explr(self):
working_folder_path, working_folder = self.get_working_folder()
working_folder_path, working_folder = self.get_folder_context()
entity_ids = request.args.get('entity_ids', '').split(',')
folder_ids = request.args.get('folder_ids', '').split(',')
route_args_dict = {"path": working_folder_path}
for id in entity_ids:
if id:
error_tuple = self.delete_content_by_id(id)
error = self.delete_content_by_id(id)
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
for id in folder_ids:
if id:
error_tuple = self.delete_folder_by_id(id)
error = self.delete_folder_by_id(id)
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('slideshow_content_list', **route_args_dict))
@ -273,7 +277,7 @@ class ContentController(ObController):
return None
if self._model_store.slide().count_slides_for_content(content.id) > 0:
return 'referenced_in_slide_error', content.name
return 'slideshow_content_referenced_in_slide_error'.replace('%contentName%', content.name)
self._model_store.content().delete(content.id)
self._post_update()
@ -289,7 +293,7 @@ class ContentController(ObController):
folder_counter = self._model_store.folder().count_subfolders_for_folder(folder.id)
if content_counter > 0 or folder_counter:
return 'folder_not_empty_error', folder.name
return self.t('common_folder_not_empty_error').replace('%folderName%', folder.name)
self._model_store.folder().delete(id=folder.id)
self._post_update()

View File

@ -1,7 +1,8 @@
from flask import Flask, send_file, render_template_string, jsonify
from typing import Optional
from flask import Flask, send_file, render_template_string, jsonify, request
from src.interface.ObController import ObController
# from src.util.UtilChromecast import fetch_friendly_names, cast_url
class CoreController(ObController):
@ -9,6 +10,8 @@ class CoreController(ObController):
def register(self):
self._app.add_url_rule('/manifest.json', 'manifest', self.manifest, methods=['GET'])
self._app.add_url_rule('/favicon.ico', 'favicon', self.favicon, methods=['GET'])
# self._app.add_url_rule('/cast-scan', 'cast_scan', self.cast_scan, methods=['GET'])
# self._app.add_url_rule('/cast-url', 'cast_url', self.cast_url, methods=['POST'])
def manifest(self):
with open("{}/manifest.jinja.json".format(self.get_template_dir()), 'r') as file:
@ -19,4 +22,17 @@ class CoreController(ObController):
return self._app.response_class(rendered_content, mimetype='application/json')
def favicon(self):
return send_file("{}/favicon.ico".format(self.get_web_dir()), mimetype='image/x-icon')
return send_file("{}/favicon.ico".format(self.get_web_dir()), mimetype='image/x-icon')
# def cast_scan(self):
# return jsonify({
# 'devices': fetch_friendly_names(discovery_timeout=5)
# })
#
# def cast_url(self):
# data = request.get_json()
# success = cast_url(friendly_name=data.get('device'), url=data.get('url'), discovery_timeout=5)
#
# return jsonify({
# 'success': success
# })

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort, flash
from src.service.ModelStore import ModelStore
from src.model.entity.NodePlayer import NodePlayer
from src.interface.ObController import ObController
@ -108,18 +108,19 @@ class FleetNodePlayerController(ObController):
)
self._post_update()
# return redirect(url_for('fleet_node_player_edit', node_player_id=node_player_id, saved=1))
flash(self.t('common_saved'), 'success')
return redirect(url_for('fleet_node_player_list', path=working_folder_path))
def fleet_node_player_delete(self):
working_folder_path, working_folder = self.get_working_folder()
error_tuple = self.delete_node_player_by_id(request.args.get('id'))
error = self.delete_node_player_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('fleet_node_player_list', **route_args))
@ -200,13 +201,13 @@ class FleetNodePlayerController(ObController):
def fleet_node_player_folder_delete(self):
working_folder_path, working_folder = self.get_working_folder()
error_tuple = self.delete_folder_by_id(request.args.get('id'))
error = self.delete_folder_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('fleet_node_player_list', **route_args))
@ -218,17 +219,17 @@ class FleetNodePlayerController(ObController):
for id in entity_ids:
if id:
error_tuple = self.delete_node_player_by_id(id)
error = self.delete_node_player_by_id(id)
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
for id in folder_ids:
if id:
error_tuple = self.delete_folder_by_id(id)
error = self.delete_folder_by_id(id)
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('fleet_node_player_list', **route_args_dict))
@ -252,7 +253,7 @@ class FleetNodePlayerController(ObController):
folder_counter = self._model_store.folder().count_subfolders_for_folder(folder.id)
if node_player_counter > 0 or folder_counter:
return 'folder_not_empty_error', folder.name
return self.t('common_folder_not_empty_error').replace('%folderName%', folder.name)
self._model_store.folder().delete(id=folder.id)

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify
from flask import Flask, render_template, redirect, request, url_for, jsonify, flash
from src.service.ModelStore import ModelStore
from src.model.entity.NodePlayerGroup import NodePlayerGroup
from src.model.enum.FolderEntity import FolderEntity
@ -43,7 +43,6 @@ class FleetNodePlayerGroupController(ObController):
return render_template(
'fleet/player-group/list.jinja.html',
error=request.args.get('error', None),
current_player_group=current_player_group,
node_player_groups=node_player_groups,
pcounters=pcounters,
@ -86,7 +85,8 @@ class FleetNodePlayerGroupController(ObController):
def fleet_node_player_group_delete(self, player_group_id: int):
if self._model_store.node_player().count_node_players_for_group(player_group_id) > 0:
return redirect(url_for('fleet_node_player_group_list', player_group_id=player_group_id, error='node_player_group_delete_has_node_player'))
flash(self.t('node_player_group_delete_has_node_player'), 'error')
return redirect(url_for('fleet_node_player_group_list', player_group_id=player_group_id))
self._model_store.node_player_group().delete(player_group_id)
return redirect(url_for('fleet_node_player_group'))

View File

@ -1,18 +1,20 @@
import os
import json
import logging
import hashlib
from datetime import datetime
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort, send_file, Response
from pathlib import Path
from src.model.entity.Slide import Slide
from src.model.entity.Content import Content
from src.model.enum.ContentType import ContentType
from src.exceptions.NoFallbackPlaylistException import NoFallbackPlaylistException
from src.service.ModelStore import ModelStore
from src.interface.ObController import ObController
from src.util.utils import get_safe_cron_descriptor, is_cron_in_datetime_moment, is_cron_in_week_moment, is_now_after_cron_date_time_moment, is_now_after_cron_week_moment
from src.util.utils import get_safe_cron_descriptor, is_cron_in_datetime_moment, is_cron_in_week_moment, is_now_after_cron_date_time_moment, is_now_after_cron_week_moment, decode_uri_component
from src.util.UtilNetwork import get_safe_remote_addr, get_network_interfaces
from src.model.enum.AnimationSpeed import animation_speed_duration
@ -25,20 +27,32 @@ class PlayerController(ObController):
self._app.add_url_rule('/player/default', 'player_default', self.player_default, methods=['GET'])
self._app.add_url_rule('/player/playlist', 'player_playlist', self.player_playlist, methods=['GET'])
self._app.add_url_rule('/player/playlist/use/<playlist_slug_or_id>', 'player_playlist_use', self.player_playlist, methods=['GET'])
self._app.add_url_rule('/serve/content/<content_type>/<content_id>/<content_location>', 'serve_content_file', self.serve_content_file, methods=['GET'])
self._app.add_url_rule('/serve/content/composition/<content_id>', 'serve_content_composition', self.serve_content_composition, methods=['GET'])
def player(self, playlist_slug_or_id: str = ''):
preview_playlist = request.args.get('preview_playlist')
preview_content_id = request.args.get('preview_content_id')
playlist_id = None
playlist_slug_or_id = self._get_dynamic_playlist_id(playlist_slug_or_id)
current_playlist = self._model_store.playlist().get_one_by("slug = ? OR id = ?", {
"slug": playlist_slug_or_id,
"id": playlist_slug_or_id
})
if not preview_content_id:
query = " (slug = ? OR id = ?) "
query_args = {
"slug": playlist_slug_or_id,
"id": playlist_slug_or_id,
}
if playlist_slug_or_id and not current_playlist:
return abort(404)
if not preview_playlist:
query = query + " AND enabled = ? "
query_args["enabled"] = True
playlist_id = current_playlist.id if current_playlist else None
current_playlist = self._model_store.playlist().get_one_by(query, query_args)
if playlist_slug_or_id and not current_playlist:
return abort(404)
playlist_id = current_playlist.id if current_playlist else None
try:
items = self._get_playlist(playlist_id=playlist_id, preview_content_id=preview_content_id)
@ -52,6 +66,8 @@ class PlayerController(ObController):
slide_animation_entrance_effect = request.args.get('animation_effect', self._model_store.variable().get_one_by_name('slide_animation_entrance_effect').eval())
slide_animation_exit_effect = request.args.get('slide_animation_exit_effect', self._model_store.variable().get_one_by_name('slide_animation_exit_effect').eval())
return render_template(
'player/player.jinja.html',
items=items,
@ -70,7 +86,8 @@ class PlayerController(ObController):
interfaces=[iface['ip_address'] for iface in get_network_interfaces()],
external_url=self._model_store.variable().get_one_by_name('external_url').as_string().strip(),
time_with_seconds=self._model_store.variable().get_one_by_name('default_slide_time_with_seconds'),
noplaylist=request.args.get('noplaylist', '0') == '1'
noplaylist=request.args.get('noplaylist', '0') == '1',
hard_refresh_request=self._model_store.variable().get_one_by_name("refresh_player_request").as_int()
)
def player_playlist(self, playlist_slug_or_id: str = ''):
@ -110,7 +127,7 @@ class PlayerController(ObController):
preview_content = self._model_store.content().get(preview_content_id) if preview_content_id else None
preview_mode = preview_content is not None
if playlist_id == 0 or not playlist_id:
if not preview_mode and (playlist_id == 0 or not playlist_id):
playlist = self._model_store.playlist().get_one_by(query="fallback = 1")
if playlist:
@ -120,8 +137,9 @@ class PlayerController(ObController):
enabled_slides = [Slide(content_id=preview_content.id, duration=1000000)] if preview_mode else self._model_store.slide().get_slides(enabled=True, playlist_id=playlist_id)
slides = self._model_store.slide().to_dict(enabled_slides)
contents = self._model_store.content().get_all_indexed()
playlist = self._model_store.playlist().get(playlist_id)
content_ids = [str(slide['content_id']) for slide in slides if slide['content_id'] is not None]
contents = self._model_store.content().get_all_indexed(query="id IN ({})".format(','.join(content_ids)))
playlist = self._model_store.playlist().get(playlist_id) if not preview_mode else None
position = 9999
playlist_loop = []
@ -136,24 +154,27 @@ class PlayerController(ObController):
content = contents[int(slide['content_id'])]
slide['name'] = content.name
slide['location'] = content.location
slide['type'] = content.type.value
slide['location'] = self._model_store.content().resolve_content_location(content)
if slide['type'] == ContentType.EXTERNAL_STORAGE.value:
mount_point_dir = Path(self.get_external_storage_server().get_directory(), slide['location'])
mount_point_dir = Path(self._model_store.config().map().get('external_storage_mountpoint'), content.location)
if mount_point_dir.is_dir():
for file in mount_point_dir.iterdir():
if file.is_file() and not file.stem.startswith('.'):
virtual_content = Content(
id=content.id,
name=file.stem,
location=str(Path(mount_point_dir, file.name)),
type=ContentType.guess_content_type_file(str(file.resolve())),
)
slide = dict(slide)
slide['id'] = hashlib.md5(str(file).encode('utf-8')).hexdigest()
slide['position'] = position
slide['type'] = ContentType.guess_content_type_file(str(file.resolve())).value
slide['name'] = file.stem
slide['delegate_duration'] = 1 if slide['type'] == ContentType.VIDEO.value else 0
slide['location'] = "{}/{}".format(
self._model_store.content().resolve_content_location(content),
file.name
)
slide['delegate_duration'] = 1 if virtual_content.type == ContentType.VIDEO else 0
slide['name'] = file.name
slide['type'] = virtual_content.type.value
slide['location'] = self._model_store.content().resolve_content_location(virtual_content)
self._check_slide_enablement(playlist_loop, playlist_notifications, slide)
position = position + 1
else:
@ -197,3 +218,50 @@ class PlayerController(ObController):
return
loop.append(slide)
def serve_content_file(self, content_location, content_type, content_id):
content = self._model_store.content().get(content_id)
if not content:
abort(404, 'Content not found')
content_location = decode_uri_component(content_location)
content_path = str(Path(self.get_application_dir(), content_location))
if content_type == ContentType.EXTERNAL_STORAGE.value:
content_path = str(Path(self._model_store.config().map().get('external_storage_mountpoint'), content_location))
if not os.path.exists(content_path) or '..' in content_path:
abort(404, 'Content not found')
if not self._model_store.variable().get_one_by_name('player_content_cache').as_bool():
response = send_file(content_path)
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
content_path_hash = hashlib.sha256(str(content_path).encode()).hexdigest()
etag = f'"{content_path_hash}-{content_id}-{os.path.getmtime(content_path)}"'
if_none_match = request.headers.get('If-None-Match')
if if_none_match == etag:
return Response(status=304)
response = send_file(content_path)
response.headers['Cache-Control'] = 'public, max-age=3153600000' # 100 years
response.headers['ETag'] = etag
return response
def serve_content_composition(self, content_id):
content = self._model_store.content().get(content_id)
if not content or content.type != ContentType.COMPOSITION:
abort(404, 'Content not found')
return render_template(
'player/content/composition.jinja.html',
content=content,
)

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort, flash
from src.service.ModelStore import ModelStore
from src.model.entity.Playlist import Playlist
from src.model.enum.FolderEntity import FolderEntity
@ -35,7 +35,6 @@ class PlaylistController(ObController):
return render_template(
'playlist/list.jinja.html',
error=request.args.get('error', None),
current_playlist=current_playlist,
playlists=playlists,
durations=durations,
@ -55,7 +54,8 @@ class PlaylistController(ObController):
playlist = Playlist(
name=request.form['name'],
enabled=True,
time_sync=False
time_sync=False,
fallback=self._model_store.playlist().count_fallbacks() == 0
)
try:
@ -70,7 +70,8 @@ class PlaylistController(ObController):
id=request.form['id'],
name=request.form['name'],
time_sync=True if 'time_sync' in request.form else False,
enabled=True if 'enabled' in request.form else False
enabled=True if 'enabled' in request.form else False,
fallback=True if self._model_store.playlist().count_fallbacks() == 0 else None
)
return redirect(url_for('playlist_list', playlist_id=request.form['id']))
@ -81,10 +82,12 @@ class PlaylistController(ObController):
abort(404)
if self._model_store.slide().count_slides_for_playlist(playlist_id) > 0:
return redirect(url_for('playlist_list', playlist_id=playlist_id, error='playlist_delete_has_slides'))
flash(self.t('playlist_delete_has_slides'), 'error')
return redirect(url_for('playlist_list', playlist_id=playlist_id))
if self._model_store.node_player_group().count_node_player_groups_for_playlist(playlist_id) > 0:
return redirect(url_for('playlist_list', playlist_id=playlist_id, error='playlist_delete_has_node_player_groups'))
flash(self.t('playlist_delete_has_node_player_groups'), 'error')
return redirect(url_for('playlist_list', playlist_id=playlist_id))
self._model_store.playlist().delete(playlist_id)
return redirect(url_for('playlist'))

View File

@ -2,7 +2,7 @@ import time
import json
import threading
from flask import Flask, render_template, redirect, request, url_for
from flask import Flask, render_template, redirect, request, url_for, flash
from typing import Optional
from src.service.ModelStore import ModelStore
@ -40,7 +40,8 @@ class SettingsController(ObController):
error = self._pre_update(request.form['id'])
if error:
return redirect(url_for('settings_variable_list', error=error))
flash(error, 'error')
return redirect(url_for('settings_variable_list'))
self._model_store.variable().update_form(request.form['id'], request.form['value'])
redirect_response = self._post_update(request.form['id'])
@ -54,7 +55,8 @@ class SettingsController(ObController):
error = self._pre_update(request.form['id'])
if error:
return redirect(url_for('settings_variable_plugin_list', error=error))
flash(error, 'error')
return redirect(url_for('settings_variable_plugin_list'))
self._model_store.variable().update_form(request.form['id'], request.form['value'])
redirect_response = self._post_update(request.form['id'])
@ -79,7 +81,8 @@ class SettingsController(ObController):
if variable.name == 'slide_upload_limit':
self.reload_web_server()
return redirect(url_for('settings_variable_list', warning='common_restart_needed'))
flash(self.t('common_restart_needed'), 'warning')
return redirect(url_for('settings_variable_list'))
if variable.name == 'fleet_player_enabled':
self.reload_web_server()
@ -98,7 +101,10 @@ class SettingsController(ObController):
thread = threading.Thread(target=self.plugin_update)
thread.daemon = True
thread.start()
return redirect(url_for('settings_variable_plugin_list', warning='common_restart_needed'))
flash(self.t('common_restart_needed'), 'warning')
return redirect(url_for('settings_variable_plugin_list'))
flash(self.t('common_saved'), 'success')
def plugin_update(self) -> None:
restart()

View File

@ -2,7 +2,7 @@ import json
import os
import time
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort, flash
from werkzeug.utils import secure_filename
from src.service.ModelStore import ModelStore
from src.model.entity.Slide import Slide
@ -102,11 +102,11 @@ class SlideController(ObController):
return jsonify({'status': 'ok'})
def slideshow_player_refresh(self):
referrer_path = self.get_referrer_path()
max_timeout_value = self._model_store.variable().get_one_by_name('polling_interval').as_string()
flash(self.t('slideshow_slide_refresh_player_success').replace('%time%', max_timeout_value), 'success:refresh')
self._model_store.variable().update_by_name("refresh_player_request", time.time())
max_timeout_value = self._model_store.variable().get_one_by_name('polling_interval').as_int()
query_params = '{}={}'.format('refresh_player', max_timeout_value)
next_url = request.args.get('next')
return redirect('{}{}{}'.format(next_url, '&' if '?' in next_url else '?', query_params))
return redirect(referrer_path)
def _post_update(self):
self._model_store.variable().update_by_name("last_slide_update", time.time())

View File

@ -69,5 +69,6 @@ class SysinfoController(ObController):
def sysinfo_get_ipaddr(self):
return jsonify({
'external_url': self._model_store.variable().get_one_by_name('external_url').as_string().strip(),
'interfaces': [iface['ip_address'] for iface in get_network_interfaces()]
'interfaces': [iface['ip_address'] for iface in get_network_interfaces()],
'hard_refresh_request': self._model_store.variable().get_one_by_name("refresh_player_request").as_int()
})

View File

@ -0,0 +1,5 @@
from werkzeug.exceptions import HTTPException
class HttpClientException(HTTPException):
pass

View File

@ -1,6 +1,7 @@
import abc
from typing import Optional, List, Dict, Union
from flask import request
from src.service.TemplateRenderer import TemplateRenderer
from src.service.ModelStore import ModelStore
from src.interface.ObPlugin import ObPlugin
@ -40,11 +41,30 @@ class ObController(abc.ABC):
def reload_lang(self, lang: str):
self._kernel.reload_lang(lang)
def get_application_dir(self):
return self._kernel.get_application_dir()
def t(self, token) -> Union[Dict, str]:
return self._model_store.lang().translate(token)
def get_external_storage_server(self):
return self._kernel.external_storage_server
def render_view(self, template_file: str, **parameters: dict) -> str:
return self._template_renderer.render_view(template_file, self.plugin(), **parameters)
def api(self):
return self._web_server.api
def get_referrer_path(self):
referer_url = request.referrer
if referer_url:
return '/' + referer_url.replace(request.host_url, '').split('?')[0]
return None
def get_referrer_rule(self):
referer_path = self.get_referrer_path()
if referer_path:
for rule in self._app.url_map.iter_rules():
if referer_path == rule.rule.split('/<')[0]:
return rule.rule
return None

View File

@ -38,6 +38,10 @@ class ObPlugin(abc.ABC):
def use_description(self) -> str:
pass
@abc.abstractmethod
def use_help_on_activation(self) -> Optional[str]:
pass
@abc.abstractmethod
def use_variables(self) -> List[Variable]:
pass
@ -46,6 +50,10 @@ class ObPlugin(abc.ABC):
def use_hooks_registrations(self) -> List[HookRegistration]:
pass
@abc.abstractmethod
def get_version(self) -> str:
pass
def get_directory(self) -> Optional[str]:
return self._plugin_dir
@ -82,7 +90,10 @@ class ObPlugin(abc.ABC):
def add_functional_hook_registration(self, hook: HookType, priority: int = 0, function=None) -> FunctionalHookRegistration:
return FunctionalHookRegistration(plugin=self, hook=hook, priority=priority, function=function)
def translate(self, token, resolve=False) -> Union[Dict, str]:
def translate(self, token, resolve=True) -> Union[Dict, str]:
if not token:
token = '<UNKNOWN>'
token = token if token.startswith(self.use_id()) else "{}_{}".format(self.use_id(), token)
return self._model_store.lang().translate(token) if resolve else token

View File

@ -9,18 +9,17 @@ load_dotenv()
class ConfigManager:
APPLICATION_NAME = "Obscreen"
DEFAULT_PORT = 5000
DEFAULT_PORT_HTTP_EXTERNAL_STORAGE = 5001
VERSION_FILE = 'version.txt'
def __init__(self, replacers: Dict):
self._replacers = replacers
self._CONFIG = {
'application_name': self.APPLICATION_NAME,
'version': None,
'demo': False,
'port_http_external_storage': self.DEFAULT_PORT_HTTP_EXTERNAL_STORAGE,
'bind_http_external_storage': '0.0.0.0',
'chroot_http_external_storage': '%application_dir%/var/run/storage',
'external_storage_mountpoint': '%application_dir%/var/run/storage',
'port': self.DEFAULT_PORT,
'bind': '0.0.0.0',
'debug': False,
@ -54,9 +53,7 @@ class ConfigManager:
parser.add_argument('--log-level', '-ll', default=self._CONFIG['log_level'], help='Log Level')
parser.add_argument('--log-stdout', '-ls', default=self._CONFIG['log_stdout'], action='store_true', help='Log to standard output')
parser.add_argument('--demo', '-o', default=self._CONFIG['demo'], help='Demo mode to showcase obscreen in a sandbox')
parser.add_argument('--port-http-external-storage', '-bx', default=self._CONFIG['port_http_external_storage'], help='Port of http server serving external storage')
parser.add_argument('--bind-http-external-storage', '-px', default=self._CONFIG['bind_http_external_storage'], help='Bind address of http server serving external storage')
parser.add_argument('--chroot-http-external-storage', '-cx', default=self._CONFIG['chroot_http_external_storage'], help='Chroot directory of http server serving external storage')
parser.add_argument('--external-storage-mountpoint', '-e', default=self._CONFIG['external_storage_mountpoint'], help='Mountpoint directory of external storage')
parser.add_argument('--version', '-v', default=None, action='store_true', help='Get version number')
return parser.parse_args()
@ -72,12 +69,8 @@ class ConfigManager:
self._CONFIG['debug'] = args.debug
if args.demo:
self._CONFIG['demo'] = args.demo
if args.port_http_external_storage:
self._CONFIG['port_http_external_storage'] = args.port_http_external_storage
if args.bind_http_external_storage:
self._CONFIG['bind_http_external_storage'] = args.bind_http_external_storage
if args.chroot_http_external_storage:
self._CONFIG['chroot_http_external_storage'] = args.chroot_http_external_storage
if args.external_storage_mountpoint:
self._CONFIG['external_storage_mountpoint'] = args.external_storage_mountpoint
if args.log_file:
self._CONFIG['log_file'] = args.log_file
if args.secret_key:
@ -87,7 +80,7 @@ class ConfigManager:
if args.log_stdout:
self._CONFIG['log_stdout'] = args.log_stdout
if args.version:
print("Obscreen version v{} (https://github.com/jr-k/obscreen)".format(self._CONFIG['version']))
print("{} version v{} (https://github.com/jr-k/obscreen)".format(self.APPLICATION_NAME, self._CONFIG['version']))
sys.exit(0)
def load_from_env(self) -> None:

View File

@ -2,9 +2,11 @@ import os
from typing import Dict, Optional, List, Tuple, Union
from werkzeug.datastructures import FileStorage
from flask import url_for
from src.model.entity.Content import Content
from src.model.entity.Playlist import Playlist
from src.model.enum.ContentMetadata import ContentMetadata
from src.model.enum.ContentType import ContentType
from src.util.utils import get_yt_video_id
from src.manager.DatabaseManager import DatabaseManager
@ -15,7 +17,9 @@ from src.manager.VariableManager import VariableManager
from src.service.ModelManager import ModelManager
from src.util.UtilFile import randomize_filename
from src.util.UtilNetwork import get_preferred_ip_address
from src.util.UtilVideo import mp4_duration_with_ffprobe
from src.util.UtilVideo import get_video_metadata
from src.util.UtilPicture import get_picture_metadata
from src.util.utils import encode_uri_component
class ContentManager(ModelManager):
@ -26,7 +30,8 @@ class ContentManager(ModelManager):
"name CHAR(255)",
"type CHAR(30)",
"location TEXT",
"duration INTEGER",
"duration FLOAT",
"metadata TEXT",
"folder_id INTEGER",
"created_by CHAR(255)",
"updated_by CHAR(255)",
@ -38,6 +43,12 @@ class ContentManager(ModelManager):
super().__init__(lang_manager, database_manager, user_manager, variable_manager)
self._config_manager = config_manager
self._db = database_manager.open(self.TABLE_NAME, self.TABLE_MODEL)
self.pre_migrate()
def pre_migrate(self):
if not self._variable_manager.get_one_by_name('refresh_all_metadata').as_bool():
self.refresh_all_metadata()
self._variable_manager.update_by_name('refresh_all_metadata', True)
def hydrate_object(self, raw_content: dict, id: int = None) -> Content:
if id:
@ -71,10 +82,12 @@ class ContentManager(ModelManager):
def get_all(self, sort: Optional[str] = 'created_at', ascending=False) -> List[Content]:
return self.hydrate_list(self._db.get_all(table_name=self.TABLE_NAME, sort=sort, ascending=ascending))
def get_all_indexed(self, attribute: str = 'id', multiple=False) -> Dict[str, Content]:
def get_all_indexed(self, attribute: str = 'id', multiple=False, query: str = None) -> Dict[str, Content]:
index = {}
for item in self.get_contents():
items = self.get_by(query) if query else self.get_contents()
for item in items:
id = getattr(item, attribute)
if multiple:
if id not in index:
@ -92,14 +105,17 @@ class ContentManager(ModelManager):
for content_id, edits in edits_contents.items():
self._db.update_by_id(self.TABLE_NAME, content_id, edits)
def get_contents(self, slide_id: Optional[id] = None, folder_id: Optional[id] = None) -> List[Content]:
def get_contents(self, slide_id: Optional[int] = None, folder_id: Optional[int] = None) -> List[Content]:
query = " 1=1 "
if slide_id:
query = "{} {}".format(query, "AND slide_id = {}".format(slide_id))
if folder_id:
query = "{} {}".format(query, "AND folder_id = {}".format(folder_id))
if folder_id is not None:
if folder_id == 0:
query = "{} {}".format(query, "AND folder_id is null")
else:
query = "{} {}".format(query, "AND folder_id = {}".format(folder_id))
return self.get_by(query=query)
@ -127,14 +143,15 @@ class ContentManager(ModelManager):
def post_delete(self, content_id: str) -> str:
return content_id
def update_form(self, id: int, name: str, location: Optional[str] = None) -> Optional[Content]:
def update_form(self, id: int, name: Optional[str] = None, location: Optional[str] = None, metadata: Optional[str] = None) -> Optional[Content]:
content = self.get(id)
if not content:
return
form = {
"name": name,
"name": name if isinstance(name, str) else content.name,
"metadata": metadata if isinstance(metadata, str) else content.metadata
}
if location is not None and location:
@ -193,16 +210,29 @@ class ContentManager(ModelManager):
object_path = os.path.join(upload_dir, object_name)
object.save(object_path)
content.location = object_path
if type == ContentType.VIDEO:
content.duration = mp4_duration_with_ffprobe(content.location)
self.set_metadata(content)
else:
content.location = location
content.location = ContentType.get_initial_location(content.type, location)
self.add_form(content)
return self.get_one_by(query="uuid = '{}'".format(content.uuid))
def set_metadata(self, content: Content) -> str:
if content.type == ContentType.VIDEO:
width, height, duration = get_video_metadata(content.location)
content.duration = duration
content.set_metadata(ContentMetadata.DURATION, duration)
content.set_metadata(ContentMetadata.WIDTH, width)
content.set_metadata(ContentMetadata.HEIGHT, height)
elif content.type == ContentType.PICTURE:
width, height = get_picture_metadata(content.location)
content.set_metadata(ContentMetadata.WIDTH, width)
content.set_metadata(ContentMetadata.HEIGHT, height)
else:
content.init_metadata()
return content.metadata
def delete(self, id: int) -> None:
content = self.get(id)
@ -230,20 +260,36 @@ class ContentManager(ModelManager):
var_external_url = self._variable_manager.get_one_by_name('external_url').as_string().strip().strip('/')
location = content.location
if content.type == ContentType.EXTERNAL_STORAGE:
var_external_storage_url = self._variable_manager.get_one_by_name('external_url_storage').as_string().strip().strip('/')
port_ex_st = self._config_manager.map().get('port_http_external_storage')
if content.type == ContentType.YOUTUBE:
location = content.location
elif content.type == ContentType.TEXT:
pass
elif content.type == ContentType.COMPOSITION:
location = "{}/{}".format(
var_external_storage_url if var_external_storage_url else 'http://{}:{}'.format(get_preferred_ip_address(), port_ex_st),
content.location.strip('/')
var_external_url if len(var_external_url) > 0 else "",
url_for(
'serve_content_composition',
content_id=content.id
).strip('/')
)
elif content.has_file() or content.type == ContentType.EXTERNAL_STORAGE:
location = "{}/{}".format(
var_external_url if len(var_external_url) > 0 else "",
url_for(
'serve_content_file',
content_location=encode_uri_component(content.location),
content_type=content.type.value,
content_id=content.id
).strip('/')
)
elif content.type == ContentType.YOUTUBE:
location = "https://www.youtube.com/watch?v={}".format(content.location)
elif len(var_external_url) > 0 and content.has_file():
location = "{}/{}".format(var_external_url, content.location)
elif content.has_file():
location = "/{}".format(content.location)
elif content.type == ContentType.URL:
location = 'http://' + content.location if not content.location.startswith('http') else content.location
location = 'http://' + content.location if content.location and not content.location.startswith('http') else content.location
return location
return location
def refresh_all_metadata(self):
for content in self.get_all():
self.update_form(
id=content.id,
metadata=self.set_metadata(content)
)

View File

@ -3,6 +3,7 @@ import re
import json
import sqlite3
import logging
import uuid
from sqlite3 import Cursor
from typing import Optional, Dict
@ -213,8 +214,11 @@ class DatabaseManager:
"DELETE FROM settings WHERE name = 'playlist_default_time_sync'",
"DELETE FROM settings WHERE name = 'slide_animation_exit_effect'",
"DELETE FROM settings WHERE name = 'playlist_enabled'",
"DELETE FROM settings WHERE name = 'external_url_storage'",
"UPDATE fleet_player_group SET slug = id WHERE slug = '' or slug is null",
"UPDATE content SET uuid = id WHERE uuid = '' or uuid is null",
"UPDATE slide SET uuid = id WHERE uuid = '' or uuid is null",
"UPDATE user SET apikey = \'{}\' || id WHERE apikey = '' or apikey is null".format(str(uuid.uuid4())),
]
for query in queries:

View File

@ -110,7 +110,7 @@ class FolderManager(ModelManager):
for folder_id, edits in edits_folders.items():
self._db.update_by_id(self.TABLE_NAME, folder_id, edits)
def get_folders(self, parent_id: Optional[id] = None) -> List[Folder]:
def get_folders(self, parent_id: Optional[int] = None) -> List[Folder]:
query = " 1=1 "
if parent_id:
@ -267,3 +267,9 @@ class FolderManager(ModelManager):
def count_subfolders_for_folder(self, folder_id: int) -> int:
return len(self.get_folders(parent_id=folder_id))
@staticmethod
def is_root_drive(path: str):
clean_path = path.strip('/')
clean_root_path = FOLDER_ROOT_PATH.strip('/')
return path == '/' or clean_path == clean_root_path

View File

@ -81,7 +81,7 @@ class NodePlayerManager(ModelManager):
for node_player_id, edits in edits_node_players.items():
self._db.update_by_id(self.TABLE_NAME, node_player_id, edits)
def get_node_players(self, group_id: Optional[int] = None, folder_id: Optional[id] = None, sort: Optional[str] = None, ascending=False) -> List[NodePlayer]:
def get_node_players(self, group_id: Optional[int] = None, folder_id: Optional[int] = None, sort: Optional[str] = None, ascending=False) -> List[NodePlayer]:
query = " 1=1 "
if group_id:

View File

@ -3,6 +3,7 @@ import os
from typing import Dict, Optional, List, Tuple, Union
from src.model.entity.Playlist import Playlist
from src.model.enum.ContentType import ContentType
from src.util.utils import get_optional_string, get_yt_video_id, slugify, slugify_next
from src.manager.DatabaseManager import DatabaseManager
from src.manager.SlideManager import SlideManager
@ -69,15 +70,17 @@ class PlaylistManager(ModelManager):
durations = self._db.execute_read_query("""
SELECT
playlist_id,
SUM(CASE
ROUND(SUM(CASE
WHEN s.delegate_duration = 1 THEN c.duration
WHEN c.type = '{}' THEN s.duration
ELSE s.duration
END) AS total_duration
END)) AS total_duration
FROM {} s
LEFT JOIN {} c ON c.id = s.content_id
WHERE cron_schedule IS NULL {}
WHERE cron_schedule IS NULL {} AND s.enabled is TRUE
GROUP BY playlist_id;
""".format(
ContentType.EXTERNAL_STORAGE.value,
SlideManager.TABLE_NAME,
ContentManager.TABLE_NAME,
"{}".format(
@ -139,7 +142,9 @@ GROUP BY playlist_id;
return playlist
def pre_update(self, playlist: Dict) -> Dict:
playlist = self.slugify(playlist)
if 'slug' in playlist:
playlist = self.slugify(playlist)
self.user_manager.track_user_on_update(playlist)
return playlist
@ -158,18 +163,22 @@ GROUP BY playlist_id;
def post_delete(self, playlist_id: str) -> str:
return playlist_id
def update_form(self, id: int, name: Optional[str] = None, time_sync: Optional[bool] = None, enabled: Optional[bool] = None) -> None:
def update_form(self, id: int, name: Optional[str] = None, time_sync: Optional[bool] = None, enabled: Optional[bool] = None, fallback: Optional[bool] = None) -> None:
playlist = self.get(id)
if not playlist:
return
form = {
"name": name,
"time_sync": time_sync if isinstance(time_sync, bool) else slide.time_sync,
"enabled": enabled if isinstance(enabled, bool) else slide.enabled,
"name": name if isinstance(name, str) else playlist.name,
"time_sync": time_sync if isinstance(time_sync, bool) else playlist.time_sync,
"enabled": enabled if isinstance(enabled, bool) else playlist.enabled,
"fallback": fallback if isinstance(fallback, bool) else playlist.fallback,
}
if name != playlist.name:
form["slug"] = True
self._db.update_by_id(self.TABLE_NAME, id, self.pre_update(form))
if playlist.fallback and not enabled:
@ -178,7 +187,7 @@ GROUP BY playlist_id;
self.post_update(id)
def check_and_set_fallback(self):
if len(self.get_by("fallback = 1")) == 0:
if self.count_fallbacks() == 0:
self.set_fallback()
def set_fallback(self, playlist_id: Optional[int] = 0) -> None:
@ -218,3 +227,8 @@ GROUP BY playlist_id;
def to_dict(self, playlists: List[Playlist]) -> List[Dict]:
return [playlist.to_dict() for playlist in playlists]
def count_all(self):
return len(self.get_all())
def count_fallbacks(self):
return len(self.get_by("fallback = 1"))

View File

@ -16,6 +16,7 @@ class SlideManager(ModelManager):
TABLE_NAME = "slides"
TABLE_MODEL = [
"uuid CHAR(255)",
"enabled INTEGER DEFAULT 0",
"delegate_duration INTEGER DEFAULT 0",
"is_notification INTEGER DEFAULT 0",
@ -136,18 +137,19 @@ class SlideManager(ModelManager):
for slide_id, slide_position in positions.items():
self._db.update_by_id(self.TABLE_NAME, slide_id, {"position": slide_position})
def update_form(self, id: int, duration: Optional[int] = None, content_id: Optional[int] = None, delegate_duration: Optional[bool] = None, is_notification: bool = False, cron_schedule: Optional[str] = '', cron_schedule_end: Optional[str] = '', enabled: Optional[bool] = None) -> Optional[Slide]:
def update_form(self, id: int, duration: Optional[int] = None, content_id: Optional[int] = None, delegate_duration: Optional[bool] = None, is_notification: Optional[bool] = None, cron_schedule: Optional[str] = '', cron_schedule_end: Optional[str] = '', enabled: Optional[bool] = None, position: Optional[int] = None) -> Optional[Slide]:
slide = self.get(id)
if not slide:
return
form = {
"duration": duration if duration else slide.duration,
"content_id": content_id if content_id else slide.content_id,
"duration": duration if duration and int(duration) >= 0 else slide.duration,
"content_id": content_id if isinstance(content_id, int) else slide.content_id,
"position": position if isinstance(position, int) else slide.position,
"enabled": enabled if isinstance(enabled, bool) else slide.enabled,
"delegate_duration": delegate_duration if isinstance(delegate_duration, bool) else slide.delegate_duration,
"is_notification": True if is_notification else False,
"is_notification": is_notification if isinstance(is_notification, bool) else slide.is_notification,
"cron_schedule": get_optional_string(cron_schedule),
"cron_schedule_end": get_optional_string(cron_schedule_end)
}
@ -156,7 +158,7 @@ class SlideManager(ModelManager):
self.post_update(id)
return self.get(id)
def add_form(self, slide: Union[Slide, Dict]) -> None:
def add_form(self, slide: Union[Slide, Dict]) -> Slide:
form = slide
if not isinstance(slide, dict):
@ -165,6 +167,7 @@ class SlideManager(ModelManager):
self._db.add(self.TABLE_NAME, self.pre_add(form))
self.post_add(slide.id)
return self.get_one_by(query="uuid = '{}'".format(slide.uuid))
def delete(self, id: int) -> None:
slide = self.get(id)

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