WIP Issue 285
This commit is contained in:
parent
3b004567df
commit
2f8afeb814
@ -11,7 +11,6 @@ import re
|
|||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
QAbstractTableModel,
|
QAbstractTableModel,
|
||||||
QModelIndex,
|
QModelIndex,
|
||||||
QObject,
|
|
||||||
QRegularExpression,
|
QRegularExpression,
|
||||||
QSortFilterProxyModel,
|
QSortFilterProxyModel,
|
||||||
Qt,
|
Qt,
|
||||||
@ -25,7 +24,6 @@ from PyQt6.QtGui import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import line_profiler
|
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
import obswebsocket # type: ignore
|
import obswebsocket # type: ignore
|
||||||
|
|
||||||
@ -72,18 +70,14 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
database.
|
database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, playlist_id: int, is_template: bool,) -> None:
|
||||||
self,
|
|
||||||
playlist_id: int,
|
super().__init__()
|
||||||
is_template: bool,
|
|
||||||
*args: Optional[QObject],
|
|
||||||
**kwargs: Optional[QObject],
|
|
||||||
) -> None:
|
|
||||||
log.debug("PlaylistModel.__init__()")
|
log.debug("PlaylistModel.__init__()")
|
||||||
|
|
||||||
self.playlist_id = playlist_id
|
self.playlist_id = playlist_id
|
||||||
self.is_template = is_template
|
self.is_template = is_template
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.playlist_rows: dict[int, RowAndTrack] = {}
|
self.playlist_rows: dict[int, RowAndTrack] = {}
|
||||||
self.signals = MusicMusterSignals()
|
self.signals = MusicMusterSignals()
|
||||||
@ -101,13 +95,17 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"<PlaylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
|
f"<PlaylistModel: playlist_id={self.playlist_id}, "
|
||||||
|
f"is_template={self.is_template}, "
|
||||||
|
f"{self.rowCount()} rows>"
|
||||||
)
|
)
|
||||||
|
|
||||||
def active_section_header(self) -> int:
|
def active_section_header(self) -> int:
|
||||||
"""
|
"""
|
||||||
Return the row number of the first header that has either unplayed tracks
|
Return the row number of the first header that has any of the following below it:
|
||||||
or currently being played track below it.
|
- unplayed tracks
|
||||||
|
- the currently being played track
|
||||||
|
- the track marked as next to play
|
||||||
"""
|
"""
|
||||||
|
|
||||||
header_row = 0
|
header_row = 0
|
||||||
@ -119,23 +117,20 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
if not self.is_played_row(row_number):
|
if not self.is_played_row(row_number):
|
||||||
break
|
break
|
||||||
|
|
||||||
# If track is played, we need to check it's not the current
|
# Here means that row_number points to a played track. The
|
||||||
# next or previous track because we don't want to scroll them
|
# current track will be marked as played when we start
|
||||||
# out of view
|
# playing it. It's also possible that the track marked as
|
||||||
|
# next has already been played. Check for either of those.
|
||||||
|
|
||||||
for ts in [
|
for ts in [track_sequence.next, track_sequence.current]:
|
||||||
track_sequence.next,
|
|
||||||
track_sequence.current,
|
|
||||||
]:
|
|
||||||
if (
|
if (
|
||||||
ts
|
ts
|
||||||
and ts.row_number == row_number
|
and ts.row_number == row_number
|
||||||
and ts.playlist_id == self.playlist_id
|
and ts.playlist_id == self.playlist_id
|
||||||
):
|
):
|
||||||
break
|
# We've found the current or next track, so return
|
||||||
else:
|
# the last-found header row
|
||||||
continue # continue iterating over playlist_rows
|
return header_row
|
||||||
break # current row is in one of the track sequences
|
|
||||||
|
|
||||||
return header_row
|
return header_row
|
||||||
|
|
||||||
@ -152,31 +147,34 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
try:
|
try:
|
||||||
rat = self.playlist_rows[row_number]
|
rat = self.playlist_rows[row_number]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
log.error(
|
raise ApplicationError(
|
||||||
f"{self}: KeyError in add_track_to_header ({row_number=}, {track_id=})"
|
f"{self}: KeyError in add_track_to_header ({row_number=}, {track_id=})"
|
||||||
)
|
)
|
||||||
return
|
|
||||||
if rat.path:
|
if rat.path:
|
||||||
log.error(
|
raise ApplicationError(
|
||||||
f"{self}: Header row already has track associated ({rat=}, {track_id=})"
|
f"{self}: Header row already has track associated ({rat=}, {track_id=})"
|
||||||
)
|
)
|
||||||
return
|
|
||||||
with db.Session() as session:
|
with db.Session() as session:
|
||||||
playlistrow = session.get(PlaylistRows, rat.playlistrow_id)
|
playlistrow = session.get(PlaylistRows, rat.playlistrow_id)
|
||||||
if playlistrow:
|
if not playlistrow:
|
||||||
|
raise ApplicationError(
|
||||||
|
f"{self}: Failed to retrieve playlist row ({rat.playlistrow_id=}"
|
||||||
|
)
|
||||||
# Add track to PlaylistRows
|
# Add track to PlaylistRows
|
||||||
playlistrow.track_id = track_id
|
playlistrow.track_id = track_id
|
||||||
# Add any further note (header will already have a note)
|
# Add any further note (header will already have a note)
|
||||||
if note:
|
if note:
|
||||||
playlistrow.note += "\n" + note
|
playlistrow.note += " " + note
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# Update local copy
|
# Update local copy
|
||||||
self.refresh_row(session, row_number)
|
self.refresh_row(session, row_number)
|
||||||
# Repaint row
|
# Repaint row
|
||||||
self.invalidate_row(row_number)
|
self.invalidate_row(row_number)
|
||||||
session.commit()
|
|
||||||
|
|
||||||
self.signals.resize_rows_signal.emit(self.playlist_id)
|
self.signals.resize_rows_signal.emit(self.playlist_id)
|
||||||
|
|
||||||
|
# @line_profiler.profile
|
||||||
def background_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
|
def background_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
|
||||||
"""Return background setting"""
|
"""Return background setting"""
|
||||||
|
|
||||||
@ -257,26 +255,28 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
- update track times
|
- update track times
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
log.debug(f"{self}: current_track_started()")
|
||||||
|
|
||||||
if not track_sequence.current:
|
if not track_sequence.current:
|
||||||
return
|
return
|
||||||
|
|
||||||
row_number = track_sequence.current.row_number
|
row_number = track_sequence.current.row_number
|
||||||
|
|
||||||
# Check for OBS scene change
|
# Check for OBS scene change
|
||||||
log.debug(f"{self}: Call OBS scene change")
|
|
||||||
self.obs_scene_change(row_number)
|
self.obs_scene_change(row_number)
|
||||||
|
|
||||||
# Sanity check that we have a track_id
|
# Sanity check that we have a track_id
|
||||||
if not track_sequence.current.track_id:
|
track_id = track_sequence.current.track_id
|
||||||
log.error(
|
if not track_id:
|
||||||
f"{self}: current_track_started() called with {track_sequence.current.track_id=}"
|
raise ApplicationError(
|
||||||
|
f"{self}: current_track_started() called with {track_id=}"
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
with db.Session() as session:
|
with db.Session() as session:
|
||||||
# Update Playdates in database
|
# Update Playdates in database
|
||||||
log.debug(f"{self}: update playdates")
|
log.debug(f"{self}: update playdates {track_id=}")
|
||||||
Playdates(session, track_sequence.current.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")
|
||||||
@ -315,36 +315,16 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
if next_row is not None:
|
if next_row is not None:
|
||||||
self.set_next_row(next_row)
|
self.set_next_row(next_row)
|
||||||
|
|
||||||
session.commit()
|
# @line_profiler.profile
|
||||||
|
|
||||||
def data(
|
def data(
|
||||||
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
|
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
|
||||||
) -> QVariant:
|
) -> QVariant | QFont | QBrush | str:
|
||||||
"""Return data to view"""
|
"""Return data to view"""
|
||||||
|
|
||||||
if not index.isValid() or not (0 <= index.row() < len(self.playlist_rows)):
|
if (
|
||||||
return QVariant()
|
not index.isValid()
|
||||||
|
or not (0 <= index.row() < len(self.playlist_rows))
|
||||||
row = index.row()
|
or role in [
|
||||||
column = index.column()
|
|
||||||
# rat for playlist row data as it's used a lot
|
|
||||||
rat = self.playlist_rows[row]
|
|
||||||
|
|
||||||
# Dispatch to role-specific functions
|
|
||||||
dispatch_table = {
|
|
||||||
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
|
|
||||||
int(Qt.ItemDataRole.DisplayRole): self.display_role,
|
|
||||||
int(Qt.ItemDataRole.EditRole): self.edit_role,
|
|
||||||
int(Qt.ItemDataRole.FontRole): self.font_role,
|
|
||||||
int(Qt.ItemDataRole.ForegroundRole): self.foreground_role,
|
|
||||||
int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role,
|
|
||||||
}
|
|
||||||
|
|
||||||
if role in dispatch_table:
|
|
||||||
return QVariant(dispatch_table[role](row, column, rat))
|
|
||||||
|
|
||||||
# Document other roles but don't use them
|
|
||||||
if role in [
|
|
||||||
Qt.ItemDataRole.DecorationRole,
|
Qt.ItemDataRole.DecorationRole,
|
||||||
Qt.ItemDataRole.StatusTipRole,
|
Qt.ItemDataRole.StatusTipRole,
|
||||||
Qt.ItemDataRole.WhatsThisRole,
|
Qt.ItemDataRole.WhatsThisRole,
|
||||||
@ -352,10 +332,30 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
Qt.ItemDataRole.TextAlignmentRole,
|
Qt.ItemDataRole.TextAlignmentRole,
|
||||||
Qt.ItemDataRole.CheckStateRole,
|
Qt.ItemDataRole.CheckStateRole,
|
||||||
Qt.ItemDataRole.InitialSortOrderRole,
|
Qt.ItemDataRole.InitialSortOrderRole,
|
||||||
]:
|
]
|
||||||
|
):
|
||||||
return QVariant()
|
return QVariant()
|
||||||
|
|
||||||
# Fall through to no-op
|
row = index.row()
|
||||||
|
column = index.column()
|
||||||
|
# rat for playlist row data as it's used a lot
|
||||||
|
rat = self.playlist_rows[row]
|
||||||
|
|
||||||
|
# These are ordered in approximately the frequency with which
|
||||||
|
# they are called
|
||||||
|
if role == Qt.ItemDataRole.BackgroundRole:
|
||||||
|
return self.background_role(row, column, rat)
|
||||||
|
elif role == Qt.ItemDataRole.DisplayRole:
|
||||||
|
return self.display_role(row, column, rat)
|
||||||
|
elif role == Qt.ItemDataRole.EditRole:
|
||||||
|
return self.edit_role(row, column, rat)
|
||||||
|
elif role == Qt.ItemDataRole.FontRole:
|
||||||
|
return self.font_role(row, column, rat)
|
||||||
|
elif role == Qt.ItemDataRole.ForegroundRole:
|
||||||
|
return self.foreground_role(row, column, rat)
|
||||||
|
elif role == Qt.ItemDataRole.ToolTipRole:
|
||||||
|
return self.tooltip_role(row, column, rat)
|
||||||
|
|
||||||
return QVariant()
|
return QVariant()
|
||||||
|
|
||||||
def delete_rows(self, row_numbers: list[int]) -> None:
|
def delete_rows(self, row_numbers: list[int]) -> None:
|
||||||
@ -385,8 +385,10 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
super().endRemoveRows()
|
super().endRemoveRows()
|
||||||
|
|
||||||
self.reset_track_sequence_row_numbers()
|
self.reset_track_sequence_row_numbers()
|
||||||
|
self.update_track_times()
|
||||||
|
|
||||||
def display_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
|
# @line_profiler.profile
|
||||||
|
def display_role(self, row: int, column: int, rat: RowAndTrack) -> str:
|
||||||
"""
|
"""
|
||||||
Return text for display
|
Return text for display
|
||||||
"""
|
"""
|
||||||
@ -406,45 +408,45 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
if column == HEADER_NOTES_COLUMN:
|
if column == HEADER_NOTES_COLUMN:
|
||||||
header_text = self.header_text(rat)
|
header_text = self.header_text(rat)
|
||||||
if not header_text:
|
if not header_text:
|
||||||
return QVariant(Config.TEXT_NO_TRACK_NO_NOTE)
|
return Config.SECTION_HEADER
|
||||||
else:
|
else:
|
||||||
formatted_header = self.header_text(rat)
|
formatted_header = self.header_text(rat)
|
||||||
trimmed_header = self.remove_section_timer_markers(formatted_header)
|
trimmed_header = self.remove_section_timer_markers(formatted_header)
|
||||||
return QVariant(trimmed_header)
|
return trimmed_header
|
||||||
else:
|
else:
|
||||||
return QVariant("")
|
return ""
|
||||||
|
|
||||||
if column == Col.START_TIME.value:
|
if column == Col.START_TIME.value:
|
||||||
start_time = rat.forecast_start_time
|
start_time = rat.forecast_start_time
|
||||||
if start_time:
|
if start_time:
|
||||||
return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT))
|
return start_time.strftime(Config.TRACK_TIME_FORMAT)
|
||||||
return QVariant()
|
return ""
|
||||||
|
|
||||||
if column == Col.END_TIME.value:
|
if column == Col.END_TIME.value:
|
||||||
end_time = rat.forecast_end_time
|
end_time = rat.forecast_end_time
|
||||||
if end_time:
|
if end_time:
|
||||||
return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT))
|
return end_time.strftime(Config.TRACK_TIME_FORMAT)
|
||||||
return QVariant()
|
return ""
|
||||||
|
|
||||||
if column == Col.INTRO.value:
|
if column == Col.INTRO.value:
|
||||||
if rat.intro:
|
if rat.intro:
|
||||||
return QVariant(f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}")
|
return f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}"
|
||||||
else:
|
else:
|
||||||
return QVariant("")
|
return ""
|
||||||
|
|
||||||
dispatch_table = {
|
dispatch_table: dict[int, str] = {
|
||||||
Col.ARTIST.value: QVariant(rat.artist),
|
Col.ARTIST.value: rat.artist,
|
||||||
Col.BITRATE.value: QVariant(rat.bitrate),
|
Col.BITRATE.value: str(rat.bitrate),
|
||||||
Col.DURATION.value: QVariant(ms_to_mmss(rat.duration)),
|
Col.DURATION.value: ms_to_mmss(rat.duration),
|
||||||
Col.LAST_PLAYED.value: QVariant(get_relative_date(rat.lastplayed)),
|
Col.LAST_PLAYED.value: get_relative_date(rat.lastplayed),
|
||||||
Col.NOTE.value: QVariant(rat.note),
|
Col.NOTE.value: rat.note,
|
||||||
Col.START_GAP.value: QVariant(rat.start_gap),
|
Col.START_GAP.value: str(rat.start_gap),
|
||||||
Col.TITLE.value: QVariant(rat.title),
|
Col.TITLE.value: rat.title,
|
||||||
}
|
}
|
||||||
if column in dispatch_table:
|
if column in dispatch_table:
|
||||||
return dispatch_table[column]
|
return dispatch_table[column]
|
||||||
|
|
||||||
return QVariant()
|
return ""
|
||||||
|
|
||||||
def end_reset_model(self, playlist_id: int) -> None:
|
def end_reset_model(self, playlist_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
@ -461,7 +463,8 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
super().endResetModel()
|
super().endResetModel()
|
||||||
self.reset_track_sequence_row_numbers()
|
self.reset_track_sequence_row_numbers()
|
||||||
|
|
||||||
def edit_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
|
# @line_profiler.profile
|
||||||
|
def edit_role(self, row: int, column: int, rat: RowAndTrack) -> str:
|
||||||
"""
|
"""
|
||||||
Return text for editing
|
Return text for editing
|
||||||
"""
|
"""
|
||||||
@ -469,19 +472,20 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
# If this is a header row and we're being asked for the
|
# If this is a header row and we're being asked for the
|
||||||
# HEADER_NOTES_COLUMN, return the note value
|
# HEADER_NOTES_COLUMN, return the note value
|
||||||
if self.is_header_row(row) and column == HEADER_NOTES_COLUMN:
|
if self.is_header_row(row) and column == HEADER_NOTES_COLUMN:
|
||||||
return QVariant(rat.note)
|
return rat.note
|
||||||
|
|
||||||
if column == Col.INTRO.value:
|
if column == Col.INTRO.value:
|
||||||
return QVariant(rat.intro)
|
return str(rat.intro or "")
|
||||||
if column == Col.TITLE.value:
|
if column == Col.TITLE.value:
|
||||||
return QVariant(rat.title)
|
return rat.title
|
||||||
if column == Col.ARTIST.value:
|
if column == Col.ARTIST.value:
|
||||||
return QVariant(rat.artist)
|
return rat.artist
|
||||||
if column == Col.NOTE.value:
|
if column == Col.NOTE.value:
|
||||||
return QVariant(rat.note)
|
return rat.note
|
||||||
|
|
||||||
return QVariant()
|
return ""
|
||||||
|
|
||||||
|
# @line_profiler.profile
|
||||||
def foreground_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
|
def foreground_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
|
||||||
"""Return header foreground colour or QBrush() if none"""
|
"""Return header foreground colour or QBrush() if none"""
|
||||||
|
|
||||||
@ -518,19 +522,20 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
|
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def font_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
|
# @line_profiler.profile
|
||||||
|
def font_role(self, row: int, column: int, rat: RowAndTrack) -> QFont:
|
||||||
"""
|
"""
|
||||||
Return font
|
Return font
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Notes column is never bold
|
# Notes column is never bold
|
||||||
if column == Col.NOTE.value:
|
if column == Col.NOTE.value:
|
||||||
return QVariant()
|
return QFont()
|
||||||
|
|
||||||
boldfont = QFont()
|
boldfont = QFont()
|
||||||
boldfont.setBold(not self.playlist_rows[row].played)
|
boldfont.setBold(not self.playlist_rows[row].played)
|
||||||
|
|
||||||
return QVariant(boldfont)
|
return boldfont
|
||||||
|
|
||||||
def get_duplicate_rows(self) -> list[int]:
|
def get_duplicate_rows(self) -> list[int]:
|
||||||
"""
|
"""
|
||||||
@ -729,7 +734,8 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
self.reset_track_sequence_row_numbers()
|
self.reset_track_sequence_row_numbers()
|
||||||
self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows))))
|
self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows))))
|
||||||
|
|
||||||
@line_profiler.profile
|
# Keep this decorator for now
|
||||||
|
# @line_profiler.profile
|
||||||
def invalidate_row(self, modified_row: int) -> None:
|
def invalidate_row(self, modified_row: int) -> None:
|
||||||
"""
|
"""
|
||||||
Signal to view to refresh invalidated row
|
Signal to view to refresh invalidated row
|
||||||
@ -742,7 +748,8 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
self.index(modified_row, self.columnCount() - 1),
|
self.index(modified_row, self.columnCount() - 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
@line_profiler.profile
|
# Keep this decorator for now
|
||||||
|
# @line_profiler.profile
|
||||||
def invalidate_rows(self, modified_rows: list[int]) -> None:
|
def invalidate_rows(self, modified_rows: list[int]) -> None:
|
||||||
"""
|
"""
|
||||||
Signal to view to refresh invlidated rows
|
Signal to view to refresh invlidated rows
|
||||||
@ -1558,19 +1565,20 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
def supportedDropActions(self) -> Qt.DropAction:
|
def supportedDropActions(self) -> Qt.DropAction:
|
||||||
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
|
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
|
||||||
|
|
||||||
def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
|
# @line_profiler.profile
|
||||||
|
def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str:
|
||||||
"""
|
"""
|
||||||
Return tooltip. Currently only used for last_played column.
|
Return tooltip. Currently only used for last_played column.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if column != Col.LAST_PLAYED.value:
|
if column != Col.LAST_PLAYED.value:
|
||||||
return QVariant()
|
return ""
|
||||||
with db.Session() as session:
|
with db.Session() as session:
|
||||||
track_id = self.playlist_rows[row].track_id
|
track_id = self.playlist_rows[row].track_id
|
||||||
if not track_id:
|
if not track_id:
|
||||||
return QVariant()
|
return ""
|
||||||
playdates = Playdates.last_playdates(session, track_id)
|
playdates = Playdates.last_playdates(session, track_id)
|
||||||
return QVariant(
|
return (
|
||||||
"<br>".join(
|
"<br>".join(
|
||||||
[
|
[
|
||||||
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
|
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user