diff --git a/README.md b/README.md index 64a7516..1537c63 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/data/www/css/animate.min.css b/data/www/css/lib/animate.min.css similarity index 100% rename from data/www/css/animate.min.css rename to data/www/css/lib/animate.min.css diff --git a/data/www/css/flatpickr.min.css b/data/www/css/lib/flatpickr.min.css similarity index 100% rename from data/www/css/flatpickr.min.css rename to data/www/css/lib/flatpickr.min.css diff --git a/data/www/css/main.css b/data/www/css/main.css index ad6233d..d9e65cf 100644 --- a/data/www/css/main.css +++ b/data/www/css/main.css @@ -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; } \ No newline at end of file diff --git a/data/www/js/auth.js b/data/www/js/auth/users.js similarity index 99% rename from data/www/js/auth.js rename to data/www/js/auth/users.js index 1cd4c35..9173435 100644 --- a/data/www/js/auth.js +++ b/data/www/js/auth/users.js @@ -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'); diff --git a/data/www/js/fleet.js b/data/www/js/fleet/screens.js similarity index 99% rename from data/www/js/fleet.js rename to data/www/js/fleet/screens.js index a7e2578..4f7c159 100644 --- a/data/www/js/fleet.js +++ b/data/www/js/fleet/screens.js @@ -16,7 +16,7 @@ jQuery(document).ready(function ($) { } }).tableDnDUpdate(); updatePositions(); - } + }; const showModal = function (modalClass) { $modalsRoot.removeClass('hidden').find('form').trigger('reset'); diff --git a/data/www/js/flatpickr.min.js b/data/www/js/lib/flatpickr.min.js similarity index 100% rename from data/www/js/flatpickr.min.js rename to data/www/js/lib/flatpickr.min.js diff --git a/data/www/js/is-cron-now.js b/data/www/js/lib/is-cron-now.js similarity index 100% rename from data/www/js/is-cron-now.js rename to data/www/js/lib/is-cron-now.js diff --git a/data/www/js/tablednd-fixed.js b/data/www/js/lib/tablednd-fixed.js similarity index 100% rename from data/www/js/tablednd-fixed.js rename to data/www/js/lib/tablednd-fixed.js diff --git a/data/www/js/playlist/playlists.js b/data/www/js/playlist/playlists.js new file mode 100644 index 0000000..87c19c1 --- /dev/null +++ b/data/www/js/playlist/playlists.js @@ -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(); +}); diff --git a/data/www/js/slideshow.js b/data/www/js/slideshow/slides.js similarity index 100% rename from data/www/js/slideshow.js rename to data/www/js/slideshow/slides.js diff --git a/docs/setup-run-headless.md b/docs/setup-run-headless.md index 76d66d8..76f8141 100644 --- a/docs/setup-run-headless.md +++ b/docs/setup-run-headless.md @@ -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 diff --git a/docs/setup-run-on-rpi.md b/docs/setup-run-on-rpi.md index ccae030..da270a2 100644 --- a/docs/setup-run-on-rpi.md +++ b/docs/setup-run-on-rpi.md @@ -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 diff --git a/lang/en.json b/lang/en.json index 850974e..908bace 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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)", diff --git a/lang/fr.json b/lang/fr.json index 948e9de..d319f17 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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)", diff --git a/src/controller/AuthController.py b/src/controller/AuthController.py index 153eee0..d9823a8 100644 --- a/src/controller/AuthController.py +++ b/src/controller/AuthController.py @@ -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 diff --git a/src/controller/FleetController.py b/src/controller/FleetController.py index 1dfd7fb..2e8a4b7 100644 --- a/src/controller/FleetController.py +++ b/src/controller/FleetController.py @@ -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( diff --git a/src/controller/PlayerController.py b/src/controller/PlayerController.py index 8754e4e..9b33b06 100644 --- a/src/controller/PlayerController.py +++ b/src/controller/PlayerController.py @@ -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/', '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/', '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)) diff --git a/src/controller/PlaylistController.py b/src/controller/PlaylistController.py new file mode 100644 index 0000000..be6bc6a --- /dev/null +++ b/src/controller/PlaylistController.py @@ -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'}) diff --git a/src/controller/SettingsController.py b/src/controller/SettingsController.py index c837e7a..63fcb8a 100644 --- a/src/controller/SettingsController.py +++ b/src/controller/SettingsController.py @@ -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': diff --git a/src/controller/SlideshowController.py b/src/controller/SlideshowController.py index 5f014f1..07714e0 100644 --- a/src/controller/SlideshowController.py +++ b/src/controller/SlideshowController.py @@ -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/', '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): diff --git a/src/controller/SysinfoController.py b/src/controller/SysinfoController.py index 9e01fa5..985e53a 100644 --- a/src/controller/SysinfoController.py +++ b/src/controller/SysinfoController.py @@ -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 diff --git a/src/manager/DatabaseManager.py b/src/manager/DatabaseManager.py index 22ca2ed..263a477 100644 --- a/src/manager/DatabaseManager.py +++ b/src/manager/DatabaseManager.py @@ -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 diff --git a/src/manager/PlaylistManager.py b/src/manager/PlaylistManager.py new file mode 100644 index 0000000..23771c0 --- /dev/null +++ b/src/manager/PlaylistManager.py @@ -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] + diff --git a/src/manager/ScreenManager.py b/src/manager/ScreenManager.py index 4e207e9..de8bed3 100644 --- a/src/manager/ScreenManager.py +++ b/src/manager/ScreenManager.py @@ -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" diff --git a/src/manager/SlideManager.py b/src/manager/SlideManager.py index 4c808be..0337c6b 100644 --- a/src/manager/SlideManager.py +++ b/src/manager/SlideManager.py @@ -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)) \ No newline at end of file diff --git a/src/manager/UserManager.py b/src/manager/UserManager.py index de2d30a..42f6478 100644 --- a/src/manager/UserManager.py +++ b/src/manager/UserManager.py @@ -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): diff --git a/src/manager/VariableManager.py b/src/manager/VariableManager.py index d1888ad..12e88ef 100644 --- a/src/manager/VariableManager.py +++ b/src/manager/VariableManager.py @@ -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}, diff --git a/src/model/entity/Playlist.py b/src/model/entity/Playlist.py new file mode 100644 index 0000000..20d1088 --- /dev/null +++ b/src/model/entity/Playlist.py @@ -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 diff --git a/src/model/entity/Slide.py b/src/model/entity/Slide.py index fed0d8b..45556a0 100644 --- a/src/model/entity/Slide.py +++ b/src/model/entity/Slide.py @@ -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, } diff --git a/src/model/enum/HookType.py b/src/model/enum/HookType.py index 6b1ecfd..5c0762a 100644 --- a/src/model/enum/HookType.py +++ b/src/model/enum/HookType.py @@ -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' diff --git a/src/service/ModelStore.py b/src/service/ModelStore.py index 1aa530d..700606a 100644 --- a/src/service/ModelStore.py +++ b/src/service/ModelStore.py @@ -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 diff --git a/src/service/TemplateRenderer.py b/src/service/TemplateRenderer.py index 5c3bb1f..d7ee74a 100644 --- a/src/service/TemplateRenderer.py +++ b/src/service/TemplateRenderer.py @@ -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, diff --git a/src/service/WebServer.py b/src/service/WebServer.py index e1ab350..9e794bc 100644 --- a/src/service/WebServer.py +++ b/src/service/WebServer.py @@ -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: diff --git a/src/utils.py b/src/utils.py index 497fedc..1c2277d 100644 --- a/src/utils.py +++ b/src/utils.py @@ -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}" diff --git a/system/obscreen-manager.service b/system/obscreen-composer.service similarity index 93% rename from system/obscreen-manager.service rename to system/obscreen-composer.service index baccadd..1107351 100644 --- a/system/obscreen-manager.service +++ b/system/obscreen-composer.service @@ -1,5 +1,5 @@ [Unit] -Description=Obscreen Manager +Description=Obscreen Composer After=network.target [Service] diff --git a/views/auth/component/table.jinja.html b/views/auth/component/table.jinja.html index 788a5f2..f82cfb9 100644 --- a/views/auth/component/table.jinja.html +++ b/views/auth/component/table.jinja.html @@ -19,7 +19,9 @@
+
{{ user.id }}
+ {{ user.username }}
diff --git a/views/auth/list.jinja.html b/views/auth/list.jinja.html index 150480a..876fb99 100644 --- a/views/auth/list.jinja.html +++ b/views/auth/list.jinja.html @@ -9,7 +9,7 @@ {% endblock %} {% block add_js %} - + {{ HOOK(H_AUTH_JAVASCRIPT) }} {% endblock %} diff --git a/views/base.jinja.html b/views/base.jinja.html index 87dfedf..657f244 100755 --- a/views/base.jinja.html +++ b/views/base.jinja.html @@ -34,7 +34,7 @@ {{ HOOK(H_ROOT_CSS) }} -
+
{% set fleet_mode = request.args.get('fleet_mode') == '1' %} {% block header %} @@ -56,7 +56,14 @@ {{ l.slideshow_page_title }} - {% if FLEET_ENABLED %} + {% if PLAYLIST_ENABLED %} +
  • + + {{ l.playlist_page_title }} + +
  • + {% endif %} + {% if FLEET_COMPOSER_ENABLED %}
  • {{ l.fleet_page_title }} @@ -115,7 +122,8 @@ - + + {{ HOOK(H_FLEET_JAVASCRIPT) }} {% endblock %} diff --git a/views/player/player.jinja.html b/views/player/player.jinja.html index f87ed99..81c39fc 100755 --- a/views/player/player.jinja.html +++ b/views/player/player.jinja.html @@ -6,7 +6,7 @@ {% if slide_animation_enabled.eval() %} - + {% endif %} - +
    @@ -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(); diff --git a/views/playlist/component/table.jinja.html b/views/playlist/component/table.jinja.html new file mode 100644 index 0000000..a9e0ec1 --- /dev/null +++ b/views/playlist/component/table.jinja.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + {% for playlist in playlists %} + + + + + + + {% endfor %} + +
    {{ l.playlist_panel_th_name }}{{ l.playlist_panel_th_enabled }}{{ l.playlist_panel_th_duration }}{{ l.playlist_panel_th_activity }}
    + {{ l.playlist_panel_empty|replace( + '%link%', + (''~l.playlist_button_add~'')|safe + ) }} +
    +
    + {% if playlist.id %} +
    {{ playlist.id }}
    + {% else %} +
    + {% endif %} + + + {{ playlist.name }} +
    +
    + {% if playlist.id %} + + {% endif %} + + {{ seconds_to_hhmmss(durations[playlist.id]) }} + + {% if playlist.id %} + + + + + + + {% endif %} +
    \ No newline at end of file diff --git a/views/playlist/list.jinja.html b/views/playlist/list.jinja.html new file mode 100644 index 0000000..0767db9 --- /dev/null +++ b/views/playlist/list.jinja.html @@ -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 %} + + + {{ HOOK(H_PLAYLIST_JAVASCRIPT) }} +{% endblock %} + +{% block page %} +
    +

    {{ l.playlist_page_title }}

    + +
    + {{ HOOK(H_PLAYLIST_TOOLBAR_ACTIONS_START) }} + + {{ HOOK(H_PLAYLIST_TOOLBAR_ACTIONS_END) }} +
    +
    + +
    +
    +

    {{ l.playlist_panel_active }}

    + + {% with tclass='active', playlists=enabled_playlists %} + {% include 'playlist/component/table.jinja.html' %} + {% endwith %} +
    +
    +
    +
    +

    {{ l.playlist_panel_inactive }}

    + + {% with tclass='inactive', playlists=disabled_playlists %} + {% include 'playlist/component/table.jinja.html' %} + {% endwith %} +
    +
    + + +
    +{% endblock %} diff --git a/views/playlist/modal/add.jinja.html b/views/playlist/modal/add.jinja.html new file mode 100644 index 0000000..5ddcf04 --- /dev/null +++ b/views/playlist/modal/add.jinja.html @@ -0,0 +1,25 @@ + \ No newline at end of file diff --git a/views/playlist/modal/edit.jinja.html b/views/playlist/modal/edit.jinja.html new file mode 100644 index 0000000..15283d5 --- /dev/null +++ b/views/playlist/modal/edit.jinja.html @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/views/slideshow/list.jinja.html b/views/slideshow/list.jinja.html index 87e1fde..f3d4a55 100644 --- a/views/slideshow/list.jinja.html +++ b/views/slideshow/list.jinja.html @@ -5,18 +5,20 @@ {% endblock %} {% block add_css %} - + {{ HOOK(H_SLIDESHOW_CSS) }} {% endblock %} {% block add_js %} - - - + + + {{ HOOK(H_SLIDESHOW_JAVASCRIPT) }} {% endblock %} +{% block container_class %}{% if PLAYLIST_ENABLED %}expand{% endif %}{% endblock %} + {% block page %}

    {{ l.slideshow_page_title }}

    @@ -28,7 +30,7 @@ {{ HOOK(H_FLEETMODE_SLIDESHOW_TOOLBAR_ACTIONS) }} {% endif %} - + @@ -36,6 +38,7 @@ + {{ HOOK(H_SLIDESHOW_TOOLBAR_ACTIONS_END) }}
    @@ -47,22 +50,57 @@
  • {% endif %} -
    -
    -

    {{ l.slideshow_slide_panel_active }}

    - - {% with tclass='active', slides=enabled_slides %} - {% include 'slideshow/component/table.jinja.html' %} - {% endwith %} +
    + {% if PLAYLIST_ENABLED %} +
    +
    + +
    -
    -
    -
    -

    {{ l.slideshow_slide_panel_inactive }}

    + {% endif %} +
    +
    +
    +

    {{ l.slideshow_slide_panel_active }}

    - {% 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 %} +
    +
    +
    +
    +

    {{ l.slideshow_slide_panel_inactive }}

    + + {% with tclass='inactive', slides=disabled_slides %} + {% include 'slideshow/component/table.jinja.html' %} + {% endwith %} +
    +
    diff --git a/views/slideshow/modal/add.jinja.html b/views/slideshow/modal/add.jinja.html index a3a9a48..4f848a5 100644 --- a/views/slideshow/modal/add.jinja.html +++ b/views/slideshow/modal/add.jinja.html @@ -9,6 +9,10 @@ {{ l.slideshow_slide_form_section_content }} + {% if current_playlist %} + + {% endif %} +