Compare commits

..

3 Commits

Author SHA1 Message Date
be6516d0a1 Update version.txt
All checks were successful
Release build and push docker image / build-and-push-release (push) Successful in 6m45s
2024-10-14 17:49:42 +00:00
1f6ed076c9 Add .github/actions/common-docker-build/action.yml 2024-10-14 17:49:28 +00:00
75f041d337 Update .github/workflows/master.yml 2024-10-14 17:49:10 +00:00
168 changed files with 842 additions and 5615 deletions

View File

@ -1,27 +1,18 @@
.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
*.swp
var/run/*
!var/run/.gitkeep
.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,7 +5,12 @@ SECRET_KEY=ANY_SECRET_KEY_HERE
# Application Server
PORT=5000
BIND=0.0.0.0
EXTERNAL_STORAGE_MOUNTPOINT=%application_dir%/var/run/storage
# 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
# Misc
DEMO=false

1
.github/FUNDING.yml vendored
View File

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

0
.github/actions/common-docker-build/action.yml vendored Executable file → Normal file
View File

View File

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

View File

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

View File

@ -24,7 +24,7 @@ jobs:
with:
build_tags: |
csmith1865/obscreen:v${{ steps.version.outputs.VERSION }}
csmith1865/obscreen:latest
csmith1865/obscreen:latest
manifest_tags: type=semver,pattern=v${{ steps.version.outputs.VERSION }}
flavor: latest=true
docker_username: ${{ secrets.DOCKER_USERNAME }}

20
.gitignore vendored
View File

@ -4,27 +4,19 @@
*.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
*.egg-info
/build/
/dist/
tmp.py

View File

@ -1,24 +1,13 @@
FROM python:3.9-slim-bullseye
FROM python:3.9.17-alpine3.17
# Install ffmpeg and other dependencies
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/*
RUN apk add --no-cache gcc musl-dev sqlite-dev ntfs-3g ffmpeg build-base linux-headers
WORKDIR /app
COPY . .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install -r requirements.txt
ENTRYPOINT ["python", "/app/obscreen.py"]

View File

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

View File

@ -6,15 +6,10 @@
Obscreen is a user-friendly self-hosted digital signage tool leveraging chromium browser.
<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>
<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>
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-playlist-edit.png" width="700" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/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
@ -24,7 +19,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
@ -33,12 +28,11 @@ 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
- 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
- Plugin system to extend capabilities
- [Multi Languages](https://github.com/jr-k/obscreen/tree/master/lang)
- No costly monthly pricing plan per screen or whatever, no cloud, no telemetry
## 👨‍🍳 How to install
## 👨‍🍳 Cookbooks
### 🔴 [I want to power a RaspberryPi and automatically see my slideshow on a screen connected to it and manage the slideshow](docs/setup-run-on-rpi.md)
### 🔵 [I just want a slideshow manager and I'll deal with screen and browser myself](docs/setup-run-headless.md)
@ -47,19 +41,19 @@ It is a temporary live demo, all data will be deleted after 30 minutes (~30secs
Light Mode:
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-light-mode.png" width="512" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-light-mode.png" width="512" alt="" />
Content Explorer:
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-content-explorer.png" width="512" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-content-explorer.png" width="512" alt="" />
Settings Page:
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-settings.png" width="512" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-settings.png" width="512" alt="" />
Add Content Modal:
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-add-content.png" width="512" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-add-content.png" width="512" alt="" />
## 🫡 Motivation
@ -71,30 +65,23 @@ 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://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/img/discord.png" width="64">](https://discord.obscreen.io)
[<img src="https://github.com/jr-k/obscreen/blob/master/docs/img/discord.png" width="64">](https://discord.obscreen.io)
### Open an Issue
[<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)
[<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)
### Troubleshoot
<details closed>
<summary><h3>Why aren't the videos starting?</h3></summary>
<summary><h3>Videos aren't playing why ?</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.
---
@ -112,7 +99,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://git.sumisu.xyz/csmith1865/obscreen/src/branch/master/lang).
If you want to translate Obscreen into your language, please visit [Languages Files](https://github.com/jr-k/obscreen/blob/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,8 +1,6 @@
jQuery(document).ready(function ($) {
const main = function () {
$('.user-token-reveal').each(function() {
updateTokenReveal($(this), false);
});
};
$(document).on('click', '.user-add', function () {
@ -19,26 +17,5 @@ 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();
});

View File

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

@ -31,20 +31,13 @@ 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');
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}`);
if (response.status != 200) {
const $alert = $('.alert-danger').removeClass('hidden');
if (response.status == 413) {
$alert.text(l.js_common_http_error_413);
} else {
$alert.html(`<i class="fa fa-warning"></i>${l.js_common_http_error_occured.replace('%code%', statusCode)}`);
$alert.text(l.js_common_http_error_occured.replace('%code%', response.status));
}
} else {
document.location.reload();

View File

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

View File

@ -30,10 +30,6 @@ 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');
@ -166,12 +162,5 @@ 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

@ -1,13 +0,0 @@
(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);

View File

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

File diff suppressed because one or more lines are too long

View File

@ -21,81 +21,14 @@ jQuery(document).ready(function ($) {
});
$(document).on('click', '.playlist-preview', function () {
const $icon = $(this).find('i');
const isPlay = $icon.hasClass('fa-play');
const $holder = $(this).parents('.preview:eq(0)');
const $iframe = $('<iframe>', {
src: $(this).attr('data-url'),
frameborder: 0
});
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');
}
$(this).parents('.preview:eq(0)').append($iframe);
$(this).remove();
});
//
// $(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.description_edition === null);
$('#variable-edit-description-edition').html(variable.description_edition).toggleClass('hidden', variable.description_edition === '');
$('#variable-edit-value').val(variable.value);
$('#variable-edit-id').val(variable.id);
});

View File

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

@ -1,79 +0,0 @@
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,11 +21,7 @@ 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}`,
classColorXor(color, '')
].join(' '));
$form.find('button[type=submit]').attr('class', 'btn ' + ('btn-' + color));
};
const main = function () {

View File

@ -81,9 +81,3 @@ 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,16 +31,8 @@ main {
margin-right: 20px;
}
.context-tail {
margin-right: 30px;
.btn {
margin-right: 0;
}
}
.context-tail-auth {
margin-right: 10px;
.contex-tail {
margin-right: 20px;
.btn {
margin-right: 0;

View File

@ -26,10 +26,6 @@ body, html {
align-items: flex-start;
flex: 1;
align-self: stretch;
&.fx-end {
justify-content: flex-end;
}
}
.vertical {
@ -119,24 +115,9 @@ main {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
padding: 10px 10px 40px 10px;
padding: 0 10px 40px 10px;
background: $layoutBackground;
align-self: stretch;
h1, h2, h3, h4, h5, h6 {
color: $gscaleD;
}
p {
font-size: 12px;
line-height: 18px;
display: flex;
margin-bottom: 5px;
flex-direction: row;
justify-content: flex-start;
align-items: center;
color: $gscale6;
}
}
}

View File

@ -1,12 +0,0 @@
@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,10 +1,32 @@
.badge-inset {
display: inline;
color: $gscaleA;
font-size: 12px;
margin-left: 5px;
background: $gscale0;
border: 1px solid $gscale3;
border-radius: $baseRadius;
padding: 3px 7px;
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;
}

View File

@ -1,4 +1,3 @@
button,
.btn {
$shadowOffset: 2px;
@ -57,7 +56,6 @@ 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%);
@ -143,3 +141,4 @@ button,
cursor: default;
}
}

View File

@ -197,7 +197,7 @@ ul.explr-dirview {
.img-holder {
width: 64px;
height: 64px;
background: $black;
background: $gscale17;
border-radius: 8px;
display: flex;
flex-direction: column;

View File

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

View File

@ -139,30 +139,6 @@
}
}
}
&.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,24 +81,6 @@ 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;
@ -118,11 +100,12 @@ form {
}
}
input + .btn + .btn {
.btn {
margin-left: 10px;
}
&.widget-unit {
select,
input {
flex-grow: 0;
@ -148,33 +131,6 @@ 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;
@ -199,17 +155,23 @@ 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,20 +18,19 @@
@import 'components/modals';
@import 'components/toast';
@import 'components/dragdrop';
@import 'components/animation';
// Legacy
@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

@ -7,16 +7,8 @@ menu:hover h1.logo a {
color: transparent;
}
ul.explr-dirview li a {
&.with-thumbnail {
.img-holder {
background: $gkscaleD;
}
}
i {
color: $seaBlue;
}
ul.explr-dirview li a i {
color: $seaBlue;
}
button,
@ -43,25 +35,6 @@ 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 {
@ -212,5 +185,3 @@ ul.pills {
background: $gkscaleF7;
}
}

View File

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

@ -1,155 +0,0 @@
.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,29 +23,6 @@
.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;
@ -61,11 +38,32 @@
align-self: stretch;
display: flex;
flex-direction: column;
overflow: auto;
overflow: hidden;
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;
@ -89,3 +87,7 @@
}
}

View File

@ -75,27 +75,12 @@
}
}
.preview-holder {
position: relative;
.preview-holder {
.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,75 +11,25 @@
align-self: stretch;
color: $gscale6;
}
//
//.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-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-slide {
h2 {
@ -148,28 +98,12 @@
}
}
.preview-holder {
position: relative;
.preview-holder {
.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,35 +15,9 @@
}
.tile-tail {
.btn {
a:last-child {
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,7 +14,6 @@ $layoutBorder: 1px solid $gscale2;
// Packs
$colors: (
warning: $warning,
orange: $orange,
info: $info,
info-alt: $bitterBlue,
success: $success,
@ -40,8 +39,6 @@ $colors: (
redhat:$redhat,
centos:$centos,
other:$other,
gscale0:$gscale0,
gscaleF:$gscaleF,
);
// Classes

View File

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

View File

@ -2,16 +2,17 @@ services:
webapp:
container_name: obscreen
restart: unless-stopped
image: csmith1865/obscreen:latest
image: jierka/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

View File

@ -1,42 +0,0 @@
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,20 +62,10 @@ 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.
@ -120,23 +110,7 @@ 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 \
--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
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
```
---

View File

@ -1,6 +1,6 @@
# <img src="https://raw.githubusercontent.com/csmith1865/obscreen/refs/heads/master/docs/img/obscreen.png" width="22"> Obscreen - Autorun on RaspberryPi
# <img src="https://github.com/jr-k/obscreen/blob/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,20 +20,10 @@
#### Install
- Install studio by executing following script
##### Linux
```bash
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
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/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.
@ -82,7 +72,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/csmith1865/obscreen/master/docker-compose.yml > docker-compose.yml
curl https://raw.githubusercontent.com/jr-k/obscreen/master/docker-compose.yml > docker-compose.yml
# Run
docker compose up --detach --pull=always
@ -106,7 +96,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/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
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
sudo reboot
```
@ -114,7 +104,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 if you never did after obscreen player installation; with command `sudo reboot`
1. First you have to reboot your device with `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)
---
@ -128,23 +118,7 @@ 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 \
--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
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
```
---

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 '%contentName%' is referenced in a slide, remove slide first",
"slideshow_content_referenced_in_slide_error": "Content 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,7 +105,6 @@
"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",
@ -184,12 +183,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)",
@ -237,7 +236,6 @@
"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",
@ -255,27 +253,12 @@
"common_apply": "Apply",
"common_saved": "Changes have been saved",
"common_new_folder": "New Folder",
"common_folder_not_empty_error": "Folder '%folderName%' isn't empty, you must delete its content first",
"common_folder_not_empty_error": "Folder 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",
@ -307,10 +290,6 @@
"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",
@ -329,8 +308,8 @@
"enum_operating_system_redhat": "RedHat",
"enum_operating_system_centos": "CentOS",
"enum_operating_system_other": "Other",
"sysinfo_device_model": "Device model",
"sysinfo_device_model_unknown": "Unknown model",
"sysinfo_rpi_model": "Raspberry Pi Model",
"sysinfo_rpi_model_unknown": "Not a Raspberry Pi or model information not available",
"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 '%contentName%' en una diapositiva; elimine la diapositiva primero",
"slideshow_content_referenced_in_slide_error": "Se hace referencia al contenido 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,7 +105,6 @@
"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",
@ -185,12 +184,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)",
@ -238,7 +237,6 @@
"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",
@ -256,27 +254,12 @@
"common_apply": "Aplicar",
"common_saved": "Los cambios se han guardado",
"common_new_folder": "Nuevo Carpeta",
"common_folder_not_empty_error": "La carpeta '%folderName%' no está vacía, primero debes eliminar su contenido",
"common_folder_not_empty_error": "La carpeta 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",
@ -308,10 +291,6 @@
"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",
@ -330,8 +309,8 @@
"enum_operating_system_redhat": "RedHat",
"enum_operating_system_centos": "CentOS",
"enum_operating_system_other": "Otro",
"sysinfo_device_model": "Modelo del dispositivo",
"sysinfo_device_model_unknown": "Modelo desconocido",
"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_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 '%contentName%' est référencé dans une slide, supprimez d'abord la slide",
"slideshow_content_referenced_in_slide_error": "Le contenu 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,7 +106,6 @@
"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",
@ -186,12 +185,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)",
@ -239,7 +238,6 @@
"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",
@ -257,27 +255,12 @@
"common_apply": "Appliquer",
"common_saved": "Les modifications ont été enregistrées",
"common_new_folder": "Nouveau Dossier",
"common_folder_not_empty_error": "Le dossier '%folderName%' n'est pas vide, vous devez d'abord supprimer son contenu",
"common_folder_not_empty_error": "Le dossier 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",
@ -309,10 +292,6 @@
"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",
@ -331,8 +310,8 @@
"enum_operating_system_redhat": "RedHat",
"enum_operating_system_centos": "CentOS",
"enum_operating_system_other": "Autre",
"sysinfo_device_model": "Modèle de l'appareil",
"sysinfo_device_model_unknown": "Modèle inconnu",
"sysinfo_rpi_model": "Modèle du Raspberry Pi",
"sysinfo_rpi_model_unknown": "Le modèle n'est pas un Raspberry Pi",
"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 '%contentName%' in una diapositiva, rimuovere prima la diapositiva",
"slideshow_content_referenced_in_slide_error": "Si fa riferimento al contenuto 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,7 +105,6 @@
"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",
@ -185,12 +184,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)",
@ -238,7 +237,6 @@
"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",
@ -256,27 +254,12 @@
"common_apply": "Applica",
"common_saved": "Le modifiche sono state salvate",
"common_new_folder": "Nuovo Cartella",
"common_folder_not_empty_error": "La cartella '%folderName%' non è vuota, devi prima eliminarne il contenuto",
"common_folder_not_empty_error": "La cartella 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",
@ -308,10 +291,6 @@
"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",
@ -330,8 +309,8 @@
"enum_operating_system_redhat": "RedHat",
"enum_operating_system_centos": "CentOS",
"enum_operating_system_other": "Altro",
"sysinfo_device_model": "Modello del dispositivo",
"sysinfo_device_model_unknown": "Modello sconosciuto",
"sysinfo_rpi_model": "Raspberry Pi Model",
"sysinfo_rpi_model_unknown": "Informazioni Raspberry Pi non disponibili",
"sysinfo_storage_free_space": "Spazio libero",
"sysinfo_memory_usage": "Memoria usata",
"sysinfo_os_version": "OS Version",

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

@ -10,11 +10,8 @@ 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 'core_updater'
return 'git_updater'
def use_title(self):
return self.translate('plugin_title')
@ -22,9 +19,6 @@ 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 CoreUpdaterController(ObController):
class GitUpdaterController(ObController):
def register(self):
self._app.add_url_rule('/core-updater/update/now', 'core_updater_update_now', self._auth(self.update_now), methods=['GET'])
self._app.add_url_rule('/git-updater/update/now', 'git_updater_update_now', self._auth(self.update_now), methods=['GET'])
def update_now(self):
debug = self._model_store.config().map().get('debug')
@ -43,7 +43,6 @@ class CoreUpdaterController(ObController):
elif os_name == "darwin":
logging.warn('Git Updater doesn\'t supports macos dependency manager, install system dependencies manually with homebrew')
run_system_command(['git', 'config', '--global', '--add', 'safe.directory', get_working_directory()])
run_system_command(['git', '-C', get_working_directory(), 'stash'])
run_system_command(['git', '-C', get_working_directory(), 'checkout', 'master'])
run_system_command(['git', '-C', get_working_directory(), 'pull'])

View File

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

View File

@ -1,5 +1,5 @@
{
"plugin_title": "Core Updater Button",
"plugin_title": "Botón de Actualización de Git",
"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": "Core Updater Button",
"plugin_title": "Bouton de mise à jour",
"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": "Core Updater Button",
"plugin_title": "Pulsante di aggiornamento",
"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('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

@ -1,35 +0,0 @@
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 Dashboard(ObPlugin):
def get_version(self) -> str:
return '1.0'
def use_id(self):
return 'dashboard'
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 None
def use_variables(self) -> List[Variable]:
return []
def use_hooks_registrations(self) -> List[HookRegistration]:
return [
super().add_functional_hook_registration(hook=HookType.H_ROOT_NAV_ELEMENT_START, priority=10, function=self.hook_navigation),
]
def hook_navigation(self) -> str:
return self.render_view('@hook_navigation.jinja.html')

View File

@ -1,15 +0,0 @@
from flask import Flask, render_template
from src.interface.ObController import ObController
class DashboardController(ObController):
def register(self):
self._app.add_url_rule('/dashboard', 'dashboard', self._auth(self.dashboard), methods=['GET'])
def dashboard(self):
return self.render_view(
'@dashboard.jinja.html',
count_players=len(self._model_store.node_player().get_node_players())
)

View File

@ -1,5 +0,0 @@
{
"plugin_title": "Dashboard",
"plugin_description": "Adds a dashboard reachable from navigation (has no use - developer demo plugin only)",
"menu_title": "Dashboard"
}

View File

@ -1,5 +0,0 @@
{
"plugin_title": "Panel de control",
"plugin_description": "Agrega un panel de control accesible desde la navegación (no utilizado, solo complemento de demostración para desarrolladores)",
"menu_title": "Panel de control"
}

View File

@ -1,5 +0,0 @@
{
"plugin_title": "Tableau de bord",
"plugin_description": "Ajoute un tableau de bord accessible depuis la navigation (n'a aucune utilisé - plugin de démo développeur seulement)",
"menu_title": "Tableau de bord"
}

View File

@ -1,5 +0,0 @@
{
"plugin_title": "Dashboard",
"plugin_description": "Aggiunge una dashboard raggiungibile dalla navigazione (non utilizzato - solo plug-in demo per sviluppatori)",
"menu_title": "Dashboard"
}

View File

@ -1,3 +0,0 @@
.inner h3 {
color: red !important;
}

View File

@ -1,53 +0,0 @@
{% extends '::base.jinja.html' %}
{% block page_title %}
{{ l.dashboard_menu_title }}
{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{{ STATIC_PLUGIN_PREFIX }}css/dashboard.css"/>
{% endblock %}
{% block add_js %}
{% endblock %}
{% block body_class %}view-dashboard{% endblock %}
{% block pill_menu %}
{% with pills=[
{"name": l.dashboard_menu_title, "route": "dashboard", "icon": "fa-dashboard"},
] %}
{% include '::core/pill-menu.jinja.html' %}
{% endwith %}
{% endblock %}
{% block top_page %}
<div class="top-content">
<div class="top-actions">
<button type="button" class="btn btn-neutral">
<i class="fa fa-hand"></i>
</button>
</div>
</div>
{% endblock %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">
<div class="inner">
<div class="vertical">
<h3>
{{ l.dashboard_menu_title }}
</h3>
<p>Hello world ! You have {{ count_players }} player(s)</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,5 +0,0 @@
<li class="{{ 'active' if request.url_rule.endpoint == 'dashboard' }}">
<a href="{{ url_for('dashboard') }}">
<i class="fa fa-dashboard"></i> {{ l.dashboard_menu_title }}
</a>
</li>

View File

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

View File

@ -1,56 +0,0 @@
# 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,6 +6,7 @@ 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
@ -18,6 +19,7 @@ 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)
@ -29,6 +31,7 @@ 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:
@ -59,3 +62,7 @@ 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,3 +1,4 @@
class WebDirConstant:
FOLDER_TEMPLATES = "views"
@ -5,6 +6,6 @@ class WebDirConstant:
FOLDER_STATIC_WEB_UPLOADS = "uploads"
FOLDER_STATIC_WEB_ASSETS = "www"
FOLDER_PLUGIN_HOOK = "hook"
FOLDER_PLUGIN_STATIC_SRC = "static"
FOLDER_PLUGIN_STATIC_DST = "plugins"
FOLDER_CONTROLLER = "controller"

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify, flash
from flask import Flask, render_template, redirect, request, url_for, jsonify
from flask_login import login_user, logout_user, current_user
from src.service.ModelStore import ModelStore
from src.model.entity.User import User
@ -26,6 +26,8 @@ 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'))
@ -39,12 +41,13 @@ class AuthController(ObController):
login_user(user)
return redirect(url_for('playlist'))
else:
flash(self.t('login_error_bad_credentials'), 'error')
login_error = 'bad_credentials'
else:
flash(self.t('login_error_not_found'), 'error')
login_error = 'not_found'
return render_template(
'auth/login.jinja.html',
login_error=login_error,
last_username=request.form['username'] if 'username' in request.form else None
)
@ -64,8 +67,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):
@ -92,12 +95,10 @@ class AuthController(ObController):
return redirect(url_for('auth_user_list'))
if user.id == str(current_user.id):
flash(self.t('auth_user_delete_cant_delete_yourself'), 'error')
return redirect(url_for('auth_user_list'))
return redirect(url_for('auth_user_list', error='auth_user_delete_cant_delete_yourself'))
if self._model_store.user().count_all_enabled() == 1:
flash(self.t('auth_user_delete_at_least_one_account'), 'error')
return redirect(url_for('auth_user_list'))
return redirect(url_for('auth_user_list', error='auth_user_delete_at_least_one_account'))
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, flash
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
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,10 +28,11 @@ 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_folder_context(self):
def get_working_folder(self):
working_folder_path = request.args.get('path', None)
working_folder = None
@ -46,7 +47,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_folder_context()
working_folder_path, working_folder = self.get_working_folder()
slides_with_content = self._model_store.slide().get_all_indexed(attribute='content_id', multiple=True)
return render_template(
@ -59,11 +60,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,
external_storage_mountpoint=self._model_store.config().map().get('external_storage_mountpoint'),
chroot_http_external_storage=self.get_external_storage_server().get_directory(),
)
def slideshow_content_add(self):
working_folder_path, working_folder = self.get_folder_context()
working_folder_path, working_folder = self.get_working_folder()
route_args = {
"path": working_folder_path,
}
@ -85,7 +86,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_folder_context()
working_folder_path, working_folder = self.get_working_folder()
for key in request.files:
files = request.files.getlist(key)
@ -110,30 +111,19 @@ class ContentController(ObController):
if not content:
return abort(404)
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'
working_folder_path, working_folder = self.get_working_folder()
return render_template(
edit_view,
'slideshow/contents/edit.jinja.html',
content=content,
working_folder_path=working_folder_path,
working_folder=working_folder,
enum_content_type=ContentType,
enum_content_metadata=ContentMetadata,
external_storage_mountpoint=self._model_store.config().map().get('external_storage_mountpoint'),
**vargs
chroot_http_external_storage=self.get_external_storage_server().get_directory(),
)
def slideshow_content_save(self, content_id: int = 0):
working_folder_path, working_folder = self.get_folder_context()
working_folder_path, working_folder = self.get_working_folder()
content = self._model_store.content().get(content_id)
if not content:
@ -146,24 +136,22 @@ class ContentController(ObController):
)
self._post_update()
flash(self.t('common_saved'), 'success')
return redirect(url_for('slideshow_content_edit', content_id=content_id))
return redirect(url_for('slideshow_content_edit', content_id=content_id, saved=1))
def slideshow_content_delete(self):
working_folder_path, working_folder = self.get_folder_context()
error = self.delete_content_by_id(request.args.get('id'))
working_folder_path, working_folder = self.get_working_folder()
error_tuple = self.delete_content_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error:
flash(error, 'error')
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
return redirect(url_for('slideshow_content_list', **route_args))
def slideshow_content_rename(self):
working_folder_path, working_folder = self.get_folder_context()
working_folder_path, working_folder = self.get_working_folder()
self._model_store.content().update_form(
id=request.form['id'],
name=request.form['name'],
@ -194,7 +182,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_folder_context()
working_folder_path, working_folder = self.get_working_folder()
self._model_store.folder().add_folder(
entity=FolderEntity.CONTENT,
@ -205,7 +193,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_folder_context()
working_folder_path, working_folder = self.get_working_folder()
self._model_store.folder().rename_folder(
folder_id=request.form['id'],
name=request.form['name'],
@ -214,7 +202,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_folder_context()
working_folder_path, working_folder = self.get_working_folder()
entity_ids = request.form['entity_ids'].split(',')
folder_ids = request.form['folder_ids'].split(',')
@ -237,36 +225,44 @@ 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_folder_context()
error = self.delete_folder_by_id(request.args.get('id'))
working_folder_path, working_folder = self.get_working_folder()
error_tuple = self.delete_folder_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error:
flash(self.t(error), 'error')
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
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_folder_context()
working_folder_path, working_folder = self.get_working_folder()
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 = self.delete_content_by_id(id)
error_tuple = self.delete_content_by_id(id)
if error:
flash(error, 'error')
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
for id in folder_ids:
if id:
error = self.delete_folder_by_id(id)
error_tuple = self.delete_folder_by_id(id)
if error:
flash(error, 'error')
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
return redirect(url_for('slideshow_content_list', **route_args_dict))
@ -277,7 +273,7 @@ class ContentController(ObController):
return None
if self._model_store.slide().count_slides_for_content(content.id) > 0:
return 'slideshow_content_referenced_in_slide_error'.replace('%contentName%', content.name)
return 'referenced_in_slide_error', content.name
self._model_store.content().delete(content.id)
self._post_update()
@ -293,7 +289,7 @@ class ContentController(ObController):
folder_counter = self._model_store.folder().count_subfolders_for_folder(folder.id)
if content_counter > 0 or folder_counter:
return self.t('common_folder_not_empty_error').replace('%folderName%', folder.name)
return 'folder_not_empty_error', folder.name
self._model_store.folder().delete(id=folder.id)
self._post_update()

View File

@ -1,8 +1,7 @@
from typing import Optional
from flask import Flask, send_file, render_template_string, jsonify, request
from flask import Flask, send_file, render_template_string, jsonify
from src.interface.ObController import ObController
# from src.util.UtilChromecast import fetch_friendly_names, cast_url
class CoreController(ObController):
@ -10,11 +9,9 @@ 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:
with open("{}/manifest.jinja.json".format(self.get_template_folder()), 'r') as file:
template_content = file.read()
rendered_content = render_template_string(template_content)
@ -22,17 +19,4 @@ 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')
# 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
# })
return send_file("{}/favicon.ico".format(self.get_web_folder()), mimetype='image/x-icon')

View File

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

View File

@ -1,20 +1,18 @@
import os
import json
import logging
import hashlib
import uuid
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional, List, Dict
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort, send_file, Response
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
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, decode_uri_component
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.UtilNetwork import get_safe_remote_addr, get_network_interfaces
from src.model.enum.AnimationSpeed import animation_speed_duration
@ -27,32 +25,20 @@ 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)
if not preview_content_id:
query = " (slug = ? OR id = ?) "
query_args = {
"slug": playlist_slug_or_id,
"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_playlist:
query = query + " AND enabled = ? "
query_args["enabled"] = True
if playlist_slug_or_id and not current_playlist:
return abort(404)
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
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)
@ -64,9 +50,7 @@ class PlayerController(ObController):
polling_interval = int(request.args.get('polling', self._model_store.variable().get_one_by_name('polling_interval').eval()))
slide_animation_speed = request.args.get('animation_speed', self._model_store.variable().get_one_by_name('slide_animation_speed').eval()).lower()
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())
# 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',
@ -75,7 +59,6 @@ class PlayerController(ObController):
polling_interval=polling_interval,
slide_animation_enabled=animation_enabled,
slide_animation_entrance_effect=slide_animation_entrance_effect,
slide_animation_exit_effect=slide_animation_exit_effect,
slide_animation_speed=slide_animation_speed,
animation_speed_duration=animation_speed_duration,
)
@ -86,8 +69,7 @@ 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',
hard_refresh_request=self._model_store.variable().get_one_by_name("refresh_player_request").as_int()
noplaylist=request.args.get('noplaylist', '0') == '1'
)
def player_playlist(self, playlist_slug_or_id: str = ''):
@ -127,7 +109,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 not preview_mode and (playlist_id == 0 or not playlist_id):
if playlist_id == 0 or not playlist_id:
playlist = self._model_store.playlist().get_one_by(query="fallback = 1")
if playlist:
@ -137,9 +119,8 @@ 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)
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
contents = self._model_store.content().get_all_indexed()
playlist = self._model_store.playlist().get(playlist_id)
position = 9999
playlist_loop = []
@ -154,27 +135,23 @@ 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._model_store.config().map().get('external_storage_mountpoint'), content.location)
mount_point_dir = Path(self.get_external_storage_server().get_directory(), slide['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['id'] = str(uuid.uuid4())
slide['position'] = position
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)
slide['type'] = ContentType.guess_content_type_file(str(file.resolve())).value
slide['name'] = file.stem
slide['location'] = "{}/{}".format(
self._model_store.content().resolve_content_location(content),
file.name
)
self._check_slide_enablement(playlist_loop, playlist_notifications, slide)
position = position + 1
else:
@ -218,50 +195,3 @@ 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, flash
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort
from src.service.ModelStore import ModelStore
from src.model.entity.Playlist import Playlist
from src.model.enum.FolderEntity import FolderEntity
@ -35,6 +35,7 @@ class PlaylistController(ObController):
return render_template(
'playlist/list.jinja.html',
error=request.args.get('error', None),
current_playlist=current_playlist,
playlists=playlists,
durations=durations,
@ -54,8 +55,7 @@ class PlaylistController(ObController):
playlist = Playlist(
name=request.form['name'],
enabled=True,
time_sync=False,
fallback=self._model_store.playlist().count_fallbacks() == 0
time_sync=False
)
try:
@ -70,8 +70,7 @@ 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,
fallback=True if self._model_store.playlist().count_fallbacks() == 0 else None
enabled=True if 'enabled' in request.form else False
)
return redirect(url_for('playlist_list', playlist_id=request.form['id']))
@ -82,12 +81,10 @@ class PlaylistController(ObController):
abort(404)
if self._model_store.slide().count_slides_for_playlist(playlist_id) > 0:
flash(self.t('playlist_delete_has_slides'), 'error')
return redirect(url_for('playlist_list', playlist_id=playlist_id))
return redirect(url_for('playlist_list', playlist_id=playlist_id, error='playlist_delete_has_slides'))
if self._model_store.node_player_group().count_node_player_groups_for_playlist(playlist_id) > 0:
flash(self.t('playlist_delete_has_node_player_groups'), 'error')
return redirect(url_for('playlist_list', playlist_id=playlist_id))
return redirect(url_for('playlist_list', playlist_id=playlist_id, error='playlist_delete_has_node_player_groups'))
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, flash
from flask import Flask, render_template, redirect, request, url_for
from typing import Optional
from src.service.ModelStore import ModelStore
@ -40,8 +40,7 @@ class SettingsController(ObController):
error = self._pre_update(request.form['id'])
if error:
flash(error, 'error')
return redirect(url_for('settings_variable_list'))
return redirect(url_for('settings_variable_list', error=error))
self._model_store.variable().update_form(request.form['id'], request.form['value'])
redirect_response = self._post_update(request.form['id'])
@ -55,8 +54,7 @@ class SettingsController(ObController):
error = self._pre_update(request.form['id'])
if error:
flash(error, 'error')
return redirect(url_for('settings_variable_plugin_list'))
return redirect(url_for('settings_variable_plugin_list', error=error))
self._model_store.variable().update_form(request.form['id'], request.form['value'])
redirect_response = self._post_update(request.form['id'])
@ -81,8 +79,7 @@ class SettingsController(ObController):
if variable.name == 'slide_upload_limit':
self.reload_web_server()
flash(self.t('common_restart_needed'), 'warning')
return redirect(url_for('settings_variable_list'))
return redirect(url_for('settings_variable_list', warning='common_restart_needed'))
if variable.name == 'fleet_player_enabled':
self.reload_web_server()
@ -101,10 +98,7 @@ class SettingsController(ObController):
thread = threading.Thread(target=self.plugin_update)
thread.daemon = True
thread.start()
flash(self.t('common_restart_needed'), 'warning')
return redirect(url_for('settings_variable_plugin_list'))
flash(self.t('common_saved'), 'success')
return redirect(url_for('settings_variable_plugin_list', warning='common_restart_needed'))
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, flash
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
from werkzeug.utils import secure_filename
from src.service.ModelStore import ModelStore
from src.model.entity.Slide import Slide
@ -42,9 +42,8 @@ class SlideController(ObController):
slide = Slide(
content_id=content.id if content else request.form['content_id'],
enabled='enabled' in request.form and request.form['enabled'] == '1',
delegate_duration='delegate_duration' in request.form and request.form['delegate_duration'] == '1',
duration=request.form['duration'],
enabled='enabled' in request.form and request.form['enabled'],
is_notification=True if 'is_notification' in request.form and request.form['is_notification'] == '1' else False,
playlist_id=request.form['playlist_id'] if 'playlist_id' in request.form and request.form['playlist_id'] else None,
cron_schedule=get_optional_string(request.form['cron_schedule']),
@ -102,11 +101,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())
return redirect(referrer_path)
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))
def _post_update(self):
self._model_store.variable().update_by_name("last_slide_update", time.time())

View File

@ -69,6 +69,5 @@ 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()],
'hard_refresh_request': self._model_store.variable().get_one_by_name("refresh_player_request").as_int()
'interfaces': [iface['ip_address'] for iface in get_network_interfaces()]
})

View File

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

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