globe-server 0.0.30__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- globe_server/__init__.py +5 -0
- globe_server/api/__init__.py +22 -0
- globe_server/api/media.py +191 -0
- globe_server/api/motor.py +44 -0
- globe_server/api/network.py +73 -0
- globe_server/api/playback.py +59 -0
- globe_server/api/playlist.py +182 -0
- globe_server/api/settings.py +67 -0
- globe_server/api/status.py +62 -0
- globe_server/api/update.py +254 -0
- globe_server/api/version.py +39 -0
- globe_server/api/websocket.py +169 -0
- globe_server/config.py +83 -0
- globe_server/db/__init__.py +5 -0
- globe_server/db/broadcast_queue.py +106 -0
- globe_server/db/database.py +214 -0
- globe_server/db/db_events.py +75 -0
- globe_server/db/events.py +115 -0
- globe_server/db/orm.py +154 -0
- globe_server/db/schemas.py +199 -0
- globe_server/hardware/esp32_client.py +293 -0
- globe_server/hardware/server_hardware.py +89 -0
- globe_server/main.py +274 -0
- globe_server/models/__init__.py +5 -0
- globe_server/models/status.py +31 -0
- globe_server/network_manager/mdns_manager.py +123 -0
- globe_server/network_manager/models.py +61 -0
- globe_server/network_manager/network_fsm.py +595 -0
- globe_server/network_manager/network_service.py +75 -0
- globe_server/network_manager/platform/__init__.py +51 -0
- globe_server/network_manager/platform/base_network.py +52 -0
- globe_server/network_manager/platform/linux_network.py +184 -0
- globe_server/network_manager/platform/windows_network.py +288 -0
- globe_server/playback/event_emitter.py +51 -0
- globe_server/playback/media_player.py +177 -0
- globe_server/playback/media_services/__init__.py +11 -0
- globe_server/playback/media_services/earth_viz_service.py +111 -0
- globe_server/playback/media_services/media_window.py +308 -0
- globe_server/playback/media_services/vlc_service.py +194 -0
- globe_server/playback/playback_context.py +369 -0
- globe_server/playback/playback_fsm.py +513 -0
- globe_server/playback/playback_service.py +57 -0
- globe_server/playback/playback_state.py +100 -0
- globe_server/playback/playback_tracker.py +167 -0
- globe_server/setup.py +170 -0
- globe_server/static/index-2RLu-Oe0.js +73 -0
- globe_server/static/index-DX6Xxgpy.css +1 -0
- globe_server/static/index.html +14 -0
- globe_server/static/vite.svg +1 -0
- globe_server/tools/espota.py +325 -0
- globe_server/utils/__init__.py +0 -0
- globe_server/utils/eventbus.py +75 -0
- globe_server/utils/files.py +193 -0
- globe_server/utils/uart.py +39 -0
- globe_server-0.0.30.dist-info/METADATA +259 -0
- globe_server-0.0.30.dist-info/RECORD +60 -0
- globe_server-0.0.30.dist-info/WHEEL +5 -0
- globe_server-0.0.30.dist-info/entry_points.txt +3 -0
- globe_server-0.0.30.dist-info/licenses/LICENSE +21 -0
- globe_server-0.0.30.dist-info/top_level.txt +1 -0
globe_server/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
from .status import router as status_router
|
|
3
|
+
from .media import router as media_router
|
|
4
|
+
from .motor import router as motor_router
|
|
5
|
+
from .playlist import router as playlist_router
|
|
6
|
+
from .playback import router as playback_router
|
|
7
|
+
from .update import router as update_router
|
|
8
|
+
from .settings import router as settings_router
|
|
9
|
+
from .version import router as version_router
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
api_router = APIRouter()
|
|
13
|
+
|
|
14
|
+
api_router.include_router(status_router)
|
|
15
|
+
api_router.include_router(media_router)
|
|
16
|
+
api_router.include_router(motor_router)
|
|
17
|
+
api_router.include_router(playlist_router)
|
|
18
|
+
api_router.include_router(playback_router)
|
|
19
|
+
api_router.include_router(update_router)
|
|
20
|
+
api_router.include_router(settings_router)
|
|
21
|
+
api_router.include_router(version_router)
|
|
22
|
+
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# app/api/media.py
|
|
2
|
+
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File, Form, Query
|
|
3
|
+
from typing import List
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
from globe_server.db.orm import Media
|
|
6
|
+
from globe_server.db.database import create, update, delete, get_by_id, get_all
|
|
7
|
+
from globe_server.db.schemas import MediaCreate, MediaRead, MediaUpdate, PlanetOptions
|
|
8
|
+
from globe_server.utils import files
|
|
9
|
+
from globe_server import config
|
|
10
|
+
import os
|
|
11
|
+
import logging
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/media", tags=["media"])
|
|
15
|
+
|
|
16
|
+
@router.get("/files")
|
|
17
|
+
def list_media_files(type: str = Query(..., regex="^(video|image)$")):
|
|
18
|
+
"""List available media files on the server for a given type."""
|
|
19
|
+
try:
|
|
20
|
+
file_list = files.list_media_files(type)
|
|
21
|
+
return {"files": file_list}
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logging.error(f"Error listing media files: {str(e)}", exc_info=True)
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
26
|
+
detail=str(e)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@router.post("/upload/")
|
|
30
|
+
async def upload_file(
|
|
31
|
+
file: UploadFile = File(...),
|
|
32
|
+
media_type: str = Form(...),
|
|
33
|
+
):
|
|
34
|
+
try:
|
|
35
|
+
if media_type == "image":
|
|
36
|
+
upload_directory = os.path.join(config.GLOBE_MEDIA_DIRECTORY, "Images")
|
|
37
|
+
elif media_type == "video":
|
|
38
|
+
upload_directory = os.path.join(config.GLOBE_MEDIA_DIRECTORY, "Videos")
|
|
39
|
+
else:
|
|
40
|
+
raise HTTPException(
|
|
41
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
42
|
+
detail=f"Invalid media type: {media_type}. Allowed types: image, video"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
file_path = await files.save_uploaded_file(file, upload_directory)
|
|
46
|
+
return {"filepath": file_path}
|
|
47
|
+
except ValueError as e:
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
49
|
+
except HTTPException as e:
|
|
50
|
+
raise e
|
|
51
|
+
except Exception as e:
|
|
52
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
53
|
+
|
|
54
|
+
@router.get("/", response_model=List[MediaRead])
|
|
55
|
+
def get_all_media():
|
|
56
|
+
try:
|
|
57
|
+
media_items = get_all(Media)
|
|
58
|
+
return media_items
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logging.error(f"Error getting all media: {e}")
|
|
61
|
+
raise HTTPException(
|
|
62
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@router.post("/", response_model=MediaRead)
|
|
66
|
+
def create_media(media: MediaCreate):
|
|
67
|
+
try:
|
|
68
|
+
# Only validate filepath for image and video types
|
|
69
|
+
if media.type in ("image", "video"):
|
|
70
|
+
if not media.filepath or not os.path.exists(media.filepath):
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
73
|
+
detail="Filepath is required and must exist for image and video types",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Serialize planet_options to JSON string if it exists
|
|
77
|
+
planet_options_json = None
|
|
78
|
+
if media.planet_options:
|
|
79
|
+
if isinstance(media.planet_options, dict):
|
|
80
|
+
planet_options_json = json.dumps(media.planet_options)
|
|
81
|
+
else:
|
|
82
|
+
planet_options_json = media.planet_options.model_dump_json(exclude_none=True)
|
|
83
|
+
|
|
84
|
+
# Create new media item
|
|
85
|
+
db_media = Media(
|
|
86
|
+
type=media.type,
|
|
87
|
+
name=media.name,
|
|
88
|
+
filepath=media.filepath,
|
|
89
|
+
display_duration=media.display_duration,
|
|
90
|
+
planet_options=planet_options_json
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Use CRUD function that handles broadcasting
|
|
94
|
+
db_media = create(db_media)
|
|
95
|
+
|
|
96
|
+
return db_media
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logging.error(f"Error creating media: {e}")
|
|
99
|
+
#db.rollback()
|
|
100
|
+
raise HTTPException(
|
|
101
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@router.get("/{media_id}", response_model=MediaRead)
|
|
105
|
+
def get_media(media_id: int):
|
|
106
|
+
try:
|
|
107
|
+
media = get_by_id(Media, media_id)
|
|
108
|
+
if media is None:
|
|
109
|
+
raise HTTPException(
|
|
110
|
+
status_code=status.HTTP_404_NOT_FOUND, detail="Media item not found"
|
|
111
|
+
)
|
|
112
|
+
return media
|
|
113
|
+
except HTTPException:
|
|
114
|
+
raise
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logging.error(f"Error getting media: {e}")
|
|
117
|
+
raise HTTPException(
|
|
118
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@router.put("/{media_id}", response_model=MediaRead)
|
|
122
|
+
def update_media(media_id: int, media_update: MediaUpdate):
|
|
123
|
+
try:
|
|
124
|
+
# Get the existing media item
|
|
125
|
+
db_media = get_by_id(Media, media_id)
|
|
126
|
+
if db_media is None:
|
|
127
|
+
raise HTTPException(
|
|
128
|
+
status_code=status.HTTP_404_NOT_FOUND, detail="Media item not found"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Update the fields that are provided
|
|
132
|
+
update_data = media_update.model_dump(exclude_unset=True)
|
|
133
|
+
|
|
134
|
+
# Handle planet_options separately if it exists
|
|
135
|
+
if 'planet_options' in update_data and update_data['planet_options'] is not None:
|
|
136
|
+
if isinstance(update_data['planet_options'], dict):
|
|
137
|
+
db_media.planet_options = json.dumps(update_data['planet_options'])
|
|
138
|
+
else:
|
|
139
|
+
db_media.planet_options = update_data['planet_options'].model_dump_json(exclude_none=True)
|
|
140
|
+
del update_data['planet_options']
|
|
141
|
+
|
|
142
|
+
# Update all other fields
|
|
143
|
+
for key, value in update_data.items():
|
|
144
|
+
if value is not None: # Only update non-None values
|
|
145
|
+
setattr(db_media, key, value)
|
|
146
|
+
|
|
147
|
+
# Use CRUD function that handles broadcasting
|
|
148
|
+
db_media = update(db_media)
|
|
149
|
+
|
|
150
|
+
return db_media
|
|
151
|
+
except HTTPException:
|
|
152
|
+
raise
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logging.error(f"Error updating media: {e}")
|
|
155
|
+
raise HTTPException(
|
|
156
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@router.delete("/{media_id}")
|
|
160
|
+
def delete_media(media_id: int):
|
|
161
|
+
try:
|
|
162
|
+
# Get the media item
|
|
163
|
+
db_media = get_by_id(Media, media_id)
|
|
164
|
+
if db_media is None:
|
|
165
|
+
raise HTTPException(
|
|
166
|
+
status_code=status.HTTP_404_NOT_FOUND, detail="Media item not found"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Store filepath and type before deletion
|
|
170
|
+
filepath = db_media.filepath
|
|
171
|
+
media_type = db_media.type
|
|
172
|
+
|
|
173
|
+
# Delete the media item from the database using CRUD function
|
|
174
|
+
delete(db_media)
|
|
175
|
+
|
|
176
|
+
# Delete the file from the file system if it's an image or video
|
|
177
|
+
if media_type in ("image", "video") and filepath:
|
|
178
|
+
try:
|
|
179
|
+
files.delete_file(filepath)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
# Log the error but don't raise an exception, as the media item has already been deleted
|
|
182
|
+
logging.warning(f"Error deleting file: {e}")
|
|
183
|
+
|
|
184
|
+
return {"message": "Media item deleted successfully"}
|
|
185
|
+
except HTTPException:
|
|
186
|
+
raise
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logging.error(f"Error deleting media: {e}")
|
|
189
|
+
raise HTTPException(
|
|
190
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
191
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# app/api/motor.py
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, status
|
|
4
|
+
from globe_server import config
|
|
5
|
+
from globe_server.hardware.esp32_client import esp32_client, ESP32Error
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/motor", tags=["motor"])
|
|
11
|
+
|
|
12
|
+
@router.post("/start")
|
|
13
|
+
async def start_motor():
|
|
14
|
+
try:
|
|
15
|
+
await esp32_client.set_motor_speed(config.MOTOR_RPM)
|
|
16
|
+
return {"message": "Motor started successfully"}
|
|
17
|
+
except ESP32Error as e:
|
|
18
|
+
raise HTTPException(
|
|
19
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
20
|
+
detail=str(e)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@router.post("/stop")
|
|
24
|
+
async def stop_motor():
|
|
25
|
+
try:
|
|
26
|
+
await esp32_client.set_motor_speed(0)
|
|
27
|
+
return {"message": "Motor stopped successfully"}
|
|
28
|
+
except ESP32Error as e:
|
|
29
|
+
raise HTTPException(
|
|
30
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
31
|
+
detail=str(e)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@router.get("/status")
|
|
35
|
+
async def get_motor_status():
|
|
36
|
+
"""Get the current motor status."""
|
|
37
|
+
try:
|
|
38
|
+
return await esp32_client.get_status()
|
|
39
|
+
except ESP32Error as e:
|
|
40
|
+
logger.error(f"Error getting motor status: {e}")
|
|
41
|
+
raise HTTPException(
|
|
42
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
43
|
+
detail=str(e)
|
|
44
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from globe_server.network_manager.network_service import network_manager as state_machine, connect_to_wifi, scan_available_networks
|
|
5
|
+
from globe_server.network_manager.models import NetworkInfo as NetworkInfoModel
|
|
6
|
+
from globe_server.network_manager.platform import get_current_connection_info
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/network", tags=["network"])
|
|
12
|
+
|
|
13
|
+
class WiFiConfig(BaseModel):
|
|
14
|
+
ssid: str
|
|
15
|
+
password: str
|
|
16
|
+
|
|
17
|
+
# Use our model directly
|
|
18
|
+
class WiFiNetwork(BaseModel):
|
|
19
|
+
ssid: str
|
|
20
|
+
signal_strength: int
|
|
21
|
+
security: str
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_model(cls, model: NetworkInfoModel) -> 'WiFiNetwork':
|
|
25
|
+
return cls(
|
|
26
|
+
ssid=model.ssid,
|
|
27
|
+
signal_strength=model.signal_strength,
|
|
28
|
+
security=model.security
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
class WiFiStatus(BaseModel):
|
|
32
|
+
status: str # FSM state (idle, connecting, connected, etc.)
|
|
33
|
+
current_ssid: str | None # From OS
|
|
34
|
+
ip_address: str | None # From OS
|
|
35
|
+
esp32_connected: bool # From FSM
|
|
36
|
+
error: str | None = None # From FSM
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.post("/wifi")
|
|
40
|
+
async def configure_wifi(wifi_config: WiFiConfig):
|
|
41
|
+
"""Initiate WiFi connection (async operation)."""
|
|
42
|
+
success = await connect_to_wifi(wifi_config.ssid, wifi_config.password)
|
|
43
|
+
if not success:
|
|
44
|
+
raise HTTPException(
|
|
45
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
46
|
+
detail="Failed to initiate WiFi connection"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return {"message": f"Connection to {wifi_config.ssid} initiated"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/wifi/status", response_model=WiFiStatus)
|
|
53
|
+
async def get_wifi_status():
|
|
54
|
+
"""Get current WiFi connection status."""
|
|
55
|
+
# Query OS for real-time connection info
|
|
56
|
+
conn_info = await get_current_connection_info()
|
|
57
|
+
|
|
58
|
+
return WiFiStatus(
|
|
59
|
+
status=state_machine.context.current_state.value,
|
|
60
|
+
current_ssid=conn_info.ssid,
|
|
61
|
+
ip_address=conn_info.ip_address,
|
|
62
|
+
esp32_connected=state_machine.context.esp32_connected,
|
|
63
|
+
error=state_machine.context.error_message
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@router.get("/wifi/scan", response_model=dict)
|
|
67
|
+
async def scan_networks():
|
|
68
|
+
"""Scan for available WiFi networks."""
|
|
69
|
+
network_models = await scan_available_networks()
|
|
70
|
+
networks = [WiFiNetwork.from_model(model) for model in network_models]
|
|
71
|
+
return {"networks": networks}
|
|
72
|
+
|
|
73
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, status
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
import logging
|
|
4
|
+
import asyncio
|
|
5
|
+
from globe_server.playback import playback_service
|
|
6
|
+
import time
|
|
7
|
+
import vlc
|
|
8
|
+
import subprocess
|
|
9
|
+
from globe_server import config
|
|
10
|
+
import os
|
|
11
|
+
import json
|
|
12
|
+
from typing import List, Optional, Dict, Any
|
|
13
|
+
|
|
14
|
+
# Create router for REST endpoints
|
|
15
|
+
router = APIRouter(prefix="/playback", tags=["playback"])
|
|
16
|
+
|
|
17
|
+
# Configure logging
|
|
18
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
|
19
|
+
|
|
20
|
+
# =====================================================================
|
|
21
|
+
# API ENDPOINTS
|
|
22
|
+
# =====================================================================
|
|
23
|
+
|
|
24
|
+
@router.post("/play")
|
|
25
|
+
async def play_playlist():
|
|
26
|
+
"""Start playing the playlist or resume if paused."""
|
|
27
|
+
try:
|
|
28
|
+
if await playback_service.play():
|
|
29
|
+
return JSONResponse({"status": "success", "message": "Playback started/resumed"})
|
|
30
|
+
else:
|
|
31
|
+
return JSONResponse({"status": "error", "message": "Failed to start playback"})
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logging.error(f"Error starting playback: {e}")
|
|
34
|
+
return JSONResponse({"status": "error", "message": str(e)})
|
|
35
|
+
|
|
36
|
+
@router.post("/stop")
|
|
37
|
+
async def stop_playback():
|
|
38
|
+
"""Stop the current playback."""
|
|
39
|
+
try:
|
|
40
|
+
if await playback_service.stop():
|
|
41
|
+
return JSONResponse({"status": "success", "message": "Playback stopped"})
|
|
42
|
+
else:
|
|
43
|
+
return JSONResponse({"status": "error", "message": "Failed to stop playback"})
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logging.error(f"Error stopping playback: {e}")
|
|
46
|
+
return JSONResponse({"status": "error", "message": str(e)})
|
|
47
|
+
|
|
48
|
+
@router.post("/pause")
|
|
49
|
+
async def pause_item():
|
|
50
|
+
"""Pauses the currently playing media item."""
|
|
51
|
+
try:
|
|
52
|
+
if await playback_service.pause():
|
|
53
|
+
return JSONResponse({"status": "success", "message": "Playback paused"})
|
|
54
|
+
else:
|
|
55
|
+
return JSONResponse({"status": "error", "message": "Failed to pause playback"})
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logging.error(f"Error pausing playback: {e}")
|
|
58
|
+
return JSONResponse({"status": "error", "message": str(e)})
|
|
59
|
+
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# app/api/playlist.py
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from globe_server.db.orm import Media, PlaylistItem
|
|
8
|
+
from globe_server.db.database import create, update, delete, get_by_id, get_all, get_max_position, get_all_ordered_by_position, update_playlist_position
|
|
9
|
+
from globe_server.db.schemas import PlaylistItemCreate, PlaylistItemUpdate, PlaylistItemRead
|
|
10
|
+
import os
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
# Create router for REST endpoints
|
|
14
|
+
router = APIRouter(prefix="/playlist", tags=["playlist"])
|
|
15
|
+
|
|
16
|
+
# Helper functions for consistent data handling using SQLAlchemy ORM
|
|
17
|
+
|
|
18
|
+
def _get_playlist_item_with_media(playlist_id: int) -> Optional[PlaylistItem]:
|
|
19
|
+
"""Fetch a single playlist item with its media data using ORM"""
|
|
20
|
+
return get_by_id(PlaylistItem, playlist_id)
|
|
21
|
+
|
|
22
|
+
# =====================================================================
|
|
23
|
+
# HELPER FUNCTIONS
|
|
24
|
+
# =====================================================================
|
|
25
|
+
|
|
26
|
+
def get_playlist_items() -> List[PlaylistItem]:
|
|
27
|
+
"""
|
|
28
|
+
Get all playlist items ordered by position.
|
|
29
|
+
"""
|
|
30
|
+
return get_all_ordered_by_position()
|
|
31
|
+
|
|
32
|
+
# =====================================================================
|
|
33
|
+
# API ENDPOINTS
|
|
34
|
+
# =====================================================================
|
|
35
|
+
|
|
36
|
+
@router.get("/", response_model=List[PlaylistItemRead])
|
|
37
|
+
async def get_all_playlist_items():
|
|
38
|
+
"""
|
|
39
|
+
Get all playlist items with complete media details.
|
|
40
|
+
|
|
41
|
+
This endpoint is for direct REST API access to playlist data.
|
|
42
|
+
Real-time updates are now handled by the status WebSocket.
|
|
43
|
+
"""
|
|
44
|
+
return get_playlist_items()
|
|
45
|
+
|
|
46
|
+
@router.post("/", response_model=PlaylistItemRead)
|
|
47
|
+
async def create_playlist_item(playlist_item: PlaylistItemCreate):
|
|
48
|
+
logging.info(f"=== CREATE PLAYLIST ITEM CALLED ===")
|
|
49
|
+
logging.info(f"Received playlist_item: {playlist_item}")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# Check if the media_id exists
|
|
53
|
+
logging.info(f"Checking if media_id {playlist_item.media_id} exists...")
|
|
54
|
+
media = get_by_id(Media, playlist_item.media_id)
|
|
55
|
+
if media is None:
|
|
56
|
+
logging.error(f"Media ID {playlist_item.media_id} not found")
|
|
57
|
+
raise HTTPException(
|
|
58
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid media_id"
|
|
59
|
+
)
|
|
60
|
+
logging.info(f"Media found: {media.name}")
|
|
61
|
+
|
|
62
|
+
# Get the next available position
|
|
63
|
+
logging.info("Getting next position...")
|
|
64
|
+
next_position = get_max_position() + 1
|
|
65
|
+
logging.info(f"Next position: {next_position}")
|
|
66
|
+
|
|
67
|
+
# Create new playlist item
|
|
68
|
+
logging.info("Creating PlaylistItem object...")
|
|
69
|
+
new_item = PlaylistItem(
|
|
70
|
+
media_id=playlist_item.media_id,
|
|
71
|
+
position=next_position,
|
|
72
|
+
status="unplayed",
|
|
73
|
+
elapsed_time=0
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Use CRUD function that handles broadcasting
|
|
77
|
+
logging.info("Calling create() to save to database...")
|
|
78
|
+
new_item = create(new_item)
|
|
79
|
+
logging.info(f"Successfully created playlist item with ID: {new_item.id}")
|
|
80
|
+
|
|
81
|
+
return new_item
|
|
82
|
+
except HTTPException:
|
|
83
|
+
raise
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logging.error(f"Error creating playlist item: {e}", exc_info=True)
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@router.get("/{playlist_id}", response_model=PlaylistItemRead)
|
|
91
|
+
async def get_playlist_item(playlist_id: int):
|
|
92
|
+
"""
|
|
93
|
+
NOTE: This endpoint is not currently used by the frontend.
|
|
94
|
+
The frontend primarily receives playlist data via WebSockets.
|
|
95
|
+
|
|
96
|
+
This endpoint is kept for API completeness, external tools,
|
|
97
|
+
and as a fallback mechanism.
|
|
98
|
+
"""
|
|
99
|
+
playlist_item = _get_playlist_item_with_media(playlist_id)
|
|
100
|
+
if playlist_item is None:
|
|
101
|
+
raise HTTPException(
|
|
102
|
+
status_code=status.HTTP_404_NOT_FOUND, detail="Playlist item not found"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return playlist_item
|
|
106
|
+
|
|
107
|
+
@router.put("/{playlist_id}", response_model=PlaylistItemRead)
|
|
108
|
+
async def update_playlist_item(playlist_id: int, playlist_item_update: PlaylistItemUpdate):
|
|
109
|
+
try:
|
|
110
|
+
# Check if the item exists
|
|
111
|
+
db_item = get_by_id(PlaylistItem, playlist_id)
|
|
112
|
+
if db_item is None:
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=status.HTTP_404_NOT_FOUND, detail="Playlist item not found"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Check if media_id exists if it's being updated
|
|
118
|
+
if playlist_item_update.media_id is not None:
|
|
119
|
+
media = get_by_id(Media, playlist_item_update.media_id)
|
|
120
|
+
if media is None:
|
|
121
|
+
raise HTTPException(
|
|
122
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid media_id"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Update the playlist item attributes
|
|
126
|
+
update_data = playlist_item_update.model_dump(exclude_unset=True)
|
|
127
|
+
|
|
128
|
+
# If position is being updated, use database function to handle shifts
|
|
129
|
+
if 'position' in update_data:
|
|
130
|
+
new_position = update_data['position']
|
|
131
|
+
logging.info(f"Updating item {playlist_id} position to {new_position}")
|
|
132
|
+
|
|
133
|
+
# Database function handles shifting other items and updating this one
|
|
134
|
+
update_playlist_position(playlist_id, new_position)
|
|
135
|
+
|
|
136
|
+
# Get the updated item to return
|
|
137
|
+
db_item = get_by_id(PlaylistItem, playlist_id)
|
|
138
|
+
else:
|
|
139
|
+
# No position change, just update other attributes
|
|
140
|
+
for key, value in update_data.items():
|
|
141
|
+
setattr(db_item, key, value)
|
|
142
|
+
db_item = update(db_item)
|
|
143
|
+
|
|
144
|
+
# Broadcasting is now handled by the CRUD functions
|
|
145
|
+
return db_item
|
|
146
|
+
except HTTPException:
|
|
147
|
+
raise
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logging.error(f"Error updating playlist item: {e}")
|
|
150
|
+
raise HTTPException(
|
|
151
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@router.delete("/{playlist_id}")
|
|
155
|
+
async def delete_playlist_item(playlist_id: int):
|
|
156
|
+
try:
|
|
157
|
+
# Check if the item exists
|
|
158
|
+
db_item = get_by_id(PlaylistItem, playlist_id)
|
|
159
|
+
if db_item is None:
|
|
160
|
+
raise HTTPException(
|
|
161
|
+
status_code=status.HTTP_404_NOT_FOUND, detail="Playlist item not found"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Delete the item using CRUD function
|
|
165
|
+
delete(db_item)
|
|
166
|
+
|
|
167
|
+
# Reset positions for remaining items
|
|
168
|
+
all_items = get_all_ordered_by_position()
|
|
169
|
+
for i, item in enumerate(all_items, 1):
|
|
170
|
+
if item.position != i:
|
|
171
|
+
item.position = i
|
|
172
|
+
update(item)
|
|
173
|
+
|
|
174
|
+
# Broadcasting is now handled by the CRUD functions
|
|
175
|
+
return {"message": "Playlist item deleted successfully"}
|
|
176
|
+
except HTTPException:
|
|
177
|
+
raise
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logging.error(f"Error deleting playlist item: {e}")
|
|
180
|
+
raise HTTPException(
|
|
181
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
182
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# app/api/settings.py
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from globe_server.db.orm import Settings
|
|
7
|
+
from globe_server.db.schemas import SettingsRead, SettingsUpdate
|
|
8
|
+
from globe_server.db.database import get_singleton, update, get_all
|
|
9
|
+
from globe_server.utils import uart # Import the uart module
|
|
10
|
+
from globe_server import config
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/settings", tags=["settings"])
|
|
14
|
+
@router.get("/", response_model=SettingsRead)
|
|
15
|
+
async def get_settings():
|
|
16
|
+
# Get singleton settings record
|
|
17
|
+
settings = get_singleton(Settings)
|
|
18
|
+
if not settings:
|
|
19
|
+
# This shouldn't happen because init_db creates settings
|
|
20
|
+
# But just in case, we'll return an error
|
|
21
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
22
|
+
detail="Settings record not found in database")
|
|
23
|
+
|
|
24
|
+
return settings
|
|
25
|
+
|
|
26
|
+
@router.put("/", response_model=SettingsRead)
|
|
27
|
+
async def update_settings(settings_update: SettingsUpdate):
|
|
28
|
+
try:
|
|
29
|
+
# Get existing settings
|
|
30
|
+
db_settings = get_singleton(Settings)
|
|
31
|
+
if not db_settings:
|
|
32
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
33
|
+
detail="Settings record not found in database")
|
|
34
|
+
|
|
35
|
+
# Update with new values
|
|
36
|
+
update_data = settings_update.model_dump(exclude_unset=True)
|
|
37
|
+
for key, value in update_data.items():
|
|
38
|
+
if value is not None: # Only update non-None values
|
|
39
|
+
setattr(db_settings, key, value)
|
|
40
|
+
|
|
41
|
+
# Send brightness values to FPGA via UART
|
|
42
|
+
red_brightness = getattr(settings_update, 'red_brightness', None)
|
|
43
|
+
if red_brightness is not None:
|
|
44
|
+
red_hex = uart.convert_brightness_to_hex(red_brightness)
|
|
45
|
+
red_command = uart.create_uart_message("red", red_hex)
|
|
46
|
+
uart.send_uart_command(red_command)
|
|
47
|
+
|
|
48
|
+
green_brightness = getattr(settings_update, 'green_brightness', None)
|
|
49
|
+
if green_brightness is not None:
|
|
50
|
+
green_hex = uart.convert_brightness_to_hex(green_brightness)
|
|
51
|
+
green_command = uart.create_uart_message("green", green_hex)
|
|
52
|
+
uart.send_uart_command(green_command)
|
|
53
|
+
|
|
54
|
+
blue_brightness = getattr(settings_update, 'blue_brightness', None)
|
|
55
|
+
if blue_brightness is not None:
|
|
56
|
+
blue_hex = uart.convert_brightness_to_hex(blue_brightness)
|
|
57
|
+
blue_command = uart.create_uart_message("blue", blue_hex)
|
|
58
|
+
uart.send_uart_command(blue_command)
|
|
59
|
+
|
|
60
|
+
# Save changes using CRUD function
|
|
61
|
+
db_settings = update(db_settings)
|
|
62
|
+
|
|
63
|
+
return db_settings
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logging.error(f"Error updating settings: {e}")
|
|
66
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
67
|
+
detail=str(e))
|