Compare commits
No commits in common. "master" and "v4.1.15" have entirely different histories.
@ -69,8 +69,7 @@ class AudacityController:
|
|||||||
select_status = self._send_command("SelectAll")
|
select_status = self._send_command("SelectAll")
|
||||||
log.debug(f"{select_status=}")
|
log.debug(f"{select_status=}")
|
||||||
|
|
||||||
# Escape any double quotes in filename
|
export_cmd = f'Export2: Filename="{self.path}" NumChannels=2'
|
||||||
export_cmd = f'Export2: Filename="{self.path.replace('"', '\\"')}" NumChannels=2'
|
|
||||||
export_status = self._send_command(export_cmd)
|
export_status = self._send_command(export_cmd)
|
||||||
log.debug(f"{export_status=}")
|
log.debug(f"{export_status=}")
|
||||||
self.path = ""
|
self.path = ""
|
||||||
|
|||||||
@ -132,7 +132,7 @@ class Config(object):
|
|||||||
TRACK_TIME_FORMAT = "%H:%M:%S"
|
TRACK_TIME_FORMAT = "%H:%M:%S"
|
||||||
VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player"
|
VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player"
|
||||||
VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player"
|
VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player"
|
||||||
VLC_VOLUME_DEFAULT = 100
|
VLC_VOLUME_DEFAULT = 80
|
||||||
VLC_VOLUME_DROP3db = 70
|
VLC_VOLUME_DROP3db = 70
|
||||||
WARNING_MS_BEFORE_FADE = 5500
|
WARNING_MS_BEFORE_FADE = 5500
|
||||||
WARNING_MS_BEFORE_SILENCE = 5500
|
WARNING_MS_BEFORE_SILENCE = 5500
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
from PyQt6.QtCore import QObject, QTimer, QElapsedTimer
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from config import Config
|
|
||||||
|
|
||||||
class EventLoopJitterMonitor(QObject):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent=None,
|
|
||||||
interval_ms: int = 20,
|
|
||||||
jitter_threshold_ms: int = 100,
|
|
||||||
log_cooldown_s: float = 1.0,
|
|
||||||
):
|
|
||||||
super().__init__(parent)
|
|
||||||
self._interval = interval_ms
|
|
||||||
self._jitter_threshold = jitter_threshold_ms
|
|
||||||
self._log_cooldown_s = log_cooldown_s
|
|
||||||
|
|
||||||
self._timer = QTimer(self)
|
|
||||||
self._timer.setInterval(self._interval)
|
|
||||||
self._timer.timeout.connect(self._on_timeout)
|
|
||||||
|
|
||||||
self._elapsed = QElapsedTimer()
|
|
||||||
self._elapsed.start()
|
|
||||||
self._last = self._elapsed.elapsed()
|
|
||||||
|
|
||||||
# child logger: e.g. "musicmuster.jitter"
|
|
||||||
self._log = logging.getLogger(f"{Config.LOG_NAME}.jitter")
|
|
||||||
self._last_log_time = 0.0
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
self._timer.start()
|
|
||||||
|
|
||||||
def _on_timeout(self) -> None:
|
|
||||||
now_ms = self._elapsed.elapsed()
|
|
||||||
delta = now_ms - self._last
|
|
||||||
self._last = now_ms
|
|
||||||
|
|
||||||
if delta > (self._interval + self._jitter_threshold):
|
|
||||||
self._log_jitter(now_ms, delta)
|
|
||||||
|
|
||||||
def _log_jitter(self, now_ms: int, gap_ms: int) -> None:
|
|
||||||
now = time.monotonic()
|
|
||||||
|
|
||||||
# simple rate limit: only one log every log_cooldown_s
|
|
||||||
if now - self._last_log_time < self._log_cooldown_s:
|
|
||||||
return
|
|
||||||
self._last_log_time = now
|
|
||||||
|
|
||||||
self._log.warning(
|
|
||||||
"Event loop gap detected: t=%d ms, gap=%d ms (interval=%d ms)",
|
|
||||||
now_ms,
|
|
||||||
gap_ms,
|
|
||||||
self._interval,
|
|
||||||
)
|
|
||||||
@ -23,8 +23,8 @@ filters:
|
|||||||
# - function-name-1
|
# - function-name-1
|
||||||
# - function-name-2
|
# - function-name-2
|
||||||
musicmuster:
|
musicmuster:
|
||||||
|
- update_clocks
|
||||||
- play_next
|
- play_next
|
||||||
jittermonitor: []
|
|
||||||
|
|
||||||
handlers:
|
handlers:
|
||||||
stderr:
|
stderr:
|
||||||
|
|||||||
@ -30,7 +30,6 @@ from log import log
|
|||||||
from models import PlaylistRows
|
from models import PlaylistRows
|
||||||
from vlcmanager import VLCManager
|
from vlcmanager import VLCManager
|
||||||
|
|
||||||
|
|
||||||
# Define the VLC callback function type
|
# Define the VLC callback function type
|
||||||
# import ctypes
|
# import ctypes
|
||||||
# import platform
|
# import platform
|
||||||
@ -354,6 +353,21 @@ class _Music:
|
|||||||
self.player.set_position(position)
|
self.player.set_position(position)
|
||||||
self.start_dt = start_time
|
self.start_dt = start_time
|
||||||
|
|
||||||
|
# For as-yet unknown reasons. sometimes the volume gets
|
||||||
|
# reset to zero within 200mS or so of starting play. This
|
||||||
|
# only happened since moving to Debian 12, which uses
|
||||||
|
# Pipewire for sound (which may be irrelevant).
|
||||||
|
# It has been known for the volume to need correcting more
|
||||||
|
# than once in the first 200mS.
|
||||||
|
# Update August 2024: This no longer seems to be an issue
|
||||||
|
# for _ in range(3):
|
||||||
|
# if self.player:
|
||||||
|
# volume = self.player.audio_get_volume()
|
||||||
|
# if volume < Config.VLC_VOLUME_DEFAULT:
|
||||||
|
# self.set_volume(Config.VLC_VOLUME_DEFAULT)
|
||||||
|
# log.error(f"Reset from {volume=}")
|
||||||
|
# sleep(0.1)
|
||||||
|
|
||||||
def set_position(self, position: float) -> None:
|
def set_position(self, position: float) -> None:
|
||||||
"""
|
"""
|
||||||
Set player position
|
Set player position
|
||||||
@ -377,6 +391,17 @@ class _Music:
|
|||||||
volume = Config.VLC_VOLUME_DEFAULT
|
volume = Config.VLC_VOLUME_DEFAULT
|
||||||
|
|
||||||
self.player.audio_set_volume(volume)
|
self.player.audio_set_volume(volume)
|
||||||
|
# Ensure volume correct
|
||||||
|
# For as-yet unknown reasons. sometimes the volume gets
|
||||||
|
# reset to zero within 200mS or so of starting play. This
|
||||||
|
# only happened since moving to Debian 12, which uses
|
||||||
|
# Pipewire for sound (which may be irrelevant).
|
||||||
|
for _ in range(3):
|
||||||
|
current_volume = self.player.audio_get_volume()
|
||||||
|
if current_volume < volume:
|
||||||
|
self.player.audio_set_volume(volume)
|
||||||
|
log.debug(f"Reset from {volume=}")
|
||||||
|
sleep(0.1)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Immediately stop playing"""
|
"""Immediately stop playing"""
|
||||||
|
|||||||
@ -90,7 +90,6 @@ from ui.main_window_footer_ui import Ui_FooterSection # type: ignore
|
|||||||
from utilities import check_db, update_bitrates
|
from utilities import check_db, update_bitrates
|
||||||
import helpers
|
import helpers
|
||||||
|
|
||||||
from jittermonitor import EventLoopJitterMonitor
|
|
||||||
|
|
||||||
class Current:
|
class Current:
|
||||||
base_model: PlaylistModel
|
base_model: PlaylistModel
|
||||||
@ -1207,16 +1206,6 @@ class Window(QMainWindow):
|
|||||||
self.action_quicklog = QShortcut(QKeySequence("Ctrl+L"), self)
|
self.action_quicklog = QShortcut(QKeySequence("Ctrl+L"), self)
|
||||||
self.action_quicklog.activated.connect(self.quicklog)
|
self.action_quicklog.activated.connect(self.quicklog)
|
||||||
|
|
||||||
|
|
||||||
# Jitter monitor - log delays in main event loop
|
|
||||||
self.jitter_monitor = EventLoopJitterMonitor(
|
|
||||||
parent=self,
|
|
||||||
interval_ms=20,
|
|
||||||
jitter_threshold_ms=100, # only care about >~100 ms
|
|
||||||
log_cooldown_s=1.0, # at most 1 warning per second
|
|
||||||
)
|
|
||||||
self.jitter_monitor.start()
|
|
||||||
|
|
||||||
self.load_last_playlists()
|
self.load_last_playlists()
|
||||||
self.stop_autoplay = False
|
self.stop_autoplay = False
|
||||||
|
|
||||||
@ -2217,6 +2206,14 @@ class Window(QMainWindow):
|
|||||||
if self.return_pressed_in_error():
|
if self.return_pressed_in_error():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Issue #223 concerns a very short pause (maybe 0.1s) sometimes
|
||||||
|
# when starting to play at track. Resolution appears to be to
|
||||||
|
# disable timer10 for a short time. Timer is re-enabled in
|
||||||
|
# update_clocks.
|
||||||
|
|
||||||
|
self.timer10.stop()
|
||||||
|
log.debug("issue223: play_next: 10ms timer disabled")
|
||||||
|
|
||||||
# If there's currently a track playing, fade it.
|
# If there's currently a track playing, fade it.
|
||||||
if track_sequence.current:
|
if track_sequence.current:
|
||||||
track_sequence.current.fade()
|
track_sequence.current.fade()
|
||||||
@ -2728,7 +2725,8 @@ class Window(QMainWindow):
|
|||||||
|
|
||||||
# WARNING_MS_BEFORE_FADE milliseconds before fade starts, set
|
# WARNING_MS_BEFORE_FADE milliseconds before fade starts, set
|
||||||
# warning colour on time to silence box and enable play
|
# warning colour on time to silence box and enable play
|
||||||
# controls.
|
# controls. This is also a good time to re-enable the 10ms
|
||||||
|
# timer (see play_next() and issue #223).
|
||||||
|
|
||||||
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
|
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
|
||||||
self.footer_section.frame_fade.setStyleSheet(
|
self.footer_section.frame_fade.setStyleSheet(
|
||||||
@ -2736,6 +2734,11 @@ class Window(QMainWindow):
|
|||||||
)
|
)
|
||||||
self.catch_return_key = False
|
self.catch_return_key = False
|
||||||
self.show_status_message("Play controls: Enabled", 0)
|
self.show_status_message("Play controls: Enabled", 0)
|
||||||
|
# Re-enable 10ms timer (see above)
|
||||||
|
log.debug(f"issue287: {self.timer10.isActive()=}")
|
||||||
|
if not self.timer10.isActive():
|
||||||
|
self.timer10.start(10)
|
||||||
|
log.debug("issue223: update_clocks: 10ms timer enabled")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.footer_section.frame_silent.setStyleSheet("")
|
self.footer_section.frame_silent.setStyleSheet("")
|
||||||
|
|||||||
@ -51,7 +51,7 @@ from models import db, NoteColours, Playdates, PlaylistRows, Tracks
|
|||||||
from music_manager import RowAndTrack, track_sequence
|
from music_manager import RowAndTrack, track_sequence
|
||||||
|
|
||||||
|
|
||||||
HEADER_NOTES_COLUMN = 0
|
HEADER_NOTES_COLUMN = 1
|
||||||
scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]")
|
scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]")
|
||||||
|
|
||||||
|
|
||||||
@ -288,6 +288,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
# Update Playdates in database
|
# Update Playdates in database
|
||||||
log.debug(f"{self}: update playdates {track_id=}")
|
log.debug(f"{self}: update playdates {track_id=}")
|
||||||
Playdates(session, track_id)
|
Playdates(session, track_id)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# Mark track as played in playlist
|
# Mark track as played in playlist
|
||||||
log.debug(f"{self}: Mark track as played")
|
log.debug(f"{self}: Mark track as played")
|
||||||
@ -297,42 +298,39 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
self.refresh_row(session, plr.row_number)
|
self.refresh_row(session, plr.row_number)
|
||||||
else:
|
else:
|
||||||
log.error(
|
log.error(
|
||||||
f"{self}: Can't retrieve plr, "
|
f"{self}: Can't retrieve plr, {track_sequence.current.playlistrow_id=}"
|
||||||
f"{track_sequence.current.playlistrow_id=}"
|
|
||||||
)
|
)
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Update colour and times for current row
|
# Update colour and times for current row
|
||||||
# only invalidate required roles
|
|
||||||
roles = [
|
|
||||||
Qt.ItemDataRole.DisplayRole
|
|
||||||
]
|
|
||||||
self.invalidate_row(row_number, roles)
|
|
||||||
|
|
||||||
# Update previous row in case we're hiding played rows
|
|
||||||
if track_sequence.previous and track_sequence.previous.row_number:
|
|
||||||
# only invalidate required roles
|
# only invalidate required roles
|
||||||
self.invalidate_row(track_sequence.previous.row_number, roles)
|
roles = [
|
||||||
|
Qt.ItemDataRole.DisplayRole
|
||||||
|
]
|
||||||
|
self.invalidate_row(row_number, roles)
|
||||||
|
|
||||||
# Find next track
|
# Update previous row in case we're hiding played rows
|
||||||
next_row = None
|
if track_sequence.previous and track_sequence.previous.row_number:
|
||||||
unplayed_rows = [
|
# only invalidate required roles
|
||||||
a
|
self.invalidate_row(track_sequence.previous.row_number, roles)
|
||||||
for a in self.get_unplayed_rows()
|
|
||||||
if not self.is_header_row(a)
|
# Update all other track times
|
||||||
and not file_is_unreadable(self.playlist_rows[a].path)
|
|
||||||
]
|
|
||||||
if unplayed_rows:
|
|
||||||
try:
|
|
||||||
next_row = min([a for a in unplayed_rows if a > row_number])
|
|
||||||
except ValueError:
|
|
||||||
next_row = min(unplayed_rows)
|
|
||||||
if next_row is not None:
|
|
||||||
self.set_next_row(next_row)
|
|
||||||
else:
|
|
||||||
# set_next_row() calls update_track_times(); else we call it
|
|
||||||
self.update_track_times()
|
self.update_track_times()
|
||||||
|
|
||||||
|
# Find next track
|
||||||
|
next_row = None
|
||||||
|
unplayed_rows = [
|
||||||
|
a
|
||||||
|
for a in self.get_unplayed_rows()
|
||||||
|
if not self.is_header_row(a)
|
||||||
|
and not file_is_unreadable(self.playlist_rows[a].path)
|
||||||
|
]
|
||||||
|
if unplayed_rows:
|
||||||
|
try:
|
||||||
|
next_row = min([a for a in unplayed_rows if a > row_number])
|
||||||
|
except ValueError:
|
||||||
|
next_row = min(unplayed_rows)
|
||||||
|
if next_row is not None:
|
||||||
|
self.set_next_row(next_row)
|
||||||
|
|
||||||
def data(
|
def data(
|
||||||
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
|
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
|
||||||
@ -417,7 +415,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
if column == HEADER_NOTES_COLUMN:
|
if column == HEADER_NOTES_COLUMN:
|
||||||
column_span = 1
|
column_span = 1
|
||||||
if header_row:
|
if header_row:
|
||||||
column_span = self.columnCount() - HEADER_NOTES_COLUMN
|
column_span = self.columnCount() - 1
|
||||||
self.signals.span_cells_signal.emit(
|
self.signals.span_cells_signal.emit(
|
||||||
self.playlist_id, row, HEADER_NOTES_COLUMN, 1, column_span
|
self.playlist_id, row, HEADER_NOTES_COLUMN, 1, column_span
|
||||||
)
|
)
|
||||||
@ -534,7 +532,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
Col.ARTIST.value,
|
Col.ARTIST.value,
|
||||||
Col.NOTE.value,
|
Col.NOTE.value,
|
||||||
Col.INTRO.value,
|
Col.INTRO.value,
|
||||||
] or self.is_header_row(index.row()) and index.column() == HEADER_NOTES_COLUMN:
|
]:
|
||||||
return default | Qt.ItemFlag.ItemIsEditable
|
return default | Qt.ItemFlag.ItemIsEditable
|
||||||
|
|
||||||
return default
|
return default
|
||||||
@ -765,6 +763,8 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
Signal to view to refresh invalidated row
|
Signal to view to refresh invalidated row
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
log.debug(f"issue285: {self}: invalidate_row({modified_row=})")
|
||||||
|
|
||||||
self.dataChanged.emit(
|
self.dataChanged.emit(
|
||||||
self.index(modified_row, 0),
|
self.index(modified_row, 0),
|
||||||
self.index(modified_row, self.columnCount() - 1),
|
self.index(modified_row, self.columnCount() - 1),
|
||||||
@ -776,6 +776,8 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
Signal to view to refresh invlidated rows
|
Signal to view to refresh invlidated rows
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
log.debug(f"issue285: {self}: invalidate_rows({modified_rows=})")
|
||||||
|
|
||||||
for modified_row in modified_rows:
|
for modified_row in modified_rows:
|
||||||
# only invalidate required roles
|
# only invalidate required roles
|
||||||
self.invalidate_row(modified_row, roles)
|
self.invalidate_row(modified_row, roles)
|
||||||
@ -1197,6 +1199,8 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
looking up the playlistrow_id and retrieving the row number from the database.
|
looking up the playlistrow_id and retrieving the row number from the database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
log.debug(f"issue285: {self}: reset_track_sequence_row_numbers()")
|
||||||
|
|
||||||
# Check the track_sequence.next, current and previous plrs and
|
# Check the track_sequence.next, current and previous plrs and
|
||||||
# update the row number
|
# update the row number
|
||||||
with db.Session() as session:
|
with db.Session() as session:
|
||||||
@ -1661,6 +1665,8 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
Update track start/end times in self.playlist_rows
|
Update track start/end times in self.playlist_rows
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
log.debug(f"issue285: {self}: update_track_times()")
|
||||||
|
|
||||||
next_start_time: Optional[dt.datetime] = None
|
next_start_time: Optional[dt.datetime] = None
|
||||||
update_rows: list[int] = []
|
update_rows: list[int] = []
|
||||||
row_count = len(self.playlist_rows)
|
row_count = len(self.playlist_rows)
|
||||||
|
|||||||
@ -35,7 +35,6 @@ dependencies = [
|
|||||||
"pdbpp>=0.10.3",
|
"pdbpp>=0.10.3",
|
||||||
"filetype>=1.2.0",
|
"filetype>=1.2.0",
|
||||||
"black>=25.1.0",
|
"black>=25.1.0",
|
||||||
"slugify>=0.0.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
10
uv.lock
10
uv.lock
@ -1,5 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 2
|
||||||
requires-python = ">=3.13, <4"
|
requires-python = ">=3.13, <4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -513,7 +513,6 @@ dependencies = [
|
|||||||
{ name = "python-slugify" },
|
{ name = "python-slugify" },
|
||||||
{ name = "python-vlc" },
|
{ name = "python-vlc" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "slugify" },
|
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "stackprinter" },
|
{ name = "stackprinter" },
|
||||||
{ name = "tinytag" },
|
{ name = "tinytag" },
|
||||||
@ -561,7 +560,6 @@ requires-dist = [
|
|||||||
{ name = "python-slugify", specifier = ">=8.0.4" },
|
{ name = "python-slugify", specifier = ">=8.0.4" },
|
||||||
{ name = "python-vlc", specifier = ">=3.0.21203" },
|
{ name = "python-vlc", specifier = ">=3.0.21203" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.2,<7.0.0" },
|
{ name = "pyyaml", specifier = ">=6.0.2,<7.0.0" },
|
||||||
{ name = "slugify", specifier = ">=0.0.1" },
|
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.36" },
|
{ name = "sqlalchemy", specifier = ">=2.0.36" },
|
||||||
{ name = "stackprinter", specifier = ">=0.2.10" },
|
{ name = "stackprinter", specifier = ">=0.2.10" },
|
||||||
{ name = "tinytag", specifier = ">=1.10.1" },
|
{ name = "tinytag", specifier = ">=1.10.1" },
|
||||||
@ -1129,12 +1127,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385, upload-time = "2025-02-26T20:45:17.259Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385, upload-time = "2025-02-26T20:45:17.259Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "slugify"
|
|
||||||
version = "0.0.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/89/fbb7391d777b60c82d4e1376bb181b98e75adf506b3f7ffe837eca64570b/slugify-0.0.1.tar.gz", hash = "sha256:c5703cc11c1a6947536f3ce8bb306766b8bb5a84a53717f5a703ce0f18235e4c", size = 1156, upload-time = "2010-12-07T16:36:05.53Z" }
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.38"
|
version = "2.0.38"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user