add playlist management

This commit is contained in:
jr-k 2024-05-21 20:35:46 +02:00
parent dc0c975a36
commit a7c986e1b4
48 changed files with 1109 additions and 139 deletions

View File

@ -13,10 +13,10 @@ Use a RaspberryPi (Lite OS) to show a full-screen slideshow (Kiosk-mode)
### Features:
- Dead simple chromium webview
- Clear GUI
- Fleet view to manage many devices easily
- Very few dependencies
- SQLite database
- Plugin system
- Feature flags to enable complex use cases (Fleet/User/Playlist management)
- No stupid pricing plan
- No cloud
- No telemetry
@ -25,6 +25,6 @@ Use a RaspberryPi (Lite OS) to show a full-screen slideshow (Kiosk-mode)
# Two setups available
### 🔴 [I want to power RaspberryPi and automatically see my slideshow on a screen connected to it and manage the slideshow](docs/setup-run-on-rpi.md)
### 🔵 [I want to start browser and setup playlist url manually on my device and just want a slideshow manager](docs/setup-run-headless.md)
### 🔴 [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)

View File

@ -59,6 +59,14 @@ a {
}
}
.container.expand {
min-width: 100%;
@media only screen and (max-width: 1200px) {
min-width: 100%;
}
}
header {
text-align: center;
display: flex;
@ -346,11 +354,17 @@ button.purple:hover {
align-items: flex-start;
}
.panel td.infos .inner {
.panel td .inner {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
align-items: center;
}
.panel td div.badge {
margin-right: 5px;
font-size: 10px;
font-weight: bold;
}
.panel a {
@ -718,4 +732,102 @@ a.badge:hover {
.badge.anonymous {
opacity: .2;
}
.explorer {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
align-self: stretch;
}
.explorer .left {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
align-self: stretch;
}
.explorer .right {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.panel.panel-menu {
display: flex;
flex: 1;
flex-direction: column;
align-self: stretch;
margin-right: 0;
min-width: 250px;
}
.panel.panel-menu ul {
flex: 1;
max-width: 250px;
display: flex;
flex-direction: column;
align-self: stretch;
list-style: none;
margin: 0;
padding: 0;
}
.panel.panel-menu ul li {
margin: 3px 0;
}
.panel.panel-menu ul li a {
padding: 5px 15px 5px 15px;
color: inherit;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
flex: 1;
}
.panel.panel-menu ul li:hover {
font-weight: bold;
}
.panel.panel-menu ul li.active {
color: #fff;
background: rgba(255,255,255,.1);
border-radius: 4px;
font-weight: bold;
border: 1px solid rgba(255,255,255,.2);
}
.explorer .panel-menu {
border-color: #692fbd;
}
.explorer .panel-active {
background: white;
color: #AAA;
}
.explorer .panel-active h3 {
color: #333;
}
.explorer .panel-inactive {
background: white;
color: #AAA;
border-color: #BBB;
}
.explorer .panel.panel-active th {
border-bottom: 1px solid #ddd;
}

View File

@ -92,7 +92,6 @@ jQuery(document).ready(function ($) {
$(document).on('click', '.user-delete', function () {
if (confirm(l.js_auth_user_delete_confirmation)) {
const $tr = $(this).parents('tr:eq(0)');
updateTable();
$.ajax({
method: 'DELETE',
url: '/auth/user/delete',
@ -100,6 +99,7 @@ jQuery(document).ready(function ($) {
data: JSON.stringify({id: getId($(this))}),
success: function(data) {
$tr.remove();
updateTable();
},
error: function(data) {
$('.alert-error').html(data.responseJSON.message).removeClass('hidden');

View File

@ -16,7 +16,7 @@ jQuery(document).ready(function ($) {
}
}).tableDnDUpdate();
updatePositions();
}
};
const showModal = function (modalClass) {
$modalsRoot.removeClass('hidden').find('form').trigger('reset');

View File

@ -0,0 +1,96 @@
jQuery(document).ready(function ($) {
const $tableActive = $('table.active-playlists');
const $tableInactive = $('table.inactive-playlists');
const $modalsRoot = $('.modals');
const getId = function ($el) {
return $el.is('tr') ? $el.attr('data-level') : $el.parents('tr:eq(0)').attr('data-level');
};
const updateTable = function () {
$('table').each(function () {
if ($(this).find('tbody tr.playlist-item:visible').length === 0) {
$(this).find('tr.empty-tr').removeClass('hidden');
} else {
$(this).find('tr.empty-tr').addClass('hidden');
}
});
};
const showModal = function (modalClass) {
$modalsRoot.removeClass('hidden').find('form').trigger('reset');
$modalsRoot.find('.modal').addClass('hidden');
$modalsRoot.find('.modal.' + modalClass).removeClass('hidden');
};
const hideModal = function () {
$modalsRoot.addClass('hidden').find('form').trigger('reset');
};
const main = function () {
};
$(document).on('change', 'input[type=checkbox]', function () {
$.ajax({
url: '/playlist/toggle',
headers: {'Content-Type': 'application/json'},
data: JSON.stringify({id: getId($(this)), enabled: $(this).is(':checked')}),
method: 'POST',
});
const $tr = $(this).parents('tr:eq(0)').remove().clone();
if ($(this).is(':checked')) {
$tableActive.append($tr);
} else {
$tableInactive.append($tr);
}
updateTable();
});
$(document).on('click', '.modal-close', function () {
hideModal();
});
$(document).on('click', '.playlist-add', function () {
showModal('modal-playlist-add');
$('.modal-playlist-add input:eq(0)').focus().select();
});
$(document).on('click', '.playlist-edit', function () {
const playlist = JSON.parse($(this).parents('tr:eq(0)').attr('data-entity'));
showModal('modal-playlist-edit');
$('.modal-playlist-edit input:visible:eq(0)').focus().select();
$('#playlist-edit-name').val(playlist.name);
$('#playlist-edit-id').val(playlist.id);
});
$(document).on('click', '.playlist-delete', function () {
if (confirm(l.js_playlist_delete_confirmation)) {
const $tr = $(this).parents('tr:eq(0)');
$.ajax({
method: 'DELETE',
url: '/playlist/delete',
headers: {'Content-Type': 'application/json'},
data: JSON.stringify({id: getId($(this))}),
success: function(data) {
$tr.remove();
updateTable();
},
error: function(data) {
$('.alert-error').html(data.responseJSON.message).removeClass('hidden');
}
});
}
});
$(document).keyup(function (e) {
if (e.key === "Escape") {
hideModal();
}
});
main();
});

View File

@ -2,7 +2,7 @@
> #### 👈 [back to readme](/README.md)
#### 🔵 You want to start browser and setup playlist url manually on your device and just want a slideshow manager ? You're in the right place.
#### 🔵 You just want a slideshow manager and you'll deal with screen and browser yourself ? You're in the right place.
---
## 📡 Run the manager
@ -73,16 +73,16 @@ python ./obscreen.py
#### Start server forever with systemctl
```bash
cat "$(pwd)/system/obscreen-manager.service" | sed "s#/home/pi#$HOME#g" | sed "s#=pi#=$USER#g" | sudo tee /etc/systemd/system/obscreen-manager.service
cat "$(pwd)/system/obscreen-composer.service" | sed "s#/home/pi#$HOME#g" | sed "s#=pi#=$USER#g" | sudo tee /etc/systemd/system/obscreen-composer.service
sudo systemctl daemon-reload
sudo systemctl enable obscreen-manager.service
sudo systemctl start obscreen-manager.service
sudo systemctl enable obscreen-composer.service
sudo systemctl start obscreen-composer.service
```
#### Troubleshoot
```bash
# Watch logs with following command
sudo journalctl -u obscreen-manager -f
sudo journalctl -u obscreen-composer -f
```
---
## 👌 Usage

View File

@ -87,16 +87,16 @@ python ./obscreen.py
#### Start server forever with systemctl
```bash
cat "$(pwd)/system/obscreen-manager.service" | sed "s#/home/pi#$HOME#g" | sed "s#=pi#=$USER#g" | sudo tee /etc/systemd/system/obscreen-manager.service
cat "$(pwd)/system/obscreen-composer.service" | sed "s#/home/pi#$HOME#g" | sed "s#=pi#=$USER#g" | sudo tee /etc/systemd/system/obscreen-composer.service
sudo systemctl daemon-reload
sudo systemctl enable obscreen-manager.service
sudo systemctl start obscreen-manager.service
sudo systemctl enable obscreen-composer.service
sudo systemctl start obscreen-composer.service
```
#### Troubleshoot
```bash
# Watch logs with following command
sudo journalctl -u obscreen-manager -f
sudo journalctl -u obscreen-composer -f
```
---
## 🏁 Finally

View File

@ -3,6 +3,8 @@
"slideshow_goto_player": "Go to player",
"slideshow_refresh_player": "Refresh player",
"slideshow_refresh_player_success": "A player refresh has been schedueld, it should happen soon enough (%time% seconds maximum)",
"slideshow_playlist_panel_title": "Playlists",
"slideshow_playlist_panel_item_default": "Default Playlist",
"slideshow_slide_button_add": "Add a slide",
"slideshow_slide_panel_active": "Active slides",
"slideshow_slide_panel_inactive": "Inactive slides",
@ -37,7 +39,25 @@
"slideshow_slide_form_button_cancel": "Cancel",
"js_slideshow_slide_delete_confirmation": "Are you sure?",
"fleet_page_title": "Devices",
"playlist_page_title": "Playlists",
"playlist_button_add": "Add a playlist",
"playlist_panel_active": "Active playlists",
"playlist_panel_inactive": "Inactive playlists",
"playlist_panel_empty": "Currently, there are no playlists. %link% now.",
"playlist_panel_th_name": "Name",
"playlist_panel_th_duration": "Duration",
"playlist_panel_th_enabled": "Enabled",
"playlist_panel_th_activity": "Options",
"playlist_form_add_title": "Add Slide",
"playlist_form_add_submit": "Add",
"playlist_form_edit_title": "Edit Slide",
"playlist_form_edit_submit": "Save",
"playlist_form_label_name": "Name",
"playlist_form_button_cancel": "Cancel",
"js_playlist_delete_confirmation": "Are you sure?",
"playlist_delete_has_slides": "Playlist has slides, please remove them before and retry",
"fleet_page_title": "Composers",
"fleet_screen_button_add": "Add a screen",
"fleet_screen_button_fleetview": "Fleet view",
"fleet_screen_panel_active": "Active screens",
@ -89,7 +109,8 @@
"settings_variable_form_label_value": "Value",
"settings_variable_form_button_cancel": "Cancel",
"settings_variable_desc_lang": "Server language",
"settings_variable_desc_fleet_enabled": "Enable fleet screen management view",
"settings_variable_desc_playlist_enabled": "Enable playlist management",
"settings_variable_desc_fleet_composer_enabled": "Enable fleet composer management",
"settings_variable_desc_auth_enabled": "Enable auth management",
"settings_variable_desc_edition_auth_enabled": "Default user credentials will be admin/admin",
"settings_variable_desc_external_url": "External url (i.e: https://screen-01.company.com or http://10.10.3.100)",

View File

@ -3,6 +3,8 @@
"slideshow_goto_player": "Voir le lecteur",
"slideshow_refresh_player": "Rafraîchir le lecteur",
"slideshow_refresh_player_success": "Un rafraîchissement du lecteur a été programmé, il devrait avoir lieu sous peu (%time% secondes maximum)",
"slideshow_playlist_panel_title": "Playlists",
"slideshow_playlist_panel_item_default": "Playlist par défaut",
"slideshow_slide_button_add": "Ajouter une slide",
"slideshow_slide_panel_active": "Slides actives",
"slideshow_slide_panel_inactive": "Slides inactives",
@ -15,7 +17,7 @@
"slideshow_slide_panel_th_activity": "Options",
"slideshow_slide_panel_td_cron_scheduled_loop": "En boucle",
"slideshow_slide_panel_td_cron_scheduled_bad_cron": "Mauvaise valeur cron",
"slideshow_slide_form_add_title": "Ajouter d'une slide",
"slideshow_slide_form_add_title": "Ajout d'une slide",
"slideshow_slide_form_add_submit": "Ajouter",
"slideshow_slide_form_edit_title": "Modification d'une slide",
"slideshow_slide_form_edit_submit": "Enregistrer",
@ -37,7 +39,25 @@
"slideshow_slide_form_button_cancel": "Annuler",
"js_slideshow_slide_delete_confirmation": "Êtes-vous sûr ?",
"fleet_page_title": "Appareils",
"playlist_page_title": "Listes de lecture",
"playlist_button_add": "Ajouter une liste de lecture",
"playlist_panel_active": "Listes de lecture actives",
"playlist_panel_inactive": "Listes de lecture inactives",
"playlist_panel_empty": "Actuellement, il n'y a pas de liste de lecture. %link% maintenant.",
"playlist_panel_th_name": "Nom",
"playlist_panel_th_duration": "Durée",
"playlist_panel_th_enabled": "Activé",
"playlist_panel_th_activity": "Options",
"playlist_form_add_title": "Ajout d'une liste de lecture",
"playlist_form_add_submit": "Ajouter",
"playlist_form_edit_title": "Modification d'une liste de lecture",
"playlist_form_edit_submit": "Enregistrer",
"playlist_form_label_name": "Nom",
"playlist_form_button_cancel": "Annuler",
"js_playlist_delete_confirmation": "Êtes-vous sûr ?",
"playlist_delete_has_slides": "La liste de lecture contient des sldies, supprimez-les avant et réessayez",
"fleet_page_title": "Composeurs",
"fleet_screen_button_add": "Ajouter un écran",
"fleet_screen_button_fleetview": "Vue flotte",
"fleet_screen_panel_active": "Écrans actifs",
@ -89,7 +109,8 @@
"settings_variable_form_label_value": "Valeur",
"settings_variable_form_button_cancel": "Annuler",
"settings_variable_desc_lang": "Langage de l'application",
"settings_variable_desc_fleet_enabled": "Activer la gestion de flotte des écrans",
"settings_variable_desc_playlist_enabled": "Activer la gestion des playlists",
"settings_variable_desc_fleet_composer_enabled": "Activer la gestion de flotte des composeurs",
"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 admin/admin",
"settings_variable_desc_external_url": "URL externe (i.e: https://screen-01.company.com or http://10.10.3.100)",

View File

@ -9,14 +9,22 @@ from src.interface.ObController import ObController
class AuthController(ObController):
def guard_auth(self, f):
def decorated_function(*args, **kwargs):
if not self._model_store.variable().map().get('auth_enabled').as_bool():
return redirect(url_for('manage'))
return f(*args, **kwargs)
return decorated_function
def register(self):
self._app.add_url_rule('/login', 'login', self.login, methods=['GET', 'POST'])
self._app.add_url_rule('/logout', 'logout', self.logout, methods=['GET'])
self._app.add_url_rule('/auth/user/list', 'auth_user_list', self._auth(self.auth_user_list), methods=['GET'])
self._app.add_url_rule('/auth/user/add', 'auth_user_add', self._auth(self.auth_user_add), methods=['POST'])
self._app.add_url_rule('/auth/user/edit', 'auth_user_edit', self._auth(self.auth_user_edit), methods=['POST'])
self._app.add_url_rule('/auth/user/toggle', 'auth_user_toggle', self._auth(self.auth_user_toggle), methods=['POST'])
self._app.add_url_rule('/auth/user/delete', 'auth_user_delete', self._auth(self.auth_user_delete), methods=['DELETE'])
self._app.add_url_rule('/auth/user/list', 'auth_user_list', self.guard_auth(self._auth(self.auth_user_list)), methods=['GET'])
self._app.add_url_rule('/auth/user/add', 'auth_user_add', self.guard_auth(self._auth(self.auth_user_add)), methods=['POST'])
self._app.add_url_rule('/auth/user/edit', 'auth_user_edit', self.guard_auth(self._auth(self.auth_user_edit)), methods=['POST'])
self._app.add_url_rule('/auth/user/toggle', 'auth_user_toggle', self.guard_auth(self._auth(self.auth_user_toggle)), methods=['POST'])
self._app.add_url_rule('/auth/user/delete', 'auth_user_delete', self.guard_auth(self._auth(self.auth_user_delete)), methods=['DELETE'])
def login(self):
login_error = None

View File

@ -8,14 +8,22 @@ from src.interface.ObController import ObController
class FleetController(ObController):
def guard_fleet(self, f):
def decorated_function(*args, **kwargs):
if not self._model_store.variable().map().get('fleet_composer_enabled').as_bool():
return redirect(url_for('manage'))
return f(*args, **kwargs)
return decorated_function
def register(self):
self._app.add_url_rule('/fleet', 'fleet', self._auth(self.fleet), methods=['GET'])
self._app.add_url_rule('/fleet/screen/list', 'fleet_screen_list', self._auth(self.fleet_screen_list), methods=['GET'])
self._app.add_url_rule('/fleet/screen/add', 'fleet_screen_add', self._auth(self.fleet_screen_add), methods=['POST'])
self._app.add_url_rule('/fleet/screen/edit', 'fleet_screen_edit', self._auth(self.fleet_screen_edit), methods=['POST'])
self._app.add_url_rule('/fleet/screen/toggle', 'fleet_screen_toggle', self._auth(self.fleet_screen_toggle), methods=['POST'])
self._app.add_url_rule('/fleet/screen/delete', 'fleet_screen_delete', self._auth(self.fleet_screen_delete), methods=['DELETE'])
self._app.add_url_rule('/fleet/screen/position', 'fleet_screen_position', self._auth(self.fleet_screen_position), methods=['POST'])
self._app.add_url_rule('/fleet', 'fleet', self.guard_fleet(self._auth(self.fleet)), methods=['GET'])
self._app.add_url_rule('/fleet/screen/list', 'fleet_screen_list', self.guard_fleet(self._auth(self.fleet_screen_list)), methods=['GET'])
self._app.add_url_rule('/fleet/screen/add', 'fleet_screen_add', self.guard_fleet(self._auth(self.fleet_screen_add)), methods=['POST'])
self._app.add_url_rule('/fleet/screen/edit', 'fleet_screen_edit', self.guard_fleet(self._auth(self.fleet_screen_edit)), methods=['POST'])
self._app.add_url_rule('/fleet/screen/toggle', 'fleet_screen_toggle', self.guard_fleet(self._auth(self.fleet_screen_toggle)), methods=['POST'])
self._app.add_url_rule('/fleet/screen/delete', 'fleet_screen_delete', self.guard_fleet(self._auth(self.fleet_screen_delete)), methods=['DELETE'])
self._app.add_url_rule('/fleet/screen/position', 'fleet_screen_position', self.guard_fleet(self._auth(self.fleet_screen_position)), methods=['POST'])
def fleet(self):
return render_template(

View File

@ -1,6 +1,8 @@
import json
from typing import Optional
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify
from src.service.ModelStore import ModelStore
from src.interface.ObController import ObController
from src.utils import get_ip_address, get_safe_cron_descriptor
@ -8,8 +10,8 @@ from src.utils import get_ip_address, get_safe_cron_descriptor
class PlayerController(ObController):
def _get_playlist(self) -> dict:
enabled_slides = self._model_store.slide().get_enabled_slides()
def _get_playlist(self, playlist_id: Optional[int] = 0) -> dict:
enabled_slides = self._model_store.slide().get_slides(enabled=True, playlist_id=playlist_id)
slides = self._model_store.slide().to_dict(enabled_slides)
playlist_loop = []
@ -32,13 +34,21 @@ class PlayerController(ObController):
def register(self):
self._app.add_url_rule('/', 'player', self.player, methods=['GET'])
self._app.add_url_rule('/use/<playlist_slug_or_id>', 'player_use', self.player, methods=['GET'])
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'])
def player(self, playlist_slug_or_id: str = ''):
current_playlist = self._model_store.playlist().get_one_by("slug = ? OR id = ?", {
"slug": playlist_slug_or_id,
"id": playlist_slug_or_id
})
playlist_id = current_playlist.id if current_playlist else None
def player(self):
return render_template(
'player/player.jinja.html',
items=json.dumps(self._get_playlist()),
items=json.dumps(self._get_playlist(playlist_id=playlist_id)),
default_slide_duration=self._model_store.variable().get_one_by_name('default_slide_duration'),
polling_interval=self._model_store.variable().get_one_by_name('polling_interval'),
slide_animation_enabled=self._model_store.variable().get_one_by_name('slide_animation_enabled'),
@ -54,5 +64,11 @@ class PlayerController(ObController):
ipaddr=ipaddr if ipaddr else self._model_store.lang().map().get('common_unknown_ipaddr')
)
def player_playlist(self):
return jsonify(self._get_playlist())
def player_playlist(self, playlist_slug_or_id: str = ''):
current_playlist = self._model_store.playlist().get_one_by("slug = ? OR id = ?", {
"slug": playlist_slug_or_id,
"id": playlist_slug_or_id
})
playlist_id = current_playlist.id if current_playlist else None
return jsonify(self._get_playlist(playlist_id=playlist_id))

View File

@ -0,0 +1,68 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify
from src.service.ModelStore import ModelStore
from src.model.entity.Playlist import Playlist
from src.interface.ObController import ObController
class PlaylistController(ObController):
def guard_playlist(self, f):
def decorated_function(*args, **kwargs):
if not self._model_store.variable().map().get('playlist_enabled').as_bool():
return redirect(url_for('manage'))
return f(*args, **kwargs)
return decorated_function
def register(self):
self._app.add_url_rule('/playlist/list', 'playlist_list', self.guard_playlist(self._auth(self.playlist_list)), methods=['GET'])
self._app.add_url_rule('/playlist/add', 'playlist_add', self.guard_playlist(self._auth(self.playlist_add)), methods=['POST'])
self._app.add_url_rule('/playlist/edit', 'playlist_edit', self.guard_playlist(self._auth(self.playlist_edit)), methods=['POST'])
self._app.add_url_rule('/playlist/toggle', 'playlist_toggle', self.guard_playlist(self._auth(self.playlist_toggle)), methods=['POST'])
self._app.add_url_rule('/playlist/delete', 'playlist_delete', self.guard_playlist(self._auth(self.playlist_delete)), methods=['DELETE'])
def playlist(self):
return render_template(
'playlist/playlist.jinja.html',
playlists=self._model_store.playlist().get_enabled_playlists(),
)
def playlist_list(self):
durations = self._model_store.playlist().get_durations_by_playlists()
return render_template(
'playlist/list.jinja.html',
enabled_playlists=self._model_store.playlist().get_enabled_playlists(with_default=True),
disabled_playlists=self._model_store.playlist().get_disabled_playlists(),
durations=durations
)
def playlist_add(self):
playlist = Playlist(
name=request.form['name'],
)
self._model_store.playlist().add_form(playlist)
return redirect(url_for('playlist_list'))
def playlist_edit(self):
self._model_store.playlist().update_form(
id=request.form['id'],
name=request.form['name'],
)
return redirect(url_for('playlist_list'))
def playlist_toggle(self):
data = request.get_json()
self._model_store.playlist().update_enabled(data.get('id'), data.get('enabled'))
return jsonify({'status': 'ok'})
def playlist_delete(self):
data = request.get_json()
id = data.get('id')
if self._model_store.slide().count_slides_for_playlist(id) > 0:
return jsonify({'status': 'error', 'message': self.t('playlist_delete_has_slides')}), 400
self._model_store.playlist().delete(id)
return jsonify({'status': 'ok'})

View File

@ -33,7 +33,7 @@ class SettingsController(ObController):
if variable.name == 'slide_upload_limit':
self.reload_web_server()
if variable.name == 'fleet_enabled':
if variable.name == 'fleet_composer_enabled':
self.reload_web_server()
if variable.name == 'auth_enabled':

View File

@ -8,7 +8,7 @@ from src.service.ModelStore import ModelStore
from src.model.entity.Slide import Slide
from src.model.enum.SlideType import SlideType
from src.interface.ObController import ObController
from src.utils import str_to_enum, get_optional_string
from src.utils import str_to_enum, get_optional_string, randomize_filename
class SlideshowController(ObController):
@ -16,6 +16,7 @@ class SlideshowController(ObController):
def register(self):
self._app.add_url_rule('/manage', 'manage', self.manage, methods=['GET'])
self._app.add_url_rule('/slideshow', 'slideshow_slide_list', self._auth(self.slideshow), methods=['GET'])
self._app.add_url_rule('/slideshow/playlist/set/<playlist_id>', 'slideshow_slide_list_playlist_use', self._auth(self.slideshow), methods=['GET'])
self._app.add_url_rule('/slideshow/slide/add', 'slideshow_slide_add', self._auth(self.slideshow_slide_add), methods=['POST'])
self._app.add_url_rule('/slideshow/slide/edit', 'slideshow_slide_edit', self._auth(self.slideshow_slide_edit), methods=['POST'])
self._app.add_url_rule('/slideshow/slide/toggle', 'slideshow_slide_toggle', self._auth(self.slideshow_slide_toggle), methods=['POST'])
@ -26,11 +27,15 @@ class SlideshowController(ObController):
def manage(self):
return redirect(url_for('slideshow_slide_list'))
def slideshow(self):
def slideshow(self, playlist_id: int = 0):
current_playlist = self._model_store.playlist().get(playlist_id)
playlist_id = current_playlist.id if current_playlist else None
return render_template(
'slideshow/list.jinja.html',
enabled_slides=self._model_store.slide().get_enabled_slides(),
disabled_slides=self._model_store.slide().get_disabled_slides(),
current_playlist=current_playlist,
playlists=self._model_store.playlist().get_enabled_playlists(),
enabled_slides=self._model_store.slide().get_slides(playlist_id=playlist_id, enabled=True),
disabled_slides=self._model_store.slide().get_slides(playlist_id=playlist_id, enabled=False),
var_last_restart=self._model_store.variable().get_one_by_name('last_restart'),
var_external_url=self._model_store.variable().get_one_by_name('external_url'),
enum_slide_type=SlideType
@ -41,6 +46,7 @@ class SlideshowController(ObController):
name=request.form['name'],
type=str_to_enum(request.form['type'], SlideType),
duration=request.form['duration'],
playlist=request.form['playlist'] if 'playlist' in request.form else None,
cron_schedule=get_optional_string(request.form['cron_schedule']),
cron_schedule_end=get_optional_string(request.form['cron_schedule_end']),
)
@ -55,7 +61,7 @@ class SlideshowController(ObController):
return redirect(request.url)
if object:
object_name = secure_filename(object.filename)
object_name = randomize_filename(object.filename)
object_path = os.path.join(self._app.config['UPLOAD_FOLDER'], object_name)
object.save(object_path)
slide.location = object_path
@ -65,10 +71,13 @@ class SlideshowController(ObController):
self._model_store.slide().add_form(slide)
self._post_update()
if slide.playlist:
return redirect(url_for('slideshow_slide_list_playlist_use', playlist_id=slide.playlist))
return redirect(url_for('slideshow_slide_list'))
def slideshow_slide_edit(self):
self._model_store.slide().update_form(
slide = self._model_store.slide().update_form(
id=request.form['id'],
name=request.form['name'],
duration=request.form['duration'],
@ -77,6 +86,10 @@ class SlideshowController(ObController):
location=request.form['location'] if 'location' in request.form else None
)
self._post_update()
if slide.playlist:
return redirect(url_for('slideshow_slide_list_playlist_use', playlist_id=slide.playlist))
return redirect(url_for('slideshow_slide_list'))
def slideshow_slide_toggle(self):

View File

@ -63,7 +63,7 @@ class SysinfoController(ObController):
os.execl(python, python, *sys.argv)
else:
try:
subprocess.run(["sudo", "systemctl", "restart", 'obscreen-manager'], check=True, timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(["sudo", "systemctl", "restart", 'obscreen-composer'], check=True, timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
pass
except subprocess.TimeoutExpired:
pass

View File

@ -1,4 +1,5 @@
import os
import re
import json
import sqlite3
import logging
@ -7,6 +8,7 @@ from sqlite3 import Cursor
from src.utils import wrap_if, is_wrapped_by
from typing import Optional, Dict
class DatabaseManager:
DB_FILE: str = "data/db/obscreen.db"
@ -28,10 +30,19 @@ class DatabaseManager:
self._conn.row_factory = sqlite3.Row
def open(self, table_name: str, table_model: list):
self.execute_write_query('''CREATE TABLE IF NOT EXISTS {} (
new_table_definition = '''CREATE TABLE IF NOT EXISTS {} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
{}
)'''.format(table_name, ", ".join(table_model)))
)'''.format(table_name, ", ".join(table_model))
self.execute_write_query(new_table_definition)
old_table_definition = self.execute_read_query("select sql from sqlite_master where tbl_name = ?", (table_name,))
old_table_definition = old_table_definition[0]['sql']
delta_queries = self.generate_delta_queries(old_table_definition, new_table_definition)
for delta_query in delta_queries:
self.execute_write_query(delta_query)
return self
@ -95,18 +106,19 @@ class DatabaseManager:
query="select * from {} {}".format(table_name, "ORDER BY {} ASC".format(sort) if sort else "")
)
def get_by_query(self, table_name: str, query: str = "1=1", sort: Optional[str] = None) -> list:
def get_by_query(self, table_name: str, query: str = "1=1", sort: Optional[str] = None, values: dict = {}) -> list:
return self.execute_read_query(
query="select * from {} where {} {}".format(
table_name,
query,
"ORDER BY {} ASC".format(sort) if sort else ""
)
),
params=tuple(v for v in values.values())
)
def get_one_by_query(self, table_name: str, query: str = "1=1", sort: Optional[str] = None) -> list:
def get_one_by_query(self, table_name: str, query: str = "1=1", sort: Optional[str] = None, values: dict = {}) -> list:
query = "select * from {} where {} {}".format(table_name, query, "ORDER BY {} ASC".format(sort) if sort else "")
lines = self.execute_read_query(query=query)
lines = self.execute_read_query(query=query, params=tuple(v for v in values.values()))
count = len(lines)
if count > 1:
@ -142,3 +154,55 @@ class DatabaseManager:
def delete_by_id(self, table_name: str, id: int) -> None:
self.execute_write_query("DELETE FROM {} WHERE id = ?".format(table_name), params=(id,))
@staticmethod
def parse_create_table_query(query: str):
table_name_pattern = re.compile(r'CREATE TABLE\s+(IF NOT EXISTS\s+)?["]?(\w+)["]?', re.IGNORECASE)
columns_pattern = re.compile(r'\((.*)\)', re.DOTALL)
table_name_match = table_name_pattern.search(query)
columns_match = columns_pattern.search(query)
if not table_name_match or not columns_match:
raise ValueError("Invalid CREATE TABLE query.")
table_name = table_name_match.group(2)
columns_part = columns_match.group(1)
# Split columns_part by commas but ignore commas inside parentheses
columns = re.split(r',\s*(?![^()]*\))', columns_part)
# Extract column names and their definitions
column_definitions = {}
for column in columns:
column_parts = column.strip().split(maxsplit=1)
column_name = column_parts[0]
column_definition = column.strip()
column_definitions[column_name] = column_definition
return table_name, column_definitions
@staticmethod
def generate_delta_queries(old_query: str, new_query: str) -> list:
old_table_name, old_columns = DatabaseManager.parse_create_table_query(old_query)
new_table_name, new_columns = DatabaseManager.parse_create_table_query(new_query)
if old_table_name != new_table_name:
raise ValueError("Table names do not match.")
old_column_names = set(old_columns.keys())
new_column_names = set(new_columns.keys())
columns_to_add = new_column_names - old_column_names
columns_to_remove = old_column_names - new_column_names
delta_queries = []
for column in columns_to_add:
delta_queries.append(f'ALTER TABLE {old_table_name} ADD COLUMN {new_columns[column]}')
for column in columns_to_remove:
delta_queries.append(f'ALTER TABLE {old_table_name} DROP COLUMN {column}')
return delta_queries

View File

@ -0,0 +1,152 @@
import os
from typing import Dict, Optional, List, Tuple, Union
from src.model.entity.Playlist import Playlist
from src.utils import get_optional_string, get_yt_video_id, slugify
from src.manager.DatabaseManager import DatabaseManager
from src.manager.LangManager import LangManager
from src.manager.UserManager import UserManager
from src.service.ModelManager import ModelManager
class PlaylistManager(ModelManager):
TABLE_NAME = "playlist"
TABLE_MODEL = [
"name CHAR(255)",
"slug CHAR(255)",
"enabled INTEGER DEFAULT 0",
"created_by CHAR(255)",
"updated_by CHAR(255)",
"created_at INTEGER",
"updated_at INTEGER"
]
def __init__(self, lang_manager: LangManager, database_manager: DatabaseManager, user_manager: UserManager):
super().__init__(lang_manager, database_manager, user_manager)
self._db = database_manager.open(self.TABLE_NAME, self.TABLE_MODEL)
def hydrate_object(self, raw_playlist: dict, id: int = None) -> Playlist:
if id:
raw_playlist['id'] = id
[raw_playlist, user_tracker_edits] = self.user_manager.initialize_user_trackers(raw_playlist)
if len(user_tracker_edits) > 0:
self._db.update_by_id(self.TABLE_NAME, raw_playlist['id'], user_tracker_edits)
return Playlist(**raw_playlist)
def hydrate_list(self, raw_playlists: list) -> List[Playlist]:
return [self.hydrate_object(raw_playlist) for raw_playlist in raw_playlists]
def get(self, id: Optional[int]) -> Optional[Playlist]:
if not id:
return None
object = self._db.get_by_id(self.TABLE_NAME, id)
return self.hydrate_object(object, id) if object else None
def get_by(self, query, sort: Optional[str] = None, values: dict = {}) -> List[Playlist]:
return self.hydrate_list(self._db.get_by_query(self.TABLE_NAME, query=query, sort=sort, values=values))
def get_one_by(self, query, values: dict = {}) -> Optional[Playlist]:
object = self._db.get_one_by_query(self.TABLE_NAME, query=query, values=values)
if not object:
return None
return self.hydrate_object(object)
def get_durations_by_playlists(self):
durations = self._db.execute_read_query("select playlist, sum(duration) as total_duration from slideshow where cron_schedule is null group by playlist")
map = {}
for duration in durations:
map[duration['playlist']] = duration['total_duration']
return map
def get_all(self) -> List[Playlist]:
return self.hydrate_list(self._db.get_all(self.TABLE_NAME))
def get_enabled_playlists(self, with_default: bool = False) -> List[Playlist]:
playlists = self.get_by(query="enabled = 1")
if not with_default:
return playlists
return [Playlist(id=None, name=self.t('slideshow_playlist_panel_item_default'))] + playlists
def get_disabled_playlists(self) -> List[Playlist]:
return self.get_by(query="enabled = 0")
def update_enabled(self, id: int, enabled: bool) -> None:
self._db.update_by_id(self.TABLE_NAME, id, {"enabled": enabled})
def forget_user(self, user_id: int):
playlists = self.get_by("created_by = '{}' or updated_by = '{}'".format(user_id, user_id))
edits_playlists = self.user_manager.forget_user(playlists, user_id)
for playlist_id, edits in edits_playlists.items():
self._db.update_by_id(self.TABLE_NAME, playlist_id, edits)
def pre_add(self, playlist: Dict) -> Dict:
playlist["slug"] = slugify(playlist["name"])
self.user_manager.track_user_on_create(playlist)
self.user_manager.track_user_on_update(playlist)
return playlist
def pre_update(self, playlist: Dict) -> Dict:
playlist["slug"] = slugify(playlist["name"])
self.user_manager.track_user_on_update(playlist)
return playlist
def pre_delete(self, playlist_id: str) -> str:
return playlist_id
def post_add(self, playlist_id: str) -> str:
return playlist_id
def post_update(self, playlist_id: str) -> str:
return playlist_id
def post_updates(self):
pass
def post_delete(self, playlist_id: str) -> str:
return playlist_id
def update_form(self, id: int, name: str) -> None:
playlist = self.get(id)
if not playlist:
return
form = {
"name": name
}
self._db.update_by_id(self.TABLE_NAME, id, self.pre_update(form))
self.post_update(id)
def add_form(self, playlist: Union[Playlist, Dict]) -> None:
form = playlist
if not isinstance(playlist, dict):
form = playlist.to_dict()
del form['id']
self._db.add(self.TABLE_NAME, self.pre_add(form))
self.post_add(playlist.id)
def delete(self, id: int) -> None:
playlist = self.get(id)
if playlist:
self.pre_delete(id)
self._db.delete_by_id(self.TABLE_NAME, id)
self.post_delete(id)
def to_dict(self, playlists: List[Playlist]) -> List[Dict]:
return [playlist.to_dict() for playlist in playlists]

View File

@ -12,7 +12,7 @@ class ScreenManager(ModelManager):
TABLE_NAME = "fleet"
TABLE_MODEL = [
"name CHAR(255)",
"enabled INTEGER",
"enabled INTEGER DEFAULT 0",
"position INTEGER",
"host CHAR(255)",
"port INTEGER"

View File

@ -3,6 +3,7 @@ import os
from typing import Dict, Optional, List, Tuple, Union
from src.model.entity.Slide import Slide
from src.model.entity.Playlist import Playlist
from src.model.enum.SlideType import SlideType
from src.utils import get_optional_string, get_yt_video_id
from src.manager.DatabaseManager import DatabaseManager
@ -17,7 +18,8 @@ class SlideManager(ModelManager):
TABLE_MODEL = [
"name CHAR(255)",
"type CHAR(30)",
"enabled INTEGER",
"enabled INTEGER DEFAULT 0",
"playlist INTEGER",
"duration INTEGER",
"position INTEGER",
"location TEXT",
@ -72,11 +74,14 @@ class SlideManager(ModelManager):
for slide_id, edits in edits_slides.items():
self._db.update_by_id(self.TABLE_NAME, slide_id, edits)
def get_enabled_slides(self) -> List[Slide]:
return self.get_by(query="enabled = 1", sort="position")
def get_slides(self, playlist_id: Optional[int] = None, enabled: bool = True) -> List[Slide]:
query = "enabled = {}".format("1" if enabled else "0")
if playlist_id:
query = "{} {}".format(query, "AND playlist = {}".format(playlist_id))
else:
query = "{} {}".format(query, "AND playlist is NULL")
def get_disabled_slides(self) -> List[Slide]:
return self.get_by(query="enabled = 0", sort="position")
return self.get_by(query=query, sort="position")
def pre_add(self, slide: Dict) -> Dict:
self.user_manager.track_user_on_create(slide)
@ -110,7 +115,7 @@ class SlideManager(ModelManager):
for slide_id, slide_position in positions.items():
self._db.update_by_id(self.TABLE_NAME, slide_id, {"position": slide_position})
def update_form(self, id: int, name: str, duration: int, cron_schedule: Optional[str] = '', cron_schedule_end: Optional[str] = '', location: Optional[str] = None) -> None:
def update_form(self, id: int, name: str, duration: int, cron_schedule: Optional[str] = '', cron_schedule_end: Optional[str] = '', location: Optional[str] = None) -> Slide:
slide = self.get(id)
if not slide:
@ -131,6 +136,7 @@ class SlideManager(ModelManager):
self._db.update_by_id(self.TABLE_NAME, id, self.pre_update(form))
self.post_update(id)
return self.get(id)
def add_form(self, slide: Union[Slide, Dict]) -> None:
form = slide
@ -162,3 +168,5 @@ class SlideManager(ModelManager):
def to_dict(self, slides: List[Slide]) -> List[Dict]:
return [slide.to_dict() for slide in slides]
def count_slides_for_playlist(self, id: int) -> int:
return len(self.get_slides(playlist_id=id))

View File

@ -15,7 +15,7 @@ class UserManager:
TABLE_MODEL = [
"username CHAR(255)",
"password CHAR(255)",
"enabled INTEGER"
"enabled INTEGER DEFAULT 1"
]
def __init__(self, lang_manager: LangManager, database_manager: DatabaseManager, on_user_delete):

View File

@ -104,7 +104,8 @@ class VariableManager(ModelManager):
### General
{"name": "lang", "section": self.t(VariableSection.GENERAL), "value": "en", "type": VariableType.SELECT_SINGLE, "editable": True, "description": self.t('settings_variable_desc_lang'), "selectables": self.t(ApplicationLanguage), "refresh_player": False},
{"name": "auth_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_auth_enabled'), "description_edition": self.t('settings_variable_desc_edition_auth_enabled'), "refresh_player": False},
{"name": "fleet_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_fleet_enabled'), "refresh_player": False},
{"name": "fleet_composer_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_fleet_composer_enabled'), "refresh_player": False},
{"name": "playlist_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_playlist_enabled'), "refresh_player": False},
{"name": "external_url", "section": self.t(VariableSection.GENERAL), "value": "", "type": VariableType.STRING, "editable": True, "description": self.t('settings_variable_desc_external_url'), "refresh_player": False},
{"name": "slide_upload_limit", "section": self.t(VariableSection.GENERAL), "value": 32, "unit": VariableUnit.MEGABYTE, "type": VariableType.INT, "editable": True, "description": self.t('settings_variable_desc_slide_upload_limit'), "refresh_player": False},

View File

@ -0,0 +1,111 @@
import json
import time
from typing import Optional, Union
class Playlist:
def __init__(self, name: str = 'Untitled', slug: str = 'untitled', id: Optional[int] = None, enabled: bool = False, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
self._id = id if id else None
self._name = name
self._slug = slug
self._enabled = enabled
self._created_by = created_by if created_by else None
self._updated_by = updated_by if updated_by else None
self._created_at = int(created_at if created_at else time.time())
self._updated_at = int(updated_at if updated_at else time.time())
@property
def id(self) -> Optional[int]:
return self._id
@property
def enabled(self) -> bool:
return bool(self._enabled)
@enabled.setter
def enabled(self, value: bool):
self._enabled = bool(value)
@property
def created_by(self) -> str:
return self._created_by
@created_by.setter
def created_by(self, value: str):
self._created_by = value
@property
def updated_by(self) -> str:
return self._updated_by
@updated_by.setter
def updated_by(self, value: str):
self._updated_by = value
@property
def created_at(self) -> int:
return self._created_at
@created_at.setter
def created_at(self, value: int):
self._created_at = value
@property
def updated_at(self) -> int:
return self._updated_at
@updated_at.setter
def updated_at(self, value: int):
self._updated_at = value
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def slug(self) -> str:
return self._slug
@slug.setter
def slug(self, value: str):
self._slug = value
def __str__(self) -> str:
return f"Playlist(" \
f"id='{self.id}',\n" \
f"name='{self.name}',\n" \
f"nameslug='{self.slug}',\n" \
f"enabled='{self.enabled}',\n" \
f"created_by='{self.created_by}',\n" \
f"updated_by='{self.updated_by}',\n" \
f"created_at='{self.created_at}',\n" \
f"updated_at='{self.updated_at}',\n" \
f")"
def to_json(self, edits: dict = {}) -> str:
obj = self.to_dict()
for k,v in edits.items():
obj[k] = v
return json.dumps(obj)
def to_dict(self) -> dict:
playlist = {
"id": self.id,
"name": self.name,
"slug": self.slug,
"enabled": self.enabled,
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
return playlist

View File

@ -8,9 +8,10 @@ from src.utils import str_to_enum
class Slide:
def __init__(self, location: str = '', duration: int = 3, type: Union[SlideType, str] = SlideType.URL, enabled: bool = False, name: str = 'Untitled', position: int = 999, id: Optional[int] = None, cron_schedule: Optional[str] = None, cron_schedule_end: Optional[str] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
def __init__(self, location: str = '', playlist: Optional[int] = None, duration: int = 3, type: Union[SlideType, str] = SlideType.URL, enabled: bool = False, name: str = 'Untitled', position: int = 999, id: Optional[int] = None, cron_schedule: Optional[str] = None, cron_schedule_end: Optional[str] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
self._id = id if id else None
self._location = location
self._playlist = playlist
self._duration = duration
self._type = str_to_enum(type, SlideType) if isinstance(type, str) else type
self._enabled = enabled
@ -75,6 +76,14 @@ class Slide:
def type(self, value: SlideType):
self._type = value
@property
def playlist(self) -> Optional[int]:
return self._playlist
@playlist.setter
def playlist(self, value: Optional[int]):
self._playlist = value
@property
def cron_schedule(self) -> Optional[str]:
return self._cron_schedule
@ -136,6 +145,7 @@ class Slide:
f"updated_by='{self.updated_by}',\n" \
f"created_at='{self.created_at}',\n" \
f"updated_at='{self.updated_at}',\n" \
f"playlist='{self.playlist}',\n" \
f"cron_schedule='{self.cron_schedule}',\n" \
f"cron_schedule_end='{self.cron_schedule_end}',\n" \
f")"
@ -161,6 +171,7 @@ class Slide:
"updated_by": self.updated_by,
"created_at": self.created_at,
"updated_at": self.updated_at,
"playlist": self.playlist,
"cron_schedule": self.cron_schedule,
"cron_schedule_end": self.cron_schedule_end,
}

View File

@ -15,6 +15,11 @@ class HookType(Enum):
H_FLEET_CSS = 'h_fleet_css'
H_FLEET_JAVASCRIPT = 'h_fleet_javascript'
H_PLAYLIST_TOOLBAR_ACTIONS_START = 'h_playlist_toolbar_actions_start'
H_PLAYLIST_TOOLBAR_ACTIONS_END = 'h_playlist_toolbar_actions_end'
H_PLAYLIST_CSS = 'h_playlist_css'
H_PLAYLIST_JAVASCRIPT = 'h_playlist_javascript'
H_AUTH_TOOLBAR_ACTIONS_START = 'h_auth_toolbar_actions_start'
H_AUTH_TOOLBAR_ACTIONS_END = 'h_auth_toolbar_actions_end'
H_AUTH_CSS = 'h_auth_css'

View File

@ -1,3 +1,4 @@
from src.manager.PlaylistManager import PlaylistManager
from src.manager.SlideManager import SlideManager
from src.manager.ScreenManager import ScreenManager
from src.manager.UserManager import UserManager
@ -26,6 +27,7 @@ class ModelStore:
# Model
self._screen_manager = ScreenManager(lang_manager=self._lang_manager, database_manager=self._database_manager, user_manager=self._user_manager)
self._playlist_manager = PlaylistManager(lang_manager=self._lang_manager, database_manager=self._database_manager, user_manager=self._user_manager)
self._slide_manager = SlideManager(lang_manager=self._lang_manager, database_manager=self._database_manager, user_manager=self._user_manager)
self._variable_manager.reload()
@ -44,6 +46,9 @@ class ModelStore:
def slide(self) -> SlideManager:
return self._slide_manager
def playlist(self) -> PlaylistManager:
return self._playlist_manager
def screen(self) -> ScreenManager:
return self._screen_manager

View File

@ -9,7 +9,7 @@ from src.model.hook.HookRegistration import HookRegistration
from src.model.hook.StaticHookRegistration import StaticHookRegistration
from src.model.hook.FunctionalHookRegistration import FunctionalHookRegistration
from src.constant.WebDirConstant import WebDirConstant
from src.utils import get_safe_cron_descriptor, is_validate_cron_date_time
from src.utils import get_safe_cron_descriptor, is_validate_cron_date_time, seconds_to_hhmmss
class TemplateRenderer:
@ -26,14 +26,16 @@ class TemplateRenderer:
globals = dict(
STATIC_PREFIX="/{}/{}/".format(WebDirConstant.FOLDER_STATIC, WebDirConstant.FOLDER_STATIC_WEB_ASSETS),
SECRET_KEY=self._model_store.config().map().get('secret_key'),
FLEET_ENABLED=self._model_store.variable().map().get('fleet_enabled').as_bool(),
FLEET_COMPOSER_ENABLED=self._model_store.variable().map().get('fleet_composer_enabled').as_bool(),
AUTH_ENABLED=self._model_store.variable().map().get('auth_enabled').as_bool(),
PLAYLIST_ENABLED=self._model_store.variable().map().get('playlist_enabled').as_bool(),
track_created=self._model_store.user().track_user_created,
track_updated=self._model_store.user().track_user_updated,
VERSION=self._model_store.config().map().get('version'),
LANG=self._model_store.variable().map().get('lang').as_string(),
HOOK=self._render_hook,
cron_descriptor=self.cron_descriptor,
seconds_to_hhmmss=seconds_to_hhmmss,
is_validate_cron_date_time=is_validate_cron_date_time,
l=self._model_store.lang().map(),
t=self._model_store.lang().translate,

View File

@ -12,6 +12,7 @@ from src.service.TemplateRenderer import TemplateRenderer
from src.controller.PlayerController import PlayerController
from src.controller.SlideshowController import SlideshowController
from src.controller.FleetController import FleetController
from src.controller.PlaylistController import PlaylistController
from src.controller.AuthController import AuthController
from src.controller.SysinfoController import SysinfoController
from src.controller.SettingsController import SettingsController
@ -106,6 +107,7 @@ class WebServer:
SettingsController(self, self._app, auth_required, self._model_store, self._template_renderer)
SysinfoController(self, self._app, auth_required, self._model_store, self._template_renderer)
FleetController(self, self._app, auth_required, self._model_store, self._template_renderer)
PlaylistController(self, self._app, auth_required, self._model_store, self._template_renderer)
AuthController(self, self._app, auth_required, self._model_store, self._template_renderer)
def _setup_web_globals(self) -> None:

View File

@ -1,7 +1,9 @@
import os
import re
import uuid
import logging
import subprocess
import unicodedata
import platform
@ -200,3 +202,22 @@ def get_yt_video_id(url: str) -> str:
YouTube video id.
"""
return regex_search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", url, group=1)
def slugify(value):
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
value = re.sub('[^\w\s-]', '', value).strip().lower()
return re.sub('[-\s]+', '-', value)
def seconds_to_hhmmss(seconds):
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
return f"{hours:02}:{minutes:02}:{secs:02}"
def randomize_filename(old_filename: str) -> str:
new_uuid = str(uuid.uuid4())
_, extension = os.path.splitext(old_filename)
return f"{new_uuid}{extension}"

View File

@ -1,5 +1,5 @@
[Unit]
Description=Obscreen Manager
Description=Obscreen Composer
After=network.target
[Service]

View File

@ -19,7 +19,9 @@
<tr class="user-item" data-level="{{ user.id }}" data-entity="{{ user.to_json() }}">
<td class="infos">
<div class="inner">
<div class="badge"><i class="fa fa-key icon-left"></i> {{ user.id }}</div>
<i class="fa fa-user icon-left"></i>
{{ user.username }}
</div>
</td>

View File

@ -9,7 +9,7 @@
{% endblock %}
{% block add_js %}
<script src="{{ STATIC_PREFIX }}js/auth.js"></script>
<script src="{{ STATIC_PREFIX }}js/auth/users.js"></script>
{{ HOOK(H_AUTH_JAVASCRIPT) }}
{% endblock %}

View File

@ -34,7 +34,7 @@
{{ HOOK(H_ROOT_CSS) }}
</head>
<body>
<div class="container">
<div class="container {% block container_class %}{% endblock %}">
{% set fleet_mode = request.args.get('fleet_mode') == '1' %}
{% block header %}
@ -56,7 +56,14 @@
<i class="fa-regular fa-clock"></i> {{ l.slideshow_page_title }}
</a>
</li>
{% if FLEET_ENABLED %}
{% if PLAYLIST_ENABLED %}
<li class="{{ 'active' if request.url_rule.endpoint == 'playlist_list' }}">
<a href="{{ url_for('playlist_list') }}">
<i class="fa fa-bars-staggered"></i> {{ l.playlist_page_title }}
</a>
</li>
{% endif %}
{% if FLEET_COMPOSER_ENABLED %}
<li class="{{ 'active' if request.url_rule.endpoint == 'fleet_screen_list' }}">
<a href="{{ url_for('fleet_screen_list') }}">
<i class="fa fa-tv"></i> {{ l.fleet_page_title }}
@ -115,7 +122,8 @@
<script>
var secret_key = '{{ SECRET_KEY }}';
var l = {
'js_slideshow_slide_delete_confirmation': '{{ l.slideshow_slide_delete_confirmation }}',
'js_playlist_delete_confirmation': '{{ l.js_playlist_delete_confirmation }}',
'js_slideshow_slide_delete_confirmation': '{{ l.js_slideshow_slide_delete_confirmation }}',
'js_fleet_screen_delete_confirmation': '{{ l.js_fleet_screen_delete_confirmation }}',
'js_auth_user_delete_confirmation': '{{ l.js_auth_user_delete_confirmation }}',
'js_sysinfo_restart_confirmation': '{{ l.js_sysinfo_restart_confirmation }}',

View File

@ -24,6 +24,9 @@
<a href="javascript:void(0);" class="item-sort screen-sort">
<i class="fa fa-sort icon-left"></i>
</a>
<div class="badge"><i class="fa fa-key icon-left"></i> {{ screen.id }}</div>
<i class="fa fa-tv icon-left"></i>
{{ screen.name }}
</div>

View File

@ -9,8 +9,8 @@
{% endblock %}
{% block add_js %}
<script src="{{ STATIC_PREFIX }}js/tablednd-fixed.js"></script>
<script src="{{ STATIC_PREFIX }}js/fleet.js"></script>
<script src="{{ STATIC_PREFIX }}js/lib/tablednd-fixed.js"></script>
<script src="{{ STATIC_PREFIX }}js/fleet/screens.js"></script>
{{ HOOK(H_FLEET_JAVASCRIPT) }}
{% endblock %}

View File

@ -6,7 +6,7 @@
<meta name="google" content="notranslate">
<link rel="shortcut icon" href="{{ STATIC_PREFIX }}/favicon.ico">
{% if slide_animation_enabled.eval() %}
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/animate.min.css" />
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/animate.min.css" />
{% endif %}
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background-color: white; display: flex; flex-direction: row; justify-content: center; align-items: center; }
@ -15,7 +15,7 @@
.slide iframe { background: white; }
.slide img { height: 100%; }
</style>
<script type="application/javascript" src="{{ STATIC_PREFIX }}js/is-cron-now.js"></script>
<script type="application/javascript" src="{{ STATIC_PREFIX }}js/lib/is-cron-now.js"></script>
</head>
<body>
<div id="FirstSlide" class="slide" style="z-index: 1000;">
@ -69,12 +69,13 @@
};
var cronTick = function() {
if ((new Date()).getSeconds() != 0) {
return;
}
// console.log('Cron Tick');
for (var i = 0; i < items.cron.length; i++) {
var item = items.cron[i];
@ -98,13 +99,56 @@
loadContent(curSlide, callbackReady, item);
}
}
}
};
function main() {
preloadSlide('SecondSlide', items.loop[curItemIndex])
cronState.interval = setInterval(cronTick, 1000);
}
function preloadSlide(slide, item) {
var element = document.getElementById(slide);
var callbackReady = function (onSlideStart) {
var move = function () {
if (nextReady && !cronState.active) {
moveToSlide(slide, item);
onSlideStart();
} else {
setTimeout(move, 1000);
}
}
setTimeout(move, duration);
};
loadContent(element, callbackReady, item);
}
function moveToSlide(slide, item) {
curSlide = document.getElementById(slide);
previousSlide = curSlide == firstSlide ? secondSlide : firstSlide;
duration = item.duration * 1000;
curItemIndex = (curItemIndex + 1) === items.loop.length ? 0 : curItemIndex + 1;
curSlide.style.zIndex = 1000;
previousSlide.style.zIndex = 500;
if (animate) {
curSlide.classList.add('animate__animated', animate_transitions[0], animate_speed);
curSlide.onanimationend = () => {
curSlide.classList.remove(animate_transitions[0], animate_speed);
preloadSlide(previousSlide.attributes['id'].value, items.loop[curItemIndex]);
};
previousSlide.classList.add('animate__animated', animate_transitions[1], animate_speed);
previousSlide.onanimationend = () => {
previousSlide.classList.remove(animate_transitions[1], animate_speed);
};
} else {
preloadSlide(previousSlide.attributes['id'].value, items.loop[curItemIndex]);
}
}
function loadContent(element, callbackReady, item) {
switch (item.type) {
case 'url':
@ -164,49 +208,6 @@
callbackReady(onSlideStart);
}
function preloadSlide(slide, item) {
var element = document.getElementById(slide);
var callbackReady = function (onSlideStart) {
var move = function () {
if (nextReady && !cronState.active) {
moveToSlide(slide, item);
onSlideStart();
} else {
setTimeout(move, 1000);
}
}
setTimeout(move, duration);
};
loadContent(element, callbackReady, item);
}
function moveToSlide(slide, item) {
curSlide = document.getElementById(slide);
previousSlide = curSlide == firstSlide ? secondSlide : firstSlide;
duration = item.duration * 1000;
curItemIndex = (curItemIndex + 1) === items.loop.length ? 0 : curItemIndex + 1;
curSlide.style.zIndex = 1000;
previousSlide.style.zIndex = 500;
if (animate) {
curSlide.classList.add('animate__animated', animate_transitions[0], animate_speed);
curSlide.onanimationend = () => {
curSlide.classList.remove(animate_transitions[0], animate_speed);
preloadSlide(previousSlide.attributes['id'].value, items.loop[curItemIndex]);
};
previousSlide.classList.add('animate__animated', animate_transitions[1], animate_speed);
previousSlide.onanimationend = () => {
previousSlide.classList.remove(animate_transitions[1], animate_speed);
};
} else {
preloadSlide(previousSlide.attributes['id'].value, items.loop[curItemIndex]);
}
}
main();
</script>
</body>

View File

@ -0,0 +1,56 @@
<table class="{{ tclass }}-playlists">
<thead>
<tr>
<th>{{ l.playlist_panel_th_name }}</th>
<th class="tac">{{ l.playlist_panel_th_enabled }}</th>
<th class="tac">{{ l.playlist_panel_th_duration }}</th>
<th class="tac">{{ l.playlist_panel_th_activity }}</th>
</tr>
</thead>
<tbody>
<tr class="empty-tr {% if playlists|length != 0 %}hidden{% endif %}">
<td colspan="4">
{{ l.playlist_panel_empty|replace(
'%link%',
('<a href="javascript:void(0);" class="item-add playlist-add">'~l.playlist_button_add~'</a>')|safe
) }}
</td>
</tr>
{% for playlist in playlists %}
<tr class="playlist-item" data-level="{{ playlist.id }}" data-entity="{{ playlist.to_json() }}">
<td class="infos">
<div class="inner">
{% if playlist.id %}
<div class="badge"><i class="fa fa-key icon-left"></i> {{ playlist.id }}</div>
{% else %}
<div class="badge"><i class="fa fa-lock"></i></div>
{% endif %}
<i class="fa fa-bars-staggered icon-left"></i>
{{ playlist.name }}
</div>
</td>
<td class="tac">
{% if playlist.id %}
<label class="pure-material-switch">
<input type="checkbox" {% if playlist.enabled %}checked="checked"{% endif %}><span></span>
</label>
{% endif %}
</td>
<td class="tac">
{{ seconds_to_hhmmss(durations[playlist.id]) }}
</td>
<td class="actions tac">
{% if playlist.id %}
<a href="javascript:void(0);" class="item-edit playlist-edit">
<i class="fa fa-pencil"></i>
</a>
<a href="javascript:void(0);" class="item-delete playlist-delete">
<i class="fa fa-trash"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -0,0 +1,61 @@
{% extends 'base.jinja.html' %}
{% block page_title %}
{{ l.playlist_page_title }}
{% endblock %}
{% block add_css %}
{{ HOOK(H_PLAYLIST_CSS) }}
{% endblock %}
{% block add_js %}
<script src="{{ STATIC_PREFIX }}js/lib/tablednd-fixed.js"></script>
<script src="{{ STATIC_PREFIX }}js/playlist/playlists.js"></script>
{{ HOOK(H_PLAYLIST_JAVASCRIPT) }}
{% endblock %}
{% block page %}
<div class="toolbar">
<h2>{{ l.playlist_page_title }}</h2>
<div class="toolbar-actions">
{{ HOOK(H_PLAYLIST_TOOLBAR_ACTIONS_START) }}
<button class="purple playlist-add item-add"><i class="fa fa-plus icon-left"></i>{{ l.playlist_button_add }}</button>
{{ HOOK(H_PLAYLIST_TOOLBAR_ACTIONS_END) }}
</div>
</div>
<div class="alert alert-error hidden">
</div>
<div class="panel">
<div class="panel-body">
<h3>{{ l.playlist_panel_active }}</h3>
{% with tclass='active', playlists=enabled_playlists %}
{% include 'playlist/component/table.jinja.html' %}
{% endwith %}
</div>
</div>
<div class="panel panel-inactive">
<div class="panel-body">
<h3>{{ l.playlist_panel_inactive }}</h3>
{% with tclass='inactive', playlists=disabled_playlists %}
{% include 'playlist/component/table.jinja.html' %}
{% endwith %}
</div>
</div>
<div class="modals hidden">
<div class="modals-outer">
<a href="javascript:void(0);" class="modal-close">
<i class="fa fa-close"></i>
</a>
<div class="modals-inner">
{% include 'playlist/modal/add.jinja.html' %}
{% include 'playlist/modal/edit.jinja.html' %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,25 @@
<div class="modal modal-playlist-add modal-playlist">
<h2>
{{ l.playlist_form_add_title }}
</h2>
<form action="/playlist/add" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label for="playlist-add-name">{{ l.playlist_form_label_name }}</label>
<div class="widget">
<input name="name" type="text" id="playlist-add-name" required="required" />
</div>
</div>
<div class="actions">
<button type="button" class="btn-normal modal-close">
{{ l.playlist_form_button_cancel }}
</button>
<button type="submit" class="green">
<i class="fa fa-plus icon-left"></i> {{ l.playlist_form_add_submit }}
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,27 @@
<div class="modal modal-playlist-edit modal-playlist hidden">
<h2>
{{ l.playlist_form_edit_title }}
</h2>
<form action="/playlist/edit" method="POST">
<input type="hidden" name="id" id="playlist-edit-id" />
<div class="form-group">
<label for="playlist-edit-name">{{ l.playlist_form_label_name }}</label>
<div class="widget">
<input type="text" name="name" id="playlist-edit-name" required="required" />
</div>
</div>
<div class="actions">
<button type="button" class="btn-normal modal-close">
{{ l.playlist_form_button_cancel }}
</button>
<button type="submit" class="green">
<i class="fa fa-save icon-left"></i>{{ l.playlist_form_edit_submit }}
</button>
</div>
</form>
</div>

View File

@ -5,18 +5,20 @@
{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/flatpickr.min.css" />
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/flatpickr.min.css" />
{{ HOOK(H_SLIDESHOW_CSS) }}
{% endblock %}
{% block add_js %}
<script src="{{ STATIC_PREFIX }}js/flatpickr.min.js"></script>
<script src="{{ STATIC_PREFIX }}js/tablednd-fixed.js"></script>
<script src="{{ STATIC_PREFIX }}js/slideshow.js"></script>
<script src="{{ STATIC_PREFIX }}js/lib/flatpickr.min.js"></script>
<script src="{{ STATIC_PREFIX }}js/lib/tablednd-fixed.js"></script>
<script src="{{ STATIC_PREFIX }}js/slideshow/slides.js"></script>
<script src="{{ STATIC_PREFIX }}js/restart.js"></script>
{{ HOOK(H_SLIDESHOW_JAVASCRIPT) }}
{% endblock %}
{% block container_class %}{% if PLAYLIST_ENABLED %}expand{% endif %}{% endblock %}
{% block page %}
<div class="toolbar">
<h2>{{ l.slideshow_page_title }}</h2>
@ -28,7 +30,7 @@
{{ HOOK(H_FLEETMODE_SLIDESHOW_TOOLBAR_ACTIONS) }}
{% endif %}
<a href="/" target="_blank" class="btn" title="{{ l.slideshow_goto_player }}">
<a href="{% if current_playlist %}{{ url_for('player_use', playlist_slug_or_id=current_playlist.slug) }}{% else %}{{ url_for('player') }}{% endif %}" target="_blank" class="btn" title="{{ l.slideshow_goto_player }}">
<i class="fa fa-play"></i>
</a>
<a href="{{ url_for('slideshow_player_refresh') }}" class="btn" title="{{ l.slideshow_refresh_player }}">
@ -36,6 +38,7 @@
</a>
<button class="purple slide-add item-add"><i class="fa fa-plus icon-left"></i>{{ l.slideshow_slide_button_add }}</button>
{{ HOOK(H_SLIDESHOW_TOOLBAR_ACTIONS_END) }}
</div>
</div>
@ -47,22 +50,57 @@
</div>
{% endif %}
<div class="panel">
<div class="panel-body">
<h3>{{ l.slideshow_slide_panel_active }}</h3>
{% with tclass='active', slides=enabled_slides %}
{% include 'slideshow/component/table.jinja.html' %}
{% endwith %}
<div class="explorer">
{% if PLAYLIST_ENABLED %}
<div class="left">
<div class="panel panel-menu">
<div class="panel-body">
<h3>
{{ l.slideshow_playlist_panel_title }}
</h3>
<ul>
<li class="{% if not current_playlist %}active{% endif %}">
<a href="{{ url_for('slideshow_slide_list') }}" class="select-playlist">
{% if not current_playlist %}
<i class="fa fa-play icon-left"></i>
{% endif %}
{{ l.slideshow_playlist_panel_item_default }}
</a>
</li>
{% for playlist in playlists %}
<li class="{% if current_playlist %}active{% endif %}">
<a href="{{ url_for('slideshow_slide_list_playlist_use', playlist_id=playlist.id) }}" class="select-playlist">
{% if current_playlist %}
<i class="fa fa-play icon-left"></i>
{% endif %}
{{ playlist.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
<div class="panel panel-inactive">
<div class="panel-body">
<h3>{{ l.slideshow_slide_panel_inactive }}</h3>
{% endif %}
<div class="right">
<div class="panel {% if PLAYLIST_ENABLED %}panel-active{% endif %}">
<div class="panel-body">
<h3>{{ l.slideshow_slide_panel_active }}</h3>
{% with tclass='inactive', slides=disabled_slides %}
{% include 'slideshow/component/table.jinja.html' %}
{% endwith %}
{% with tclass='active', slides=enabled_slides %}
{% include 'slideshow/component/table.jinja.html' %}
{% endwith %}
</div>
</div>
<div class="panel panel-inactive">
<div class="panel-body">
<h3>{{ l.slideshow_slide_panel_inactive }}</h3>
{% with tclass='inactive', slides=disabled_slides %}
{% include 'slideshow/component/table.jinja.html' %}
{% endwith %}
</div>
</div>
</div>
</div>

View File

@ -9,6 +9,10 @@
{{ l.slideshow_slide_form_section_content }}
</h3>
{% if current_playlist %}
<input name="playlist" type="text" id="slide-add-playlist" value="{{ current_playlist.id }}">
{% endif %}
<div class="form-group">
<label for="slide-add-name">{{ l.slideshow_slide_form_label_name }}</label>
<div class="widget">