forked from rc/aircox
code quality
This commit is contained in:
@ -1,9 +1,9 @@
|
||||
import calendar
|
||||
from collections import OrderedDict
|
||||
from enum import IntEnum
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from collections import OrderedDict
|
||||
from enum import IntEnum
|
||||
|
||||
import pytz
|
||||
from django.conf import settings as conf
|
||||
@ -12,19 +12,26 @@ from django.db import models
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Concat, Substr
|
||||
from django.utils import timezone as tz
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from aircox import settings, utils
|
||||
|
||||
from .page import Page, PageQuerySet
|
||||
from .station import Station
|
||||
|
||||
|
||||
logger = logging.getLogger('aircox')
|
||||
logger = logging.getLogger("aircox")
|
||||
|
||||
|
||||
__all__ = ('Program', 'ProgramQuerySet', 'Stream', 'Schedule',
|
||||
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet')
|
||||
__all__ = (
|
||||
"Program",
|
||||
"ProgramQuerySet",
|
||||
"Stream",
|
||||
"Schedule",
|
||||
"ProgramChildQuerySet",
|
||||
"BaseRerun",
|
||||
"BaseRerunQuerySet",
|
||||
)
|
||||
|
||||
|
||||
class ProgramQuerySet(PageQuerySet):
|
||||
@ -37,8 +44,7 @@ class ProgramQuerySet(PageQuerySet):
|
||||
|
||||
|
||||
class Program(Page):
|
||||
"""
|
||||
A Program can either be a Streamed or a Scheduled program.
|
||||
"""A Program can either be a Streamed or a Scheduled program.
|
||||
|
||||
A Streamed program is used to generate non-stop random playlists when there
|
||||
is not scheduled diffusion. In such a case, a Stream is used to describe
|
||||
@ -49,32 +55,35 @@ class Program(Page):
|
||||
Renaming a Program rename the corresponding directory to matches the new
|
||||
name if it does not exists.
|
||||
"""
|
||||
|
||||
# explicit foreign key in order to avoid related name clashes
|
||||
station = models.ForeignKey(Station, models.CASCADE,
|
||||
verbose_name=_('station'))
|
||||
station = models.ForeignKey(
|
||||
Station, models.CASCADE, verbose_name=_("station")
|
||||
)
|
||||
active = models.BooleanField(
|
||||
_('active'),
|
||||
_("active"),
|
||||
default=True,
|
||||
help_text=_('if not checked this program is no longer active')
|
||||
help_text=_("if not checked this program is no longer active"),
|
||||
)
|
||||
sync = models.BooleanField(
|
||||
_('syncronise'),
|
||||
_("syncronise"),
|
||||
default=True,
|
||||
help_text=_('update later diffusions according to schedule changes')
|
||||
help_text=_("update later diffusions according to schedule changes"),
|
||||
)
|
||||
|
||||
objects = ProgramQuerySet.as_manager()
|
||||
detail_url_name = 'program-detail'
|
||||
detail_url_name = "program-detail"
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
""" Return program's directory path """
|
||||
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
|
||||
self.slug.replace('-', '_'))
|
||||
"""Return program's directory path."""
|
||||
return os.path.join(
|
||||
settings.AIRCOX_PROGRAMS_DIR, self.slug.replace("-", "_")
|
||||
)
|
||||
|
||||
@property
|
||||
def abspath(self):
|
||||
""" Return absolute path to program's dir """
|
||||
"""Return absolute path to program's dir."""
|
||||
return os.path.join(conf.MEDIA_ROOT, self.path)
|
||||
|
||||
@property
|
||||
@ -93,69 +102,88 @@ class Program(Page):
|
||||
|
||||
@classmethod
|
||||
def get_from_path(cl, path):
|
||||
"""
|
||||
Return a Program from the given path. We assume the path has been
|
||||
given in a previous time by this model (Program.path getter).
|
||||
"""Return a Program from the given path.
|
||||
|
||||
We assume the path has been given in a previous time by this
|
||||
model (Program.path getter).
|
||||
"""
|
||||
if path.startswith(settings.AIRCOX_PROGRAMS_DIR_ABS):
|
||||
path = path.replace(settings.AIRCOX_PROGRAMS_DIR_ABS, '')
|
||||
while path[0] == '/':
|
||||
path = path.replace(settings.AIRCOX_PROGRAMS_DIR_ABS, "")
|
||||
while path[0] == "/":
|
||||
path = path[1:]
|
||||
path = path[:path.index('/')]
|
||||
return cl.objects.filter(slug=path.replace('_','-')).first()
|
||||
path = path[: path.index("/")]
|
||||
return cl.objects.filter(slug=path.replace("_", "-")).first()
|
||||
|
||||
def ensure_dir(self, subdir=None):
|
||||
"""Make sur the program's dir exists (and optionally subdir).
|
||||
|
||||
Return True if the dir (or subdir) exists.
|
||||
"""
|
||||
Make sur the program's dir exists (and optionally subdir). Return True
|
||||
if the dir (or subdir) exists.
|
||||
"""
|
||||
path = os.path.join(self.abspath, subdir) if subdir else \
|
||||
self.abspath
|
||||
path = os.path.join(self.abspath, subdir) if subdir else self.abspath
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return os.path.exists(path)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Program')
|
||||
verbose_name_plural = _('Programs')
|
||||
verbose_name = _("Program")
|
||||
verbose_name_plural = _("Programs")
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *kargs, **kwargs):
|
||||
from .sound import Sound
|
||||
|
||||
super().save(*kargs, **kwargs)
|
||||
|
||||
# TODO: move in signals
|
||||
path_ = getattr(self, '__initial_path', None)
|
||||
path_ = getattr(self, "__initial_path", None)
|
||||
abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_)
|
||||
if path_ is not None and path_ != self.path and \
|
||||
os.path.exists(abspath) and not os.path.exists(self.abspath):
|
||||
logger.info('program #%s\'s dir changed to %s - update it.',
|
||||
self.id, self.title)
|
||||
if (
|
||||
path_ is not None
|
||||
and path_ != self.path
|
||||
and os.path.exists(abspath)
|
||||
and not os.path.exists(self.abspath)
|
||||
):
|
||||
logger.info(
|
||||
"program #%s's dir changed to %s - update it.",
|
||||
self.id,
|
||||
self.title,
|
||||
)
|
||||
|
||||
shutil.move(abspath, self.abspath)
|
||||
Sound.objects.filter(path__startswith=path_) \
|
||||
.update(file=Concat('file', Substr(F('file'), len(path_))))
|
||||
Sound.objects.filter(path__startswith=path_).update(
|
||||
file=Concat("file", Substr(F("file"), len(path_)))
|
||||
)
|
||||
|
||||
|
||||
class ProgramChildQuerySet(PageQuerySet):
|
||||
def station(self, station=None, id=None):
|
||||
return self.filter(parent__program__station=station) if id is None else \
|
||||
self.filter(parent__program__station__id=id)
|
||||
return (
|
||||
self.filter(parent__program__station=station)
|
||||
if id is None
|
||||
else self.filter(parent__program__station__id=id)
|
||||
)
|
||||
|
||||
def program(self, program=None, id=None):
|
||||
return self.parent(program, id)
|
||||
|
||||
|
||||
class BaseRerunQuerySet(models.QuerySet):
|
||||
""" Queryset for BaseRerun (sub)classes. """
|
||||
"""Queryset for BaseRerun (sub)classes."""
|
||||
|
||||
def station(self, station=None, id=None):
|
||||
return self.filter(program__station=station) if id is None else \
|
||||
self.filter(program__station__id=id)
|
||||
return (
|
||||
self.filter(program__station=station)
|
||||
if id is None
|
||||
else self.filter(program__station__id=id)
|
||||
)
|
||||
|
||||
def program(self, program=None, id=None):
|
||||
return self.filter(program=program) if id is None else \
|
||||
self.filter(program__id=id)
|
||||
return (
|
||||
self.filter(program=program)
|
||||
if id is None
|
||||
else self.filter(program__id=id)
|
||||
)
|
||||
|
||||
def rerun(self):
|
||||
return self.filter(initial__isnull=False)
|
||||
@ -165,19 +193,27 @@ class BaseRerunQuerySet(models.QuerySet):
|
||||
|
||||
|
||||
class BaseRerun(models.Model):
|
||||
"""Abstract model offering rerun facilities.
|
||||
|
||||
Assume `start` is a datetime field or attribute implemented by
|
||||
subclass.
|
||||
"""
|
||||
Abstract model offering rerun facilities. Assume `start` is a
|
||||
datetime field or attribute implemented by subclass.
|
||||
"""
|
||||
|
||||
program = models.ForeignKey(
|
||||
Program, models.CASCADE, db_index=True,
|
||||
verbose_name=_('related program'),
|
||||
Program,
|
||||
models.CASCADE,
|
||||
db_index=True,
|
||||
verbose_name=_("related program"),
|
||||
)
|
||||
initial = models.ForeignKey(
|
||||
'self', models.SET_NULL, related_name='rerun_set',
|
||||
verbose_name=_('rerun of'),
|
||||
limit_choices_to={'initial__isnull': True},
|
||||
blank=True, null=True, db_index=True,
|
||||
"self",
|
||||
models.SET_NULL,
|
||||
related_name="rerun_set",
|
||||
verbose_name=_("rerun of"),
|
||||
limit_choices_to={"initial__isnull": True},
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
)
|
||||
|
||||
objects = BaseRerunQuerySet.as_manager()
|
||||
@ -212,25 +248,27 @@ class BaseRerun(models.Model):
|
||||
return self.initial is not None
|
||||
|
||||
def get_initial(self):
|
||||
""" Return the initial schedule (self or initial) """
|
||||
"""Return the initial schedule (self or initial)"""
|
||||
return self if self.initial is None else self.initial.get_initial()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.initial is not None and self.initial.start >= self.start:
|
||||
raise ValidationError({
|
||||
'initial': _('rerun must happen after original')
|
||||
})
|
||||
raise ValidationError(
|
||||
{"initial": _("rerun must happen after original")}
|
||||
)
|
||||
|
||||
|
||||
# ? BIG FIXME: self.date is still used as datetime
|
||||
class Schedule(BaseRerun):
|
||||
"""A Schedule defines time slots of programs' diffusions.
|
||||
|
||||
It can be an initial run or a rerun (in such case it is linked to
|
||||
the related schedule).
|
||||
"""
|
||||
A Schedule defines time slots of programs' diffusions. It can be an initial
|
||||
run or a rerun (in such case it is linked to the related schedule).
|
||||
"""
|
||||
# Frequency for schedules. Basically, it is a mask of bits where each bit is
|
||||
# a week. Bits > rank 5 are used for special schedules.
|
||||
|
||||
# Frequency for schedules. Basically, it is a mask of bits where each bit
|
||||
# is a week. Bits > rank 5 are used for special schedules.
|
||||
# Important: the first week is always the first week where the weekday of
|
||||
# the schedule is present.
|
||||
# For ponctual programs, there is no need for a schedule, only a diffusion
|
||||
@ -247,45 +285,55 @@ class Schedule(BaseRerun):
|
||||
one_on_two = 0b100000
|
||||
|
||||
date = models.DateField(
|
||||
_('date'), help_text=_('date of the first diffusion'),
|
||||
_("date"),
|
||||
help_text=_("date of the first diffusion"),
|
||||
)
|
||||
time = models.TimeField(
|
||||
_('time'), help_text=_('start time'),
|
||||
_("time"),
|
||||
help_text=_("start time"),
|
||||
)
|
||||
timezone = models.CharField(
|
||||
_('timezone'),
|
||||
default=tz.get_current_timezone, max_length=100,
|
||||
_("timezone"),
|
||||
default=tz.get_current_timezone,
|
||||
max_length=100,
|
||||
choices=[(x, x) for x in pytz.all_timezones],
|
||||
help_text=_('timezone used for the date')
|
||||
help_text=_("timezone used for the date"),
|
||||
)
|
||||
duration = models.TimeField(
|
||||
_('duration'),
|
||||
help_text=_('regular duration'),
|
||||
_("duration"),
|
||||
help_text=_("regular duration"),
|
||||
)
|
||||
frequency = models.SmallIntegerField(
|
||||
_('frequency'),
|
||||
choices=[(int(y), {
|
||||
'ponctual': _('ponctual'),
|
||||
'first': _('1st {day} of the month'),
|
||||
'second': _('2nd {day} of the month'),
|
||||
'third': _('3rd {day} of the month'),
|
||||
'fourth': _('4th {day} of the month'),
|
||||
'last': _('last {day} of the month'),
|
||||
'first_and_third': _('1st and 3rd {day} of the month'),
|
||||
'second_and_fourth': _('2nd and 4th {day} of the month'),
|
||||
'every': _('{day}'),
|
||||
'one_on_two': _('one {day} on two'),
|
||||
}[x]) for x, y in Frequency.__members__.items()],
|
||||
_("frequency"),
|
||||
choices=[
|
||||
(
|
||||
int(y),
|
||||
{
|
||||
"ponctual": _("ponctual"),
|
||||
"first": _("1st {day} of the month"),
|
||||
"second": _("2nd {day} of the month"),
|
||||
"third": _("3rd {day} of the month"),
|
||||
"fourth": _("4th {day} of the month"),
|
||||
"last": _("last {day} of the month"),
|
||||
"first_and_third": _("1st and 3rd {day} of the month"),
|
||||
"second_and_fourth": _("2nd and 4th {day} of the month"),
|
||||
"every": _("{day}"),
|
||||
"one_on_two": _("one {day} on two"),
|
||||
}[x],
|
||||
)
|
||||
for x, y in Frequency.__members__.items()
|
||||
],
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Schedule')
|
||||
verbose_name_plural = _('Schedules')
|
||||
verbose_name = _("Schedule")
|
||||
verbose_name_plural = _("Schedules")
|
||||
|
||||
def __str__(self):
|
||||
return '{} - {}, {}'.format(
|
||||
self.program.title, self.get_frequency_verbose(),
|
||||
self.time.strftime('%H:%M')
|
||||
return "{} - {}, {}".format(
|
||||
self.program.title,
|
||||
self.get_frequency_verbose(),
|
||||
self.time.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
def save_rerun(self, *args, **kwargs):
|
||||
@ -295,31 +343,35 @@ class Schedule(BaseRerun):
|
||||
|
||||
@cached_property
|
||||
def tz(self):
|
||||
""" Pytz timezone of the schedule. """
|
||||
"""Pytz timezone of the schedule."""
|
||||
import pytz
|
||||
|
||||
return pytz.timezone(self.timezone)
|
||||
|
||||
@cached_property
|
||||
def start(self):
|
||||
""" Datetime of the start (timezone unaware) """
|
||||
"""Datetime of the start (timezone unaware)"""
|
||||
return tz.datetime.combine(self.date, self.time)
|
||||
|
||||
@cached_property
|
||||
def end(self):
|
||||
""" Datetime of the end """
|
||||
"""Datetime of the end."""
|
||||
return self.start + utils.to_timedelta(self.duration)
|
||||
|
||||
def get_frequency_verbose(self):
|
||||
""" Return frequency formated for display """
|
||||
"""Return frequency formated for display."""
|
||||
from django.template.defaultfilters import date
|
||||
return self.get_frequency_display().format(
|
||||
day=date(self.date, 'l')
|
||||
).capitalize()
|
||||
|
||||
return (
|
||||
self.get_frequency_display()
|
||||
.format(day=date(self.date, "l"))
|
||||
.capitalize()
|
||||
)
|
||||
|
||||
# initial cached data
|
||||
__initial = None
|
||||
|
||||
def changed(self, fields=['date', 'duration', 'frequency', 'timezone']):
|
||||
def changed(self, fields=["date", "duration", "frequency", "timezone"]):
|
||||
initial = self._Schedule__initial
|
||||
|
||||
if not initial:
|
||||
@ -334,15 +386,13 @@ class Schedule(BaseRerun):
|
||||
return False
|
||||
|
||||
def normalize(self, date):
|
||||
"""
|
||||
Return a datetime set to schedule's time for the provided date,
|
||||
handling timezone (based on schedule's timezone).
|
||||
"""
|
||||
"""Return a datetime set to schedule's time for the provided date,
|
||||
handling timezone (based on schedule's timezone)."""
|
||||
date = tz.datetime.combine(date, self.time)
|
||||
return self.tz.normalize(self.tz.localize(date))
|
||||
|
||||
def dates_of_month(self, date):
|
||||
""" Return normalized diffusion dates of provided date's month. """
|
||||
"""Return normalized diffusion dates of provided date's month."""
|
||||
if self.frequency == Schedule.Frequency.ponctual:
|
||||
return []
|
||||
|
||||
@ -352,7 +402,8 @@ class Schedule(BaseRerun):
|
||||
# last of the month
|
||||
if freq == Schedule.Frequency.last:
|
||||
date = date.replace(
|
||||
day=calendar.monthrange(date.year, date.month)[1])
|
||||
day=calendar.monthrange(date.year, date.month)[1]
|
||||
)
|
||||
date_wday = date.weekday()
|
||||
|
||||
# end of month before the wanted weekday: move one week back
|
||||
@ -361,56 +412,72 @@ class Schedule(BaseRerun):
|
||||
date += tz.timedelta(days=sched_wday - date_wday)
|
||||
return [self.normalize(date)]
|
||||
|
||||
# move to the first day of the month that matches the schedule's weekday
|
||||
# check on SO#3284452 for the formula
|
||||
# move to the first day of the month that matches the schedule's
|
||||
# weekday. Check on SO#3284452 for the formula
|
||||
date_wday, month = date.weekday(), date.month
|
||||
date += tz.timedelta(days=(7 if date_wday > sched_wday else 0) -
|
||||
date_wday + sched_wday)
|
||||
date += tz.timedelta(
|
||||
days=(7 if date_wday > sched_wday else 0) - date_wday + sched_wday
|
||||
)
|
||||
|
||||
if freq == Schedule.Frequency.one_on_two:
|
||||
# - adjust date with modulo 14 (= 2 weeks in days)
|
||||
# - there are max 3 "weeks on two" per month
|
||||
if (date - self.date).days % 14:
|
||||
date += tz.timedelta(days=7)
|
||||
dates = (date + tz.timedelta(days=14*i) for i in range(0, 3))
|
||||
dates = (date + tz.timedelta(days=14 * i) for i in range(0, 3))
|
||||
else:
|
||||
dates = (date + tz.timedelta(days=7*week) for week in range(0, 5)
|
||||
if freq & (0b1 << week))
|
||||
dates = (
|
||||
date + tz.timedelta(days=7 * week)
|
||||
for week in range(0, 5)
|
||||
if freq & (0b1 << week)
|
||||
)
|
||||
|
||||
return [self.normalize(date) for date in dates if date.month == month]
|
||||
|
||||
|
||||
def _exclude_existing_date(self, dates):
|
||||
from .episode import Diffusion
|
||||
saved = set(Diffusion.objects.filter(start__in=dates)
|
||||
.values_list('start', flat=True))
|
||||
|
||||
saved = set(
|
||||
Diffusion.objects.filter(start__in=dates).values_list(
|
||||
"start", flat=True
|
||||
)
|
||||
)
|
||||
return [date for date in dates if date not in saved]
|
||||
|
||||
|
||||
def diffusions_of_month(self, date):
|
||||
"""
|
||||
Get episodes and diffusions for month of provided date, including
|
||||
"""Get episodes and diffusions for month of provided date, including
|
||||
reruns.
|
||||
|
||||
:returns: tuple([Episode], [Diffusion])
|
||||
"""
|
||||
from .episode import Diffusion, Episode
|
||||
if self.initial is not None or \
|
||||
self.frequency == Schedule.Frequency.ponctual:
|
||||
|
||||
if (
|
||||
self.initial is not None
|
||||
or self.frequency == Schedule.Frequency.ponctual
|
||||
):
|
||||
return [], []
|
||||
|
||||
# dates for self and reruns as (date, initial)
|
||||
reruns = [(rerun, rerun.date - self.date)
|
||||
for rerun in self.rerun_set.all()]
|
||||
reruns = [
|
||||
(rerun, rerun.date - self.date) for rerun in self.rerun_set.all()
|
||||
]
|
||||
|
||||
dates = OrderedDict((date, None) for date in self.dates_of_month(date))
|
||||
dates.update([(rerun.normalize(date.date() + delta), date)
|
||||
for date in dates.keys() for rerun, delta in reruns])
|
||||
dates.update(
|
||||
[
|
||||
(rerun.normalize(date.date() + delta), date)
|
||||
for date in dates.keys()
|
||||
for rerun, delta in reruns
|
||||
]
|
||||
)
|
||||
|
||||
# remove dates corresponding to existing diffusions
|
||||
saved = set(Diffusion.objects.filter(start__in=dates.keys(),
|
||||
program=self.program,
|
||||
schedule=self)
|
||||
.values_list('start', flat=True))
|
||||
saved = set(
|
||||
Diffusion.objects.filter(
|
||||
start__in=dates.keys(), program=self.program, schedule=self
|
||||
).values_list("start", flat=True)
|
||||
)
|
||||
|
||||
# make diffs
|
||||
duration = utils.to_timedelta(self.duration)
|
||||
@ -430,8 +497,12 @@ class Schedule(BaseRerun):
|
||||
initial = diffusions[initial]
|
||||
|
||||
diffusions[date] = Diffusion(
|
||||
episode=episode, schedule=self, type=Diffusion.TYPE_ON_AIR,
|
||||
initial=initial, start=date, end=date+duration
|
||||
episode=episode,
|
||||
schedule=self,
|
||||
type=Diffusion.TYPE_ON_AIR,
|
||||
initial=initial,
|
||||
start=date,
|
||||
end=date + duration,
|
||||
)
|
||||
return episodes.values(), diffusions.values()
|
||||
|
||||
@ -440,36 +511,38 @@ class Schedule(BaseRerun):
|
||||
|
||||
# TODO/FIXME: use validators?
|
||||
if self.initial is not None and self.date > self.date:
|
||||
raise ValueError('initial must be later')
|
||||
raise ValueError("initial must be later")
|
||||
|
||||
|
||||
class Stream(models.Model):
|
||||
"""
|
||||
When there are no program scheduled, it is possible to play sounds
|
||||
in order to avoid blanks. A Stream is a Program that plays this role,
|
||||
and whose linked to a Stream.
|
||||
"""When there are no program scheduled, it is possible to play sounds in
|
||||
order to avoid blanks. A Stream is a Program that plays this role, and
|
||||
whose linked to a Stream.
|
||||
|
||||
All sounds that are marked as good and that are under the related
|
||||
program's archive dir are elligible for the sound's selection.
|
||||
"""
|
||||
|
||||
program = models.ForeignKey(
|
||||
Program, models.CASCADE,
|
||||
verbose_name=_('related program'),
|
||||
Program,
|
||||
models.CASCADE,
|
||||
verbose_name=_("related program"),
|
||||
)
|
||||
delay = models.TimeField(
|
||||
_('delay'), blank=True, null=True,
|
||||
help_text=_('minimal delay between two sound plays')
|
||||
_("delay"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("minimal delay between two sound plays"),
|
||||
)
|
||||
begin = models.TimeField(
|
||||
_('begin'), blank=True, null=True,
|
||||
help_text=_('used to define a time range this stream is '
|
||||
'played')
|
||||
_("begin"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("used to define a time range this stream is " "played"),
|
||||
)
|
||||
end = models.TimeField(
|
||||
_('end'),
|
||||
blank=True, null=True,
|
||||
help_text=_('used to define a time range this stream is '
|
||||
'played')
|
||||
_("end"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("used to define a time range this stream is " "played"),
|
||||
)
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user