mirror of
https://github.com/nwithan8/plex-prerolls
synced 2024-08-30 16:52:17 +00:00
- Rewrite nearly the whole thing for better parsing, support, readability, etc. (OOP forever!)
- Consolidate Plex API config with schedules, now in config.yaml - Changes to schema for schedules - No more priority of, e.g. date_range over monthly - No more default - `misc` reworked to `always` - Update Docker files - Update README - Delete old files, linting stuff (screw mypy)
This commit is contained in:
parent
7a8f97bb96
commit
1616d91ebe
@ -1,5 +0,0 @@
|
||||
[MASTER]
|
||||
disable=
|
||||
C0114, # missing-module-docstring
|
||||
C0103, # scake_case
|
||||
W0621, # redefine variable from outer scope
|
15
Dockerfile
15
Dockerfile
@ -12,19 +12,8 @@ COPY requirements.txt requirements.txt
|
||||
# Install Python requirements
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Make Docker /config volume for optional config file
|
||||
VOLUME /config
|
||||
|
||||
# Copy logging.conf file from build machine to Docker /config folder
|
||||
COPY logging.conf /config/
|
||||
|
||||
# Copy example config file from build machine to Docker /config folder
|
||||
# Also copies any existing config.ini file from build machine to Docker /config folder, (will cause the bot to use the existing config file if it exists)
|
||||
COPY config.ini* /config/
|
||||
|
||||
# Copy example schedule file from build machine to Docker /config folder
|
||||
# Also copies any existing schedules.yaml file from build machine to Docker /config folder, (will cause the bot to use the existing schedule file if it exists)
|
||||
COPY schedules.yaml* /config/
|
||||
# Copy config file from build machine to Docker /config folder
|
||||
COPY config.yaml /
|
||||
|
||||
# Make Docker /logs volume for log file
|
||||
VOLUME /logs
|
||||
|
95
README.md
95
README.md
@ -32,43 +32,35 @@ Install Python requirements:
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Copy `config.ini.sample` to `config.ini` and complete the `[auth]` section with your Plex server information.
|
||||
|
||||
Copy `schedules.yaml.sample` to `schedules.yaml` and [edit your schedule](#schedule-rules).
|
||||
Copy `config.yaml.example` to `config.yaml`, provide your `plex` details and [edit your schedule](#schedule-rules).
|
||||
|
||||
Run the script:
|
||||
|
||||
```sh
|
||||
python schedule_preroll.py
|
||||
python run.py
|
||||
```
|
||||
|
||||
#### Advanced Usage
|
||||
|
||||
```sh
|
||||
$ python schedule_preroll.py -h
|
||||
$ python run.py -h
|
||||
|
||||
usage: schedule_preroll.py [-h] [-v] [-l LOG_CONFIG_FILE] [-c CONFIG_FILE] [-s SCHEDULE_FILE]
|
||||
usage: run.py [-h] [-c CONFIG] [-l LOG] [-d]
|
||||
|
||||
Automate scheduling of pre-roll intros for Plex
|
||||
Plex Prerolls - A tool to manage prerolls for Plex
|
||||
|
||||
optional arguments:
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
-v, --version show the version number and exit
|
||||
-lc LOG_CONFIG_FILE, --logconfig-path LOG_CONFIG_FILE
|
||||
Path to logging config file. [Default: ./logging.conf]
|
||||
-c CONFIG_FILE, --config-path CONFIG_FILE
|
||||
Path to Config.ini to use for Plex Server info. [Default: ./config.ini]
|
||||
-s SCHEDULE_FILE, --schedule-path SCHEDULE_FILE
|
||||
Path to pre-roll schedule file (YAML) to be use. [Default: ./schedules.yaml]
|
||||
-c CONFIG, --config CONFIG
|
||||
Path to config file. Defaults to 'config.yaml'
|
||||
-l LOG, --log LOG Log file directory. Defaults to 'logs/'
|
||||
-d, --dry-run Dry run, no real changes made
|
||||
```
|
||||
|
||||
##### Example
|
||||
|
||||
```sh
|
||||
python schedule_preroll.py \
|
||||
-c path/to/custom/config.ini \
|
||||
-s path/to/custom/schedules.yaml \
|
||||
-lc path/to/custom/logger.conf
|
||||
python run.py -c path/to/custom/config.yaml -l path/to/custom/log/directory/ # Trailing slash required
|
||||
```
|
||||
|
||||
### Run as Docker Container
|
||||
@ -94,7 +86,7 @@ docker run -d \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
-e CRON_SCHEDULE="0 0 * * *" \
|
||||
-v /path/to/config:/config \
|
||||
-v /path/to/config:/ \
|
||||
-v /path/to/logs:/logs \
|
||||
--restart unless-stopped \
|
||||
nwithan8/plex_prerolls:latest
|
||||
@ -102,10 +94,10 @@ docker run -d \
|
||||
|
||||
#### Paths and Environment Variables
|
||||
|
||||
| Path | Description |
|
||||
|-----------|--------------------------------------------------------------------------------------|
|
||||
| `/config` | Path to config files (`config.ini` and `schedules.yaml` should be in this directory) |
|
||||
| `/logs` | Path to log files (`schedule_preroll.log` will be in this directory) |
|
||||
| Path | Description |
|
||||
|---------|-------------------------------------------------------------------|
|
||||
| `/` | Path to config files (`config.yaml` should be in this directory) |
|
||||
| `/logs` | Path to log files (`Plex Prerolls.log` will be in this directory) |
|
||||
|
||||
| Environment Variable | Description |
|
||||
|----------------------|-------------------------------------------------------------------|
|
||||
@ -118,27 +110,28 @@ docker run -d \
|
||||
|
||||
## Schedule Rules
|
||||
|
||||
Schedules follow the following priority:
|
||||
1. **misc**: Items listed in `always_use` will always be included (appended) to the preroll list
|
||||
- If you have a large set of prerolls, you can provide all paths and use `random_count` to randomly select a smaller subset of the list to use on each run.
|
||||
Any entry whose schedule falls within the current date/time at the time of execution will be added to the preroll.
|
||||
|
||||
2. **date_range**: Schedule based on a specific date/time range
|
||||
You can define as many schedules as you want, in the following categories (order does not matter):
|
||||
|
||||
1. **always**: Items listed here will always be included (appended) to the preroll list
|
||||
- If you have a large set of prerolls, you can provide all paths and use `random_count` to randomly select a smaller
|
||||
subset of the list to use on each run.
|
||||
|
||||
2. **date_range**: Schedule based on a specific date/time range (including [wildcards](#date-range-section-scheduling))
|
||||
|
||||
3. **weekly**: Schedule based on a specific week of the year
|
||||
|
||||
4. **monthly**: Schedule based on a specific month of the year
|
||||
|
||||
5. **default**: Default item to use if none of the above apply
|
||||
|
||||
For any conflicting schedules, the script tries to find the closest matching range and highest priority.
|
||||
|
||||
### Advanced Scheduling
|
||||
|
||||
#### Date Range Section Scheduling
|
||||
|
||||
`date_range` entries can accept both dates (`yyyy-mm-dd`) and datetimes (`yyyy-mm-dd hh:mm:ss`, 24-hour time).
|
||||
|
||||
`date_range` entries can also accept wildcards for any of the date/time fields. This can be useful for scheduling recurring events, such as annual events, "first-of-the-month" events, or even hourly events.
|
||||
`date_range` entries can also accept wildcards for any of the date/time fields. This can be useful for scheduling
|
||||
recurring events, such as annual events, "first-of-the-month" events, or even hourly events.
|
||||
|
||||
```yaml
|
||||
date_range:
|
||||
@ -147,31 +140,46 @@ date_range:
|
||||
# Each entry requires start_date, end_date, path values
|
||||
- start_date: 2020-01-01 # Jan 1st, 2020
|
||||
end_date: 2020-01-02 # Jan 2nd, 2020
|
||||
path: /path/to/video.mp4
|
||||
paths:
|
||||
- /path/to/video.mp4
|
||||
- /path/to/another/video.mp4
|
||||
weight: 2 # Add these paths to the list twice (make up greater percentage of prerolls - more likely to be selected)
|
||||
- start_date: xxxx-07-04 # Every year on July 4th
|
||||
end_date: xxxx-07-04 # Every year on July 4th
|
||||
path: /path/to/video.mp4
|
||||
paths:
|
||||
- /path/to/video.mp4
|
||||
- /path/to/another/video.mp4
|
||||
weight: 1
|
||||
- start_date: xxxx-xx-02 # Every year on the 2nd of every month
|
||||
- name: "My Schedule" # Optional name for logging purposes
|
||||
start_date: xxxx-xx-02 # Every year on the 2nd of every month
|
||||
end_date: xxxx-xx-03 # Every year on the 3rd of every month
|
||||
path: /path/to/video.mp4
|
||||
paths:
|
||||
- /path/to/video.mp4
|
||||
- /path/to/another/video.mp4
|
||||
weight: 1
|
||||
- start_date: xxxx-xx-xx 08:00:00 # Every day at 8am
|
||||
end_date: xxxx-xx-xx 09:30:00 # Every day at 9:30am
|
||||
path: /path/to/holiday_video.mp4
|
||||
paths:
|
||||
- /path/to/video.mp4
|
||||
- /path/to/another/video.mp4
|
||||
weight: 1
|
||||
```
|
||||
|
||||
You should [adjust your cron schedule](#scheduling-script) to run the script more frequently if you use this feature.
|
||||
|
||||
`date_range` entries also accept an optional `weight` value that can be used to adjust the emphasis of this entry over others by adding the listed paths multiple times. Since Plex selects a random preroll from the list of paths, having the same path listed multiple times increases its chances of being selected over paths that only appear once. This allows you to combine, e.g. a `date_range` entry with a `misc` entry, but place more weight/emphasis on the `date_range` entry.
|
||||
`date_range` entries also accept an optional `weight` value that can be used to adjust the emphasis of this entry over
|
||||
others by adding the listed paths multiple times. Since Plex selects a random preroll from the list of paths, having the
|
||||
same path listed multiple times increases its chances of being selected over paths that only appear once. This allows
|
||||
you to combine, e.g. a `date_range` entry with a `misc` entry, but place more weight/emphasis on the `date_range` entry.
|
||||
|
||||
`date_range` entries also accept an optional `name` value that can be used to identify the schedule in the logs.
|
||||
|
||||
---
|
||||
|
||||
## Scheduling Script
|
||||
|
||||
**NOTE:** Scheduling is handled automatically in the Docker version of this script via the `CRON_SCHEDULE` environment variable.
|
||||
**NOTE:** Scheduling is handled automatically in the Docker version of this script via the `CRON_SCHEDULE` environment
|
||||
variable.
|
||||
|
||||
### Linux
|
||||
|
||||
@ -184,13 +192,14 @@ crontab -e
|
||||
Place desired schedule (example below for every day at midnight)
|
||||
|
||||
```sh
|
||||
0 0 * * * python /path/to/schedule_preroll.py >/dev/null 2>&1
|
||||
0 0 * * * python /path/to/run.py >/dev/null 2>&1
|
||||
```
|
||||
|
||||
You can also wrap the execution in a shell script (useful if running other scripts/commands, using venv encapsulation, customizing arguments, etc.)
|
||||
You can also wrap the execution in a shell script (useful if running other scripts/commands, using venv encapsulation,
|
||||
customizing arguments, etc.)
|
||||
|
||||
```sh
|
||||
0 0 * * * /path/to/schedule_preroll.sh >/dev/null 2>&1
|
||||
0 0 * * * /path/to/run_prerolls.sh >/dev/null 2>&1
|
||||
```
|
||||
|
||||
Schedule as frequently as needed for your schedule (ex: hourly, daily, weekly, etc.)
|
||||
|
@ -1,7 +0,0 @@
|
||||
[plexapi]
|
||||
container_size = 200
|
||||
timeout = 60
|
||||
|
||||
[auth]
|
||||
server_baseurl = http://127.0.0.1:32400
|
||||
server_token = <PLEX_TOKEN>
|
79
config.yaml.example
Normal file
79
config.yaml.example
Normal file
@ -0,0 +1,79 @@
|
||||
# All keys must be in lowercase
|
||||
# All paths will be case-sensitive based on your environment (Linux, Windows)
|
||||
|
||||
plex:
|
||||
url: http://localhost:32400 # URL to your Plex server
|
||||
token: thisismyplextoken # Your Plex token
|
||||
|
||||
# Always include these pre-rolls
|
||||
always:
|
||||
enabled: true
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
||||
count: 10 # Optional, randomly select X many videos from the list rather than all of them
|
||||
weight: 1 # Optional, how much to emphasize these pre-rolls over others (higher = more likely to play)
|
||||
|
||||
# Schedule prerolls by date and time frames
|
||||
date_range:
|
||||
enabled: true
|
||||
ranges:
|
||||
- name: "New Years" # Optional name for logging purposes
|
||||
start_date: 2020-01-01 # Jan 1st, 2020
|
||||
end_date: 2020-01-02 # Jan 2nd, 2020
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
||||
weight: 2 # Optional, add these paths to the list twice (make up greater percentage of prerolls - more likely to be selected)
|
||||
- start_date: xxxx-07-04 # Every year on July 4th
|
||||
end_date: xxxx-07-04 # Every year on July 4th
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
||||
- start_date: xxxx-xx-02 # Every year on the 2nd of every month
|
||||
end_date: xxxx-xx-03 # Every year on the 3rd of every month
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
||||
- start_date: xxxx-xx-xx 08:00:00 # Every day at 8am
|
||||
end_date: xxxx-xx-xx 09:30:00 # Every day at 9:30am
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
||||
|
||||
# Schedule prerolls by week of the year
|
||||
weekly:
|
||||
enabled: false
|
||||
weeks:
|
||||
- number: 1 # First week of the year
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
||||
weight: 1 # Optional, how much to emphasize these pre-rolls over others (higher = more likely to play)
|
||||
- number: 2 # Second week of the year
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
||||
|
||||
# Schedule prerolls by month of the year
|
||||
monthly:
|
||||
enabled: false
|
||||
months:
|
||||
- number: 1 # January
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
||||
weight: 1 # Optional, how much to emphasize these pre-rolls over others (higher = more likely to play)
|
||||
- number: 2 # February
|
||||
paths:
|
||||
- "path/to/video1.mp4"
|
||||
- "path/to/video2.mp4"
|
||||
- "path/to/video3.mp4"
|
6
consts.py
Normal file
6
consts.py
Normal file
@ -0,0 +1,6 @@
|
||||
APP_NAME = "Plex Prerolls"
|
||||
APP_DESCRIPTION = "A tool to manage prerolls for Plex"
|
||||
DEFAULT_CONFIG_PATH = "config.yaml"
|
||||
DEFAULT_LOG_DIR = "logs/"
|
||||
CONSOLE_LOG_LEVEL = "INFO"
|
||||
FILE_LOG_LEVEL = "DEBUG"
|
@ -3,8 +3,9 @@ services:
|
||||
plex_prerolls:
|
||||
image: nwithan8/plex_prerolls:latest
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/config:/
|
||||
- /path/to/logs:/logs
|
||||
environment:
|
||||
CRON_SCHEDULE: "0 0 * * *" # Run every day at midnight, see https://crontab.guru/ for help
|
||||
DRY_RUN: "false" # Set to true to test without actually downloading
|
||||
TZ: America/New_York
|
||||
|
@ -6,8 +6,17 @@ mkdir -p /etc/cron.d
|
||||
# Read cron schedule from environment variable
|
||||
CRON_SCHEDULE=${CRON_SCHEDULE:-"0 0 * * *"} # Default to midnight every day if not supplied
|
||||
|
||||
# Add "--dry-run" flag if DRY_RUN is set to true
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
DRY_RUN_FLAG="--dry-run"
|
||||
else
|
||||
DRY_RUN_FLAG=""
|
||||
fi
|
||||
|
||||
# DRY_RUN_FLAG="--dry-run"
|
||||
|
||||
# Schedule cron job with supplied cron schedule
|
||||
echo "$CRON_SCHEDULE python3 /schedule_preroll.py -c /config/config.ini -s /config/schedules.yaml -lc /config/logging.conf > /proc/1/fd/1 2>/proc/1/fd/2" > /etc/cron.d/schedule_preroll
|
||||
echo "$CRON_SCHEDULE python3 /run.py -c /config.yaml -l /logs $DRY_RUN_FLAG > /proc/1/fd/1 2>/proc/1/fd/2" > /etc/cron.d/schedule_preroll
|
||||
|
||||
# Give execution rights on the cron job
|
||||
chmod 0644 /etc/cron.d/schedule_preroll
|
||||
|
36
logging.conf
36
logging.conf
@ -1,36 +0,0 @@
|
||||
[loggers]
|
||||
keys=root,main
|
||||
|
||||
[handlers]
|
||||
keys=consoleHandler,fileHandler
|
||||
|
||||
[formatters]
|
||||
keys=consoleFormatter,fileFormatter
|
||||
|
||||
[logger_root]
|
||||
level=INFO
|
||||
handlers=consoleHandler,fileHandler
|
||||
|
||||
[logger_main]
|
||||
level=INFO
|
||||
handlers=consoleHandler,fileHandler
|
||||
qualname=__main__
|
||||
propagate=0
|
||||
|
||||
[handler_consoleHandler]
|
||||
class=StreamHandler
|
||||
level=INFO
|
||||
formatter=consoleFormatter
|
||||
args=(sys.stdout,)
|
||||
|
||||
[handler_fileHandler]
|
||||
class=logging.handlers.RotatingFileHandler
|
||||
level=WARNING
|
||||
formatter=fileFormatter
|
||||
args=('logs/schedule_preroll.log', 'a', 20000, 5)
|
||||
|
||||
[formatter_consoleFormatter]
|
||||
format=%(levelname)-8s | %(name)-12s | %(message)s
|
||||
|
||||
[formatter_fileFormatter]
|
||||
format=[%(asctime)s] | %(levelname)-8s | %(name)-12s | %(message)s
|
0
modules/__init__.py
Normal file
0
modules/__init__.py
Normal file
247
modules/config_parser.py
Normal file
247
modules/config_parser.py
Normal file
@ -0,0 +1,247 @@
|
||||
import json
|
||||
from typing import List, Union
|
||||
|
||||
import confuse
|
||||
import yaml
|
||||
|
||||
import modules.logs as logging
|
||||
|
||||
|
||||
class YAMLElement:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def _get_value(self, key: str, default=None):
|
||||
try:
|
||||
return self.data[key].get()
|
||||
except confuse.NotFoundError:
|
||||
return default
|
||||
except Exception:
|
||||
try:
|
||||
return self.data[key]
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
class Entry(YAMLElement):
|
||||
def __init__(self, data):
|
||||
super().__init__(data)
|
||||
self.data = data
|
||||
|
||||
@property
|
||||
def paths(self) -> List[str]:
|
||||
return self._get_value(key="paths", default=[])
|
||||
|
||||
@property
|
||||
def weight(self) -> int:
|
||||
return self._get_value(key="weight", default=1)
|
||||
|
||||
|
||||
class NumericalEntry(Entry):
|
||||
def __init__(self, data):
|
||||
super().__init__(data)
|
||||
|
||||
@property
|
||||
def number(self) -> int:
|
||||
return self._get_value(key="number", default=None)
|
||||
|
||||
|
||||
class DateRangeEntry(Entry):
|
||||
def __init__(self, data):
|
||||
super().__init__(data=data)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._get_value(key="name", default=None)
|
||||
|
||||
@property
|
||||
def start_date(self) -> str:
|
||||
return self._get_value(key="start_date", default=None)
|
||||
|
||||
@property
|
||||
def end_date(self) -> str:
|
||||
return self._get_value(key="end_date", default=None)
|
||||
|
||||
def __repr__(self):
|
||||
return f"DateRangeEntry(start_date={self.start_date}, end_date={self.end_date}, paths={self.paths}, weight={self.weight})"
|
||||
|
||||
|
||||
class WeekEntry(NumericalEntry):
|
||||
def __init__(self, data):
|
||||
super().__init__(data=data)
|
||||
|
||||
def __repr__(self):
|
||||
return f"WeekEntry(number={self.number}, paths={self.paths}, weight={self.weight})"
|
||||
|
||||
|
||||
class MonthEntry(NumericalEntry):
|
||||
def __init__(self, data):
|
||||
super().__init__(data=data)
|
||||
|
||||
def __repr__(self):
|
||||
return f"MonthEntry(number={self.number}, paths={self.paths}, weight={self.weight})"
|
||||
|
||||
|
||||
class ConfigSection(YAMLElement):
|
||||
def __init__(self, section_key: str, data, parent_key: str = None):
|
||||
self.section_key = section_key
|
||||
try:
|
||||
data = data[self.section_key]
|
||||
except confuse.NotFoundError:
|
||||
pass
|
||||
self._parent_key = parent_key
|
||||
super().__init__(data=data)
|
||||
|
||||
@property
|
||||
def full_key(self):
|
||||
if self._parent_key is None:
|
||||
return self.section_key
|
||||
return f"{self._parent_key}_{self.section_key}".upper()
|
||||
|
||||
def _get_subsection(self, key: str, default=None):
|
||||
try:
|
||||
return ConfigSection(section_key=key, parent_key=self.full_key, data=self.data)
|
||||
except confuse.NotFoundError:
|
||||
return default
|
||||
|
||||
|
||||
class PlexServerConfig(ConfigSection):
|
||||
def __init__(self, data):
|
||||
super().__init__(section_key="plex", data=data)
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return self._get_value(key="url", default="")
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return self._get_value(key="token", default="")
|
||||
|
||||
@property
|
||||
def port(self) -> Union[int, None]:
|
||||
port = self._get_value(key="port", default=None)
|
||||
if not port:
|
||||
# Try to parse the port from the URL
|
||||
if self.url.startswith("http://"):
|
||||
port = 80
|
||||
elif self.url.startswith("https://"):
|
||||
port = 443
|
||||
|
||||
return port
|
||||
|
||||
|
||||
class ScheduleSection(ConfigSection):
|
||||
def __init__(self, section_key: str, data):
|
||||
super().__init__(section_key=section_key, data=data)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._get_value(key="enabled", default=False)
|
||||
|
||||
|
||||
class AlwaysSection(ScheduleSection):
|
||||
def __init__(self, data):
|
||||
super(ScheduleSection, self).__init__(section_key="always", data=data)
|
||||
|
||||
# Double inheritance doesn't work well with conflicting "data" properties, just re-implement these two functions.
|
||||
@property
|
||||
def paths(self) -> List[str]:
|
||||
return self._get_value(key="paths", default=[])
|
||||
|
||||
@property
|
||||
def weight(self) -> int:
|
||||
return self._get_value(key="weight", default=1)
|
||||
|
||||
@property
|
||||
def random_count(self) -> int:
|
||||
return self._get_value(key="count", default=len(self.paths))
|
||||
|
||||
def __repr__(self):
|
||||
return f"AlwaysSection(paths={self.paths}, weight={self.weight}, random_count={self.random_count})"
|
||||
|
||||
|
||||
class DateRangeSection(ScheduleSection):
|
||||
def __init__(self, data):
|
||||
super().__init__(section_key="date_range", data=data)
|
||||
|
||||
@property
|
||||
def ranges(self) -> List[DateRangeEntry]:
|
||||
data = self._get_value(key="ranges", default=[])
|
||||
return [DateRangeEntry(data=d) for d in data]
|
||||
|
||||
@property
|
||||
def range_count(self) -> int:
|
||||
return len(self.ranges)
|
||||
|
||||
|
||||
class WeeklySection(ScheduleSection):
|
||||
def __init__(self, data):
|
||||
super().__init__(section_key="weekly", data=data)
|
||||
|
||||
@property
|
||||
def weeks(self) -> List[WeekEntry]:
|
||||
data = self._get_value(key="weeks", default=[])
|
||||
return [WeekEntry(data=d) for d in data]
|
||||
|
||||
@property
|
||||
def week_count(self) -> int:
|
||||
return len(self.weeks)
|
||||
|
||||
|
||||
class MonthlySection(ScheduleSection):
|
||||
def __init__(self, data):
|
||||
super().__init__(section_key="monthly", data=data)
|
||||
|
||||
@property
|
||||
def months(self) -> List[MonthEntry]:
|
||||
data = self._get_value(key="months", default=[])
|
||||
return [MonthEntry(data=d) for d in data]
|
||||
|
||||
@property
|
||||
def month_count(self) -> int:
|
||||
return len(self.months)
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, app_name: str, config_path: str):
|
||||
self.config = confuse.Configuration(app_name)
|
||||
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
self.config.set_file(filename=config_path)
|
||||
logging.debug(f"Loaded config from {config_path}")
|
||||
except Exception: # pylint: disable=broad-except # not sure what confuse will throw
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
self.plex = PlexServerConfig(data=self.config)
|
||||
self.always = AlwaysSection(data=self.config)
|
||||
self.date_ranges = DateRangeSection(data=self.config)
|
||||
self.monthly = MonthlySection(data=self.config)
|
||||
self.weekly = WeeklySection(data=self.config)
|
||||
|
||||
logging.debug(f"Using configuration:\n{self.log()}")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
raw_yaml_data = self.config.dump()
|
||||
json_data = yaml.load(raw_yaml_data, Loader=yaml.FullLoader)
|
||||
return json.dumps(json_data, indent=4)
|
||||
|
||||
@property
|
||||
def all(self) -> dict:
|
||||
return {
|
||||
"Plex - URL": self.plex.url,
|
||||
"Plex - Token": "Exists" if self.plex.token else "Not Set",
|
||||
"Always - Enabled": self.always.enabled,
|
||||
"Always - Paths": self.always.paths,
|
||||
"Always - Count": self.always.random_count,
|
||||
"Always - Weight": self.always.weight,
|
||||
"Date Range - Enabled": self.date_ranges.enabled,
|
||||
"Date Range - Ranges": self.date_ranges.ranges,
|
||||
"Monthly - Enabled": self.monthly.enabled,
|
||||
"Monthly - Months": self.monthly.months,
|
||||
"Weekly - Enabled": self.weekly.enabled,
|
||||
"Weekly - Weeks": self.weekly.weeks,
|
||||
}
|
||||
|
||||
def log(self) -> str:
|
||||
return "\n".join([f"{key}: {value}" for key, value in self.all.items()])
|
71
modules/logs.py
Normal file
71
modules/logs.py
Normal file
@ -0,0 +1,71 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
_nameToLevel = {
|
||||
'CRITICAL': logging.CRITICAL,
|
||||
'FATAL': logging.FATAL,
|
||||
'ERROR': logging.ERROR,
|
||||
'WARN': logging.WARNING,
|
||||
'WARNING': logging.WARNING,
|
||||
'INFO': logging.INFO,
|
||||
'DEBUG': logging.DEBUG,
|
||||
'NOTSET': logging.NOTSET,
|
||||
}
|
||||
|
||||
_DEFAULT_LOGGER_NAME = None
|
||||
|
||||
|
||||
def init(app_name: str,
|
||||
console_log_level: str,
|
||||
log_to_file: Optional[bool] = False,
|
||||
log_file_dir: Optional[str] = "",
|
||||
file_log_level: Optional[str] = None):
|
||||
global _DEFAULT_LOGGER_NAME
|
||||
_DEFAULT_LOGGER_NAME = app_name
|
||||
|
||||
logger = logging.getLogger(app_name)
|
||||
|
||||
# Default log to DEBUG
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
formatter = logging.Formatter('%(asctime)s - [%(levelname)s]: %(message)s')
|
||||
|
||||
# Console logging
|
||||
console_logger = logging.StreamHandler()
|
||||
console_logger.setFormatter(formatter)
|
||||
console_logger.setLevel(level_name_to_level(console_log_level))
|
||||
logger.addHandler(console_logger)
|
||||
|
||||
# File logging
|
||||
if log_to_file:
|
||||
log_file_dir = log_file_dir if log_file_dir.endswith('/') else f'{log_file_dir}/'
|
||||
file_logger = logging.FileHandler(f'{log_file_dir}{app_name}.log')
|
||||
file_logger.setFormatter(formatter)
|
||||
file_logger.setLevel(level_name_to_level(file_log_level or console_log_level))
|
||||
logger.addHandler(file_logger)
|
||||
|
||||
|
||||
def level_name_to_level(level_name: str):
|
||||
return _nameToLevel.get(level_name, _nameToLevel['NOTSET'])
|
||||
|
||||
|
||||
def info(message: str, specific_logger: Optional[str] = None):
|
||||
logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).info(msg=message)
|
||||
|
||||
def warning(message: str, specific_logger: Optional[str] = None):
|
||||
logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).warning(msg=message)
|
||||
|
||||
def debug(message: str, specific_logger: Optional[str] = None):
|
||||
logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).debug(msg=message)
|
||||
|
||||
|
||||
def error(message: str, specific_logger: Optional[str] = None):
|
||||
logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).error(msg=message)
|
||||
|
||||
|
||||
def critical(message: str, specific_logger: Optional[str] = None):
|
||||
logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).critical(msg=message)
|
||||
|
||||
|
||||
def fatal(message: str, specific_logger: Optional[str] = None):
|
||||
logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).critical(msg=message)
|
92
modules/models.py
Normal file
92
modules/models.py
Normal file
@ -0,0 +1,92 @@
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import NamedTuple, List, Union
|
||||
|
||||
import modules.logs as logging
|
||||
from modules import utils
|
||||
from modules.statics import ScheduleType
|
||||
|
||||
|
||||
class ScheduleEntry(NamedTuple):
|
||||
type: str
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
paths: List[str]
|
||||
weight: int
|
||||
name_prefix: str
|
||||
|
||||
@property
|
||||
def should_be_used(self) -> bool:
|
||||
now = datetime.now()
|
||||
return self.start_date <= now <= self.end_date
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"{self.name_prefix} ({self.start_date} - {self.end_date})"
|
||||
|
||||
|
||||
def schedule_entry_from_always(paths: List[str], count: int, weight: int) -> ScheduleEntry:
|
||||
start_date = utils.make_midnight(utils.start_of_time())
|
||||
end_date = utils.make_right_before_midnight(utils.end_of_time())
|
||||
|
||||
if count > len(paths):
|
||||
logging.warning(f"Always schedule has a count of {count} but only {len(paths)} paths were provided. "
|
||||
f"Setting count to {len(paths)}")
|
||||
count = len(paths)
|
||||
|
||||
random_paths = random.sample(population=paths, k=count)
|
||||
|
||||
return ScheduleEntry(type=ScheduleType.always.value,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
paths=random_paths,
|
||||
weight=weight,
|
||||
name_prefix="Always")
|
||||
|
||||
|
||||
def schedule_entry_from_week_number(week_number: int, paths: List[str], weight: int) -> Union[ScheduleEntry, None]:
|
||||
start_date = utils.start_of_week_number(week_number=week_number)
|
||||
end_date = utils.end_of_week_number(week_number=week_number)
|
||||
|
||||
return ScheduleEntry(type=ScheduleType.weekly.value,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
paths=paths,
|
||||
weight=weight,
|
||||
name_prefix=f"Week {week_number}")
|
||||
|
||||
|
||||
def schedule_entry_from_month_number(month_number: int, paths: List[str], weight: int) -> Union[ScheduleEntry, None]:
|
||||
start_date = utils.start_of_month(month_number=month_number)
|
||||
end_date = utils.end_of_month(month_number=month_number)
|
||||
|
||||
return ScheduleEntry(type=ScheduleType.monthly.value,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
paths=paths,
|
||||
weight=weight,
|
||||
name_prefix=f"Month {month_number}")
|
||||
|
||||
|
||||
def schedule_entry_from_date_range(start_date_string: str, end_date_string: str, paths: List[str], weight: int,
|
||||
name: str = None) \
|
||||
-> Union[ScheduleEntry, None]:
|
||||
if not name:
|
||||
name = "Date Range"
|
||||
|
||||
start_date, end_date = utils.wildcard_strings_to_datetimes(start_date_string=start_date_string,
|
||||
end_date_string=end_date_string)
|
||||
|
||||
if not start_date or not end_date:
|
||||
logging.error(
|
||||
f"{name} has invalid start or end date wildcard patterns. "
|
||||
f"Any wildcard elements must be in the same position in both the start and end date.\n"
|
||||
f"Start date: {start_date_string}\nEnd date: {end_date_string}")
|
||||
return None
|
||||
|
||||
return ScheduleEntry(type=ScheduleType.date_range.value,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
paths=paths,
|
||||
weight=weight,
|
||||
name_prefix=name)
|
45
modules/plex_connector.py
Normal file
45
modules/plex_connector.py
Normal file
@ -0,0 +1,45 @@
|
||||
from typing import List, Union
|
||||
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
import modules.logs as logging
|
||||
|
||||
|
||||
def prepare_pre_roll_string(paths: List[str]) -> Union[str, None]:
|
||||
if not paths:
|
||||
return None
|
||||
|
||||
# Filter out empty paths
|
||||
paths = [path for path in paths if path]
|
||||
|
||||
return ";".join(paths)
|
||||
|
||||
|
||||
class PlexConnector:
|
||||
def __init__(self, host: str, token: str):
|
||||
self._host = host
|
||||
self._token = token
|
||||
logging.info(f"Connecting to Plex server at {self._host}")
|
||||
self._plex_server = PlexServer(baseurl=self._host, token=self._token)
|
||||
|
||||
def update_pre_roll_paths(self, paths: List[str], testing: bool = False) -> None:
|
||||
pre_roll_string = prepare_pre_roll_string(paths=paths)
|
||||
if not pre_roll_string:
|
||||
logging.info("No pre-roll paths to update")
|
||||
return
|
||||
|
||||
if testing:
|
||||
logging.debug(f"Testing: Would have updated pre-roll to: {pre_roll_string}")
|
||||
return
|
||||
|
||||
logging.info(f"Updating pre-roll to: {pre_roll_string}")
|
||||
|
||||
self._plex_server.settings.get("cinemaTrailersPrerollID").set(pre_roll_string) # type: ignore
|
||||
|
||||
try:
|
||||
self._plex_server.settings.save() # type: ignore
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save pre-roll: {e}")
|
||||
return
|
||||
|
||||
logging.info("Successfully updated pre-roll")
|
143
modules/schedule_manager.py
Normal file
143
modules/schedule_manager.py
Normal file
@ -0,0 +1,143 @@
|
||||
from typing import List
|
||||
|
||||
from modules import models
|
||||
from modules.config_parser import (
|
||||
Config,
|
||||
)
|
||||
from modules.models import ScheduleEntry
|
||||
import modules.logs as logging
|
||||
|
||||
|
||||
class ScheduleManager:
|
||||
def __init__(self, config: Config):
|
||||
self._config = config
|
||||
self.weekly_schedules: List[ScheduleEntry] = []
|
||||
self.monthly_schedules: List[ScheduleEntry] = []
|
||||
self.date_range_schedules: List[ScheduleEntry] = []
|
||||
self.always_schedules: List[ScheduleEntry] = []
|
||||
self._parse_schedules() # Only call this once, otherwise it will duplicate schedules
|
||||
|
||||
def _parse_schedules(self):
|
||||
logging.info("Parsing schedules...")
|
||||
if self._config.weekly.enabled:
|
||||
for week in self._config.weekly.weeks:
|
||||
self.weekly_schedules.append(models.schedule_entry_from_week_number(week_number=week.number,
|
||||
paths=week.paths,
|
||||
weight=week.weight))
|
||||
|
||||
if self._config.monthly.enabled:
|
||||
for month in self._config.monthly.months:
|
||||
self.monthly_schedules.append(models.schedule_entry_from_month_number(month_number=month.number,
|
||||
paths=month.paths,
|
||||
weight=month.weight))
|
||||
if self._config.date_ranges.enabled:
|
||||
for date_range in self._config.date_ranges.ranges:
|
||||
entry = models.schedule_entry_from_date_range(start_date_string=date_range.start_date,
|
||||
end_date_string=date_range.end_date,
|
||||
paths=date_range.paths,
|
||||
weight=date_range.weight,
|
||||
name=date_range.name)
|
||||
if entry:
|
||||
self.date_range_schedules.append(entry)
|
||||
|
||||
if self._config.always.enabled:
|
||||
self.always_schedules.append(models.schedule_entry_from_always(paths=self._config.always.paths,
|
||||
count=self._config.always.random_count,
|
||||
weight=self._config.always.weight))
|
||||
|
||||
@property
|
||||
def valid_weekly_schedules(self) -> List[ScheduleEntry]:
|
||||
return [schedule for schedule in self.weekly_schedules if schedule.should_be_used]
|
||||
|
||||
@property
|
||||
def valid_weekly_schedule_count(self) -> int:
|
||||
return len(self.valid_weekly_schedules)
|
||||
|
||||
@property
|
||||
def valid_weekly_schedule_log_message(self) -> str:
|
||||
valid_schedules = ""
|
||||
for schedule in self.valid_weekly_schedules:
|
||||
valid_schedules += f"- {schedule.name}\n"
|
||||
return valid_schedules
|
||||
|
||||
@property
|
||||
def valid_monthly_schedules(self) -> List[ScheduleEntry]:
|
||||
return [schedule for schedule in self.monthly_schedules if schedule.should_be_used]
|
||||
|
||||
@property
|
||||
def valid_monthly_schedule_count(self) -> int:
|
||||
return len(self.valid_monthly_schedules)
|
||||
|
||||
@property
|
||||
def valid_monthly_schedule_log_message(self) -> str:
|
||||
valid_schedules = ""
|
||||
for schedule in self.valid_monthly_schedules:
|
||||
valid_schedules += f"- {schedule.name}\n"
|
||||
return valid_schedules
|
||||
|
||||
@property
|
||||
def valid_date_range_schedules(self) -> List[ScheduleEntry]:
|
||||
return [schedule for schedule in self.date_range_schedules if schedule.should_be_used]
|
||||
|
||||
@property
|
||||
def valid_date_range_schedule_count(self) -> int:
|
||||
return len(self.valid_date_range_schedules)
|
||||
|
||||
@property
|
||||
def valid_date_range_schedule_log_message(self) -> str:
|
||||
valid_schedules = ""
|
||||
for schedule in self.valid_date_range_schedules:
|
||||
valid_schedules += f"- {schedule.name}\n"
|
||||
return valid_schedules
|
||||
|
||||
@property
|
||||
def valid_always_schedules(self) -> List[ScheduleEntry]:
|
||||
return [schedule for schedule in self.always_schedules if schedule.should_be_used]
|
||||
|
||||
@property
|
||||
def valid_always_schedule_count(self) -> int:
|
||||
return len(self.valid_always_schedules)
|
||||
|
||||
@property
|
||||
def valid_always_schedule_log_message(self) -> str:
|
||||
valid_schedules = ""
|
||||
for schedule in self.valid_always_schedules:
|
||||
valid_schedules += f"- {schedule.name}\n"
|
||||
return valid_schedules
|
||||
|
||||
@property
|
||||
def all_schedules(self) -> List[ScheduleEntry]:
|
||||
return self.always_schedules + self.weekly_schedules + self.monthly_schedules + self.date_range_schedules
|
||||
|
||||
@property
|
||||
def all_valid_schedules(self) -> List[ScheduleEntry]:
|
||||
return [schedule for schedule in self.all_schedules if schedule.should_be_used]
|
||||
|
||||
@property
|
||||
def all_valid_paths(self) -> List[str]:
|
||||
"""
|
||||
Returns a list of all valid paths from all valid schedules. Accounts for weight.
|
||||
"""
|
||||
paths = []
|
||||
for schedule in self.all_valid_schedules:
|
||||
for _ in range(schedule.weight):
|
||||
paths.extend(schedule.paths)
|
||||
|
||||
return paths
|
||||
|
||||
@property
|
||||
def valid_schedule_count(self) -> int:
|
||||
return len(self.all_valid_schedules)
|
||||
|
||||
@property
|
||||
def valid_schedule_count_log_message(self) -> str:
|
||||
return f"""
|
||||
Valid Schedule Count:
|
||||
Always - {self.valid_always_schedule_count}
|
||||
{self.valid_always_schedule_log_message}
|
||||
Weekly - {self.valid_weekly_schedule_count}
|
||||
{self.valid_weekly_schedule_log_message}
|
||||
Monthly - {self.valid_monthly_schedule_count}
|
||||
{self.valid_monthly_schedule_log_message}
|
||||
Date Ranges - {self.valid_date_range_schedule_count}
|
||||
{self.valid_date_range_schedule_log_message}"""
|
42
modules/statics.py
Normal file
42
modules/statics.py
Normal file
@ -0,0 +1,42 @@
|
||||
# Number 1-9, and A-Z
|
||||
import enum
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
VERSION = "VERSIONADDEDBYGITHUB"
|
||||
COPYRIGHT = "Copyright © YEARADDEDBYGITHUB Nate Harris. All rights reserved."
|
||||
|
||||
ASCII_ART = """
|
||||
"""
|
||||
|
||||
|
||||
def splash_logo() -> str:
|
||||
version = VERSION
|
||||
if "GITHUB" in version:
|
||||
try:
|
||||
last_commit = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8").strip()
|
||||
version = f"git-{last_commit[:7]}"
|
||||
except subprocess.SubprocessError:
|
||||
version = "git-unknown-commit"
|
||||
return f"""
|
||||
{ASCII_ART}
|
||||
Version {version}, Python {sys.version}
|
||||
|
||||
{COPYRIGHT}
|
||||
"""
|
||||
|
||||
|
||||
class ScheduleType(enum.Enum):
|
||||
monthly = "monthly"
|
||||
weekly = "weekly"
|
||||
date_range = "date_range"
|
||||
always = "always"
|
||||
|
||||
|
||||
def schedule_types() -> list[str]:
|
||||
"""Return a list of Schedule Types
|
||||
|
||||
Returns:
|
||||
List[ScheduleType]: List of Schedule Types
|
||||
"""
|
||||
return [_enum.value for _enum in ScheduleType]
|
318
modules/utils.py
Normal file
318
modules/utils.py
Normal file
@ -0,0 +1,318 @@
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Tuple, Union
|
||||
|
||||
from pytz import timezone
|
||||
|
||||
import modules.logs as logging
|
||||
|
||||
|
||||
def make_plural(word, count: int, suffix_override: str = 's') -> str:
|
||||
if count > 1:
|
||||
return f"{word}{suffix_override}"
|
||||
return word
|
||||
|
||||
|
||||
def quote(string: str) -> str:
|
||||
return f"\"{string}\""
|
||||
|
||||
|
||||
def status_code_is_success(status_code: int) -> bool:
|
||||
return 200 <= status_code < 300
|
||||
|
||||
|
||||
def milliseconds_to_minutes_seconds(milliseconds: int) -> str:
|
||||
seconds = int(milliseconds / 1000)
|
||||
minutes = int(seconds / 60)
|
||||
if minutes < 10:
|
||||
minutes = f"0{minutes}"
|
||||
seconds = int(seconds % 60)
|
||||
if seconds < 10:
|
||||
seconds = f"0{seconds}"
|
||||
return f"{minutes}:{seconds}"
|
||||
|
||||
|
||||
def now(timezone_code: str = None) -> datetime:
|
||||
if timezone_code:
|
||||
return datetime.now(timezone(timezone_code)) # will raise exception if invalid timezone_code
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def now_plus_milliseconds(milliseconds: int, timezone_code: str = None) -> datetime:
|
||||
if timezone_code:
|
||||
_now = datetime.now(timezone(timezone_code)) # will raise exception if invalid timezone_code
|
||||
else:
|
||||
_now = datetime.now()
|
||||
return _now + timedelta(milliseconds=milliseconds)
|
||||
|
||||
|
||||
def now_in_range(start: datetime, end: datetime) -> bool:
|
||||
_now = now()
|
||||
return start <= _now <= end
|
||||
|
||||
|
||||
def start_of_time() -> datetime:
|
||||
return datetime(1970, 1, 1)
|
||||
|
||||
|
||||
def end_of_time() -> datetime:
|
||||
return datetime(9999, 12, 31)
|
||||
|
||||
|
||||
def start_of_year(year: int = None) -> datetime:
|
||||
_now = now()
|
||||
|
||||
if not year:
|
||||
year = _now.year
|
||||
|
||||
return datetime(year, 1, 1)
|
||||
|
||||
|
||||
def end_of_year(year: int = None) -> datetime:
|
||||
_now = now()
|
||||
|
||||
if not year:
|
||||
year = _now.year
|
||||
|
||||
return datetime(year, 12, 31)
|
||||
|
||||
|
||||
def start_of_month(month_number: int = None) -> datetime:
|
||||
_now = now()
|
||||
|
||||
if not month_number:
|
||||
month_number = _now.month
|
||||
|
||||
return datetime(_now.year, month_number, 1)
|
||||
|
||||
|
||||
def end_of_month(month_number: int = None) -> datetime:
|
||||
_now = now()
|
||||
|
||||
if not month_number:
|
||||
month_number = _now.month
|
||||
|
||||
if month_number == 12:
|
||||
return end_of_year(year=_now.year) # If month is December, return end of year (shortcut)
|
||||
else:
|
||||
return start_of_month(month_number=month_number + 1) - timedelta(
|
||||
days=1) # Subtract one day from start of next month
|
||||
|
||||
|
||||
def start_of_week_number(week_number: int = None) -> datetime:
|
||||
_now = now()
|
||||
|
||||
if not week_number:
|
||||
week_number = _now.strftime('%U')
|
||||
|
||||
return datetime.strptime(f"{_now.year}-W{int(week_number)}-0", "%Y-W%W-%w")
|
||||
|
||||
|
||||
def end_of_week_number(week_number: int = None) -> datetime:
|
||||
_now = now()
|
||||
|
||||
if not week_number:
|
||||
week_number = _now.strftime('%U')
|
||||
|
||||
return datetime.strptime(f"{_now.year}-W{int(week_number)}-6", "%Y-W%W-%w")
|
||||
|
||||
|
||||
def make_midnight(date: datetime) -> datetime:
|
||||
return datetime(date.year, date.month, date.day)
|
||||
|
||||
|
||||
def make_right_before_midnight(date: datetime) -> datetime:
|
||||
return datetime(date.year, date.month, date.day, 23, 59, 59)
|
||||
|
||||
|
||||
def limit_text_length(text: str, limit: int, suffix: str = "...") -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
|
||||
suffix_length = len(suffix)
|
||||
return f"{text[:limit - suffix_length]}{suffix}"
|
||||
|
||||
|
||||
def string_to_datetime(date_string: str, template: str = "%Y-%m-%dT%H:%M:%S") -> datetime:
|
||||
"""
|
||||
Convert a datetime string to a datetime.datetime object
|
||||
|
||||
:param date_string: datetime string to convert
|
||||
:type date_string: str
|
||||
:param template: (Optional) datetime template to use when parsing string
|
||||
:type template: str, optional
|
||||
:return: datetime.datetime object
|
||||
:rtype: datetime.datetime
|
||||
"""
|
||||
if date_string.endswith('Z'):
|
||||
date_string = date_string[:-5]
|
||||
return datetime.strptime(date_string, template)
|
||||
|
||||
|
||||
def datetime_to_string(datetime_object: datetime, template: str = "%Y-%m-%dT%H:%M:%S.000Z") -> str:
|
||||
"""
|
||||
Convert a datetime.datetime object to a string
|
||||
|
||||
:param datetime_object: datetime.datetime object to convert
|
||||
:type datetime_object: datetime.datetime
|
||||
:param template: (Optional) datetime template to use when parsing string
|
||||
:type template: str, optional
|
||||
:return: str representation of datetime
|
||||
:rtype: str
|
||||
"""
|
||||
return datetime_object.strftime(template)
|
||||
|
||||
|
||||
def wildcard_strings_to_datetimes(start_date_string: str, end_date_string: str) -> \
|
||||
Tuple[Union[datetime, None], Union[datetime, None]]:
|
||||
"""
|
||||
Convert date or datetime strings with wildcards to datetime.datetime objects
|
||||
|
||||
:param start_date_string: start datetime string to convert
|
||||
:type start_date_string: str
|
||||
:param end_date_string: end datetime string to convert
|
||||
:type end_date_string: str
|
||||
:return: datetime.datetime object
|
||||
:rtype: datetime.datetime
|
||||
"""
|
||||
if isinstance(start_date_string, date):
|
||||
start_date_string = start_date_string.strftime("%Y-%m-%d")
|
||||
if isinstance(end_date_string, date):
|
||||
end_date_string = end_date_string.strftime("%Y-%m-%d")
|
||||
|
||||
start_date_and_time = start_date_string.split(' ')
|
||||
end_date_and_time = end_date_string.split(' ')
|
||||
template = "%Y-%m-%d %H:%M:%S"
|
||||
_now = now()
|
||||
|
||||
# Sample: xxxx-xx-xx
|
||||
# Sample: xxxx-xx-xx xx:xx:xx
|
||||
|
||||
_start_date = start_date_and_time[0] # xxxx-xx-xx
|
||||
_end_date = end_date_and_time[0] # xxxx-xx-xx
|
||||
|
||||
need_specific_datetime = False
|
||||
|
||||
_start_time = start_date_and_time[1] if len(start_date_and_time) > 1 else "00:00:00"
|
||||
_end_time = end_date_and_time[1] if len(end_date_and_time) > 1 else "23:59:59"
|
||||
|
||||
# Sample: xxxx-xx-xx xx:xx:xx
|
||||
|
||||
start_time_parts = _start_time.split(':')
|
||||
end_time_parts = _end_time.split(':')
|
||||
|
||||
start_second = start_time_parts[2]
|
||||
end_second = end_time_parts[2]
|
||||
|
||||
# Can't have a wildcard in one and not the other
|
||||
if (start_second != 'xx' and end_second == 'xx') or (start_second == 'xx' and end_second != 'xx'):
|
||||
logging.error(message=f"Incompatible second comparison: {start_date_string} - {end_date_string}")
|
||||
return None, None
|
||||
|
||||
# At this point, either they both have wildcards or neither do, we can assume based on start_second
|
||||
if start_second == 'xx':
|
||||
start_second = '00'
|
||||
end_second = '59' # Keep wide to ensure script running time doesn't interfere
|
||||
else:
|
||||
need_specific_datetime = True
|
||||
|
||||
start_minute = start_time_parts[1]
|
||||
end_minute = end_time_parts[1]
|
||||
|
||||
# Can't have a wildcard in one and not the other
|
||||
if (start_minute != 'xx' and end_minute == 'xx') or (start_minute == 'xx' and end_minute != 'xx'):
|
||||
logging.error(message=f"Incompatible minute comparison: {start_date_string} - {end_date_string}")
|
||||
return None, None
|
||||
|
||||
# At this point, either they both have wildcards or neither do, we can assume based on start_minute
|
||||
if start_minute == 'xx':
|
||||
if need_specific_datetime:
|
||||
start_minute = _now.minute
|
||||
end_minute = _now.minute + 3 # Give buffer for script running time
|
||||
else:
|
||||
start_minute = '00'
|
||||
end_minute = '59' # Keep wide to ensure script running time doesn't interfere
|
||||
else:
|
||||
need_specific_datetime = True
|
||||
|
||||
start_hour = start_time_parts[0]
|
||||
end_hour = end_time_parts[0]
|
||||
|
||||
# Can't have a wildcard in one and not the other
|
||||
if (start_hour != 'xx' and end_hour == 'xx') or (start_hour == 'xx' and end_hour != 'xx'):
|
||||
logging.error(message=f"Incompatible hour comparison: {start_date_string} - {end_date_string}")
|
||||
return None, None
|
||||
|
||||
# At this point, either they both have wildcards or neither do, we can assume based on start_hour
|
||||
if start_hour == 'xx':
|
||||
if need_specific_datetime:
|
||||
start_hour = _now.hour
|
||||
end_hour = _now.hour
|
||||
else:
|
||||
start_hour = '00'
|
||||
end_hour = '23' # Keep wide to ensure script running time doesn't interfere
|
||||
|
||||
_start_time = f"{start_hour}:{start_minute}:{start_second}"
|
||||
_end_time = f"{end_hour}:{end_minute}:{end_second}"
|
||||
|
||||
start_date_parts = _start_date.split('-')
|
||||
end_date_parts = _end_date.split('-')
|
||||
|
||||
start_day = start_date_parts[2]
|
||||
end_day = end_date_parts[2]
|
||||
|
||||
# Can't have a wildcard in one and not the other
|
||||
if (start_day != 'xx' and end_day == 'xx') or (start_day == 'xx' and end_day != 'xx'):
|
||||
logging.error(message=f"Incompatible day comparison: {start_date_string} - {end_date_string}")
|
||||
return None, None
|
||||
|
||||
# At this point, either they both have wildcards or neither do, we can assume based on start_day
|
||||
if start_day == 'xx':
|
||||
start_day = _now.day
|
||||
end_day = _now.day
|
||||
else:
|
||||
need_specific_datetime = True
|
||||
|
||||
start_month = start_date_parts[1]
|
||||
end_month = end_date_parts[1]
|
||||
|
||||
# Can't have a wildcard in one and not the other
|
||||
if (start_month != 'xx' and end_month == 'xx') or (start_month == 'xx' and end_month != 'xx'):
|
||||
logging.error(message=f"Incompatible month comparison: {start_date_string} - {end_date_string}")
|
||||
return None, None
|
||||
|
||||
# At this point, either they both have wildcards or neither do, we can assume based on start_month
|
||||
if start_month == 'xx':
|
||||
if need_specific_datetime:
|
||||
start_month = _now.month
|
||||
end_month = _now.month
|
||||
else:
|
||||
start_month = start_of_year().month
|
||||
end_month = end_of_year().month
|
||||
else:
|
||||
need_specific_datetime = True
|
||||
|
||||
start_year = start_date_parts[0]
|
||||
end_year = end_date_parts[0]
|
||||
|
||||
# Can't have a wildcard in one and not the other
|
||||
if (start_year != 'xxxx' and end_year == 'xxxx') or (start_year == 'xxxx' and end_year != 'xxxx'):
|
||||
logging.error(message=f"Incompatible year comparison: {start_date_string} - {end_date_string}")
|
||||
return None, None
|
||||
|
||||
# At this point, either they both have wildcards or neither do, we can assume based on start_yea
|
||||
if start_year == 'xxxx':
|
||||
if need_specific_datetime:
|
||||
start_year = _now.year
|
||||
end_year = _now.year
|
||||
else:
|
||||
start_year = start_of_time().year
|
||||
end_year = end_of_time().year
|
||||
|
||||
_start_date = f"{start_year}-{start_month}-{start_day}"
|
||||
_end_date = f"{end_year}-{end_month}-{end_day}"
|
||||
|
||||
_start_datetime = f"{_start_date} {_start_time}"
|
||||
_end_datetime = f"{_end_date} {_end_time}"
|
||||
|
||||
return string_to_datetime(date_string=_start_datetime, template=template), \
|
||||
string_to_datetime(date_string=_end_datetime, template=template)
|
9
mypy.ini
9
mypy.ini
@ -1,9 +0,0 @@
|
||||
[mypy]
|
||||
warn_return_any = True
|
||||
warn_unused_configs = True
|
||||
|
||||
[mypy-plexapi.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-urllib3.*]
|
||||
ignore_missing_imports = True
|
@ -1,6 +1,4 @@
|
||||
plexapi==4.13.*
|
||||
configparser==5.0.*
|
||||
requests==2.25.*
|
||||
pyyaml==5.3.*
|
||||
cerberus==1.3.*
|
||||
urllib3~=1.26.18
|
||||
confuse~=1.7
|
||||
pytz~=2022.1
|
||||
PyYAML==6.0.*
|
||||
|
46
run.py
Normal file
46
run.py
Normal file
@ -0,0 +1,46 @@
|
||||
import argparse
|
||||
|
||||
import modules.logs as logging
|
||||
from consts import (
|
||||
APP_NAME,
|
||||
APP_DESCRIPTION,
|
||||
DEFAULT_CONFIG_PATH,
|
||||
DEFAULT_LOG_DIR,
|
||||
CONSOLE_LOG_LEVEL,
|
||||
FILE_LOG_LEVEL,
|
||||
)
|
||||
from modules.config_parser import Config
|
||||
from modules.plex_connector import PlexConnector
|
||||
from modules.schedule_manager import ScheduleManager
|
||||
|
||||
parser = argparse.ArgumentParser(description=f"{APP_NAME} - {APP_DESCRIPTION}")
|
||||
|
||||
parser.add_argument("-c", "--config", help=f"Path to config file. Defaults to '{DEFAULT_CONFIG_PATH}'",
|
||||
default=DEFAULT_CONFIG_PATH)
|
||||
|
||||
# Should include trailing backslash
|
||||
parser.add_argument("-l", "--log", help=f"Log file directory. Defaults to '{DEFAULT_LOG_DIR}'", default=DEFAULT_LOG_DIR)
|
||||
|
||||
parser.add_argument("-d", "--dry-run", help="Dry run, no real changes made", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set up logging
|
||||
logging.init(app_name=APP_NAME, console_log_level=FILE_LOG_LEVEL if args.dry_run else CONSOLE_LOG_LEVEL, log_to_file=True, log_file_dir=args.log,
|
||||
file_log_level=FILE_LOG_LEVEL)
|
||||
|
||||
config = Config(app_name=APP_NAME, config_path=f"{args.config}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# logging.info(splash_logo())
|
||||
logging.info(f"Starting {APP_NAME}...")
|
||||
|
||||
schedule_manager = ScheduleManager(config=config)
|
||||
|
||||
logging.info(f"Found {schedule_manager.valid_schedule_count} valid schedules")
|
||||
logging.info(schedule_manager.valid_schedule_count_log_message)
|
||||
|
||||
all_valid_paths = schedule_manager.all_valid_paths
|
||||
|
||||
plex_connector = PlexConnector(host=config.plex.url, token=config.plex.token)
|
||||
plex_connector.update_pre_roll_paths(paths=all_valid_paths, testing=args.dry_run)
|
@ -1,721 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
"""Schedule Plex server related Pre-roll Intro videos
|
||||
A helper script to automate management of Plex pre-rolls.
|
||||
Define when you want different pre-rolls to play throughout the year.
|
||||
|
||||
Set it and forget it!
|
||||
|
||||
Optional Arguments:
|
||||
-h, --help show this help message and exit
|
||||
-v, --version show the version number and exit
|
||||
-lc LOG_CONFIG_FILE, --logconfig-path LOG_CONFIG_FILE
|
||||
Path to logging config file.
|
||||
[Default: ./logging.conf]
|
||||
-c CONFIG_FILE, --config-path CONFIG_FILE
|
||||
Path to Config.ini to use for Plex Server info.
|
||||
[Default: ./config.ini]
|
||||
-s SCHEDULE_FILE, --schedule-path SCHEDULE_FILE
|
||||
Path to pre-roll schedule file (YAML) to be used.
|
||||
[Default: ./schedules.yaml]
|
||||
|
||||
Requirements:
|
||||
- See Requirements.txt for Python modules
|
||||
|
||||
Scheduling:
|
||||
Add to system scheduler such as:
|
||||
> crontab -e
|
||||
> 0 0 * * * python path/to/schedule_pre_roll.py >/dev/null 2>&1
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: [description]
|
||||
KeyError: [description]
|
||||
ConfigError: [description]
|
||||
FileNotFoundError: [description]
|
||||
"""
|
||||
import enum
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from argparse import ArgumentParser, Namespace
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, NamedTuple, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
import urllib3 # type: ignore
|
||||
import yaml
|
||||
from cerberus import Validator # type: ignore
|
||||
from cerberus.schema import SchemaError
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
from util import plexutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
script_filename = os.path.basename(sys.argv[0])
|
||||
script_name = os.path.splitext(script_filename)[0]
|
||||
script_dir = os.path.dirname(__file__)
|
||||
|
||||
|
||||
class ScheduleEntry(NamedTuple):
|
||||
type: str
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
path: str
|
||||
weight: int
|
||||
|
||||
|
||||
class ScheduleType(enum.Enum):
|
||||
default = "default"
|
||||
monthly = "monthly"
|
||||
weekly = "weekly"
|
||||
date_range = "date_range"
|
||||
misc = "misc"
|
||||
|
||||
|
||||
def schedule_types() -> list[str]:
|
||||
"""Return a list of Schedule Types
|
||||
|
||||
Returns:
|
||||
List[ScheduleType]: List of Schedule Types
|
||||
"""
|
||||
return [_enum.value for _enum in ScheduleType]
|
||||
|
||||
|
||||
def arguments() -> Namespace:
|
||||
"""Setup and Return command line arguments
|
||||
See https://docs.python.org/3/howto/argparse.html
|
||||
|
||||
Returns:
|
||||
argparse.Namespace: Namespace object
|
||||
"""
|
||||
description = "Automate scheduling of pre-roll intros for Plex"
|
||||
version = "0.12.4"
|
||||
|
||||
config_default = "./config.ini"
|
||||
log_config_default = "./logging.conf"
|
||||
schedule_default = "./schedules.yaml"
|
||||
parser = ArgumentParser(description=f"{description}")
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"%(prog)s {version}",
|
||||
help="show the version number and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-lc",
|
||||
"--logconfig-file",
|
||||
dest="log_config_file",
|
||||
action="store",
|
||||
default=log_config_default,
|
||||
help=f"Path to logging config file. [Default: {log_config_default}]",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--test-run",
|
||||
dest="do_test_run",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Perform a test run, display output but dont save",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config-file",
|
||||
dest="config_file",
|
||||
action="store",
|
||||
help=f"Path to Config.ini to use for Plex Server info. [Default: {config_default}]",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--schedule-file",
|
||||
dest="schedule_file",
|
||||
action="store",
|
||||
help=f"Path to pre-roll schedule file (YAML) to be use. [Default: {schedule_default}]",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def week_range(year: int, week_num: int) -> Tuple[datetime, datetime]:
|
||||
"""Return the starting/ending date range of a given year/week
|
||||
|
||||
Args:
|
||||
year (int): Year to calc range for
|
||||
week_num (int): Month of the year (1-12)
|
||||
|
||||
Returns:
|
||||
DateTime: Start date of the Year/Month
|
||||
DateTime: End date of the Year/Month
|
||||
"""
|
||||
start = datetime.strptime(f"{year}-W{int(week_num) - 1}-0", "%Y-W%W-%w").date()
|
||||
end = start + timedelta(days=6)
|
||||
|
||||
start = datetime.combine(start, datetime.min.time())
|
||||
end = datetime.combine(end, datetime.max.time())
|
||||
|
||||
return start, end
|
||||
|
||||
|
||||
def month_range(year: int, month_num: int) -> Tuple[datetime, datetime]:
|
||||
"""Return the starting/ending date range of a given year/month
|
||||
|
||||
Args:
|
||||
year (int): Year to calc range for
|
||||
month_num (int): Month of the year (1-12)
|
||||
|
||||
Returns:
|
||||
DateTime: Start date of the Year/Month
|
||||
DateTime: End date of the Year/Month
|
||||
"""
|
||||
start = date(year, month_num, 1)
|
||||
next_month = start.replace(day=28) + timedelta(days=4)
|
||||
end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
start = datetime.combine(start, datetime.min.time())
|
||||
end = datetime.combine(end, datetime.max.time())
|
||||
|
||||
return start, end
|
||||
|
||||
|
||||
def duration_seconds(start: Union[date, datetime], end: Union[date, datetime]) -> float:
|
||||
"""Return length of time between two date/datetime in seconds
|
||||
|
||||
Args:
|
||||
start (date/datetime): [description]
|
||||
end (date/datetime): [description]
|
||||
|
||||
Returns:
|
||||
float: Length in time seconds
|
||||
"""
|
||||
if not isinstance(start, datetime):
|
||||
start = datetime.combine(start, datetime.min.time())
|
||||
if not isinstance(end, datetime):
|
||||
end = datetime.combine(end, datetime.max.time())
|
||||
|
||||
delta = end - start
|
||||
|
||||
logger.debug(
|
||||
"duration_second[] Start: %s End: %s Duration: %s}", start, end, delta.total_seconds()
|
||||
)
|
||||
return delta.total_seconds()
|
||||
|
||||
|
||||
def make_datetime(value: Union[str, date, datetime], lowtime: bool = True) -> datetime:
|
||||
"""Returns a DateTime object with a calculated Time component if none provided
|
||||
converts:
|
||||
* Date to DateTime, with a Time of Midnight 00:00 or 11:59 pm
|
||||
* String to DateTime, with a Time as defined in the string
|
||||
|
||||
Args:
|
||||
value (Union[str, date, datetime]): Input value to convert to a DateTime object
|
||||
lowtime (bool, optional): Calculate time to be midnight (True) or 11:59 PM (False).
|
||||
Defaults to True.
|
||||
|
||||
Raises:
|
||||
TypeError: Unknown type to calculate
|
||||
|
||||
Returns:
|
||||
datetime: DateTime object with time component set if none provided
|
||||
"""
|
||||
today = date.today()
|
||||
now = datetime.now()
|
||||
dt_val = datetime(today.year, today.month, today.day, 0, 0, 0)
|
||||
|
||||
# append the low or high time of the day
|
||||
if lowtime:
|
||||
time = datetime.min.time()
|
||||
else:
|
||||
time = datetime.max.time()
|
||||
|
||||
# determine how to translate the input value
|
||||
if isinstance(value, datetime): # type: ignore
|
||||
dt_val = value
|
||||
elif isinstance(value, date): # type: ignore
|
||||
dt_val = datetime.combine(value, time)
|
||||
elif isinstance(value, str): # type: ignore
|
||||
try:
|
||||
# Expect format of DateType string to be (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)
|
||||
# allow 'xx' to denote 'every' similar to Cron "*"
|
||||
logger.debug('Translating string value="%s" to datetime (LowTime=%s)', value, lowtime)
|
||||
|
||||
# default to today and the time period (low/high)
|
||||
year, month, day = today.year, today.month, today.day
|
||||
hour, minute, second = time.hour, time.minute, time.second
|
||||
|
||||
# start parsing the timeout, for later additional processing
|
||||
date_parts = value.lower().split("-")
|
||||
|
||||
year = today.year if date_parts[0] == "xxxx" else int(date_parts[0])
|
||||
month = today.month if date_parts[1] == "xx" else int(date_parts[1])
|
||||
|
||||
date_parts_day = date_parts[2].split(" ")
|
||||
|
||||
day = today.day if date_parts_day[0] == "xx" else int(date_parts_day[0])
|
||||
|
||||
# attempt to parse out Time components
|
||||
if len(date_parts_day) > 1:
|
||||
time_parts = date_parts_day[1].split(":")
|
||||
if len(time_parts) > 1:
|
||||
hour = now.hour if time_parts[0] == "xx" else int(time_parts[0])
|
||||
minute = now.minute if time_parts[1] == "xx" else int(time_parts[1])
|
||||
second = now.second + 1 if time_parts[2] == "xx" else int(time_parts[2])
|
||||
|
||||
dt_val = datetime(year, month, day, hour, minute, second)
|
||||
logger.debug("Datetime-> '%s'", dt_val)
|
||||
except Exception as e:
|
||||
logger.error('Unable to parse date string "%s"', value, exc_info=e)
|
||||
raise
|
||||
else:
|
||||
msg = 'UnknownType: Unable to parse date string "%s" for type "%s"'
|
||||
logger.error(msg, value, type(value))
|
||||
raise TypeError(msg)
|
||||
|
||||
return dt_val
|
||||
|
||||
|
||||
def schedule_file_contents(schedule_filename: Optional[str]) -> dict[str, any]: # type: ignore
|
||||
"""Returns a contents of the provided YAML file and validates
|
||||
|
||||
Args:
|
||||
schedule_filename (string, optional]): schedule file to load, will use defaults if not specified
|
||||
|
||||
Raises:
|
||||
Validation Errors
|
||||
|
||||
Returns:
|
||||
YAML Contents: YAML structure of Dict[str, Any]
|
||||
"""
|
||||
default_files = ["schedules.yaml"]
|
||||
|
||||
filename = None
|
||||
if schedule_filename not in ("", None):
|
||||
if os.path.exists(str(schedule_filename)):
|
||||
filename = schedule_filename
|
||||
|
||||
logger.debug('using specified schedule file "%s"', filename)
|
||||
else:
|
||||
msg = f'Pre-roll Schedule file "{schedule_filename}" not found'
|
||||
logger.error(msg)
|
||||
raise FileNotFoundError(msg)
|
||||
else:
|
||||
for f in default_files:
|
||||
default_file = os.path.join(script_dir, f)
|
||||
if os.path.exists(default_file):
|
||||
filename = default_file
|
||||
logger.debug('using default schedule file "%s"', filename)
|
||||
break
|
||||
|
||||
# if we still cant find a schedule file, we abort
|
||||
if not filename:
|
||||
file_str = '" / "'.join(default_files)
|
||||
logger.error('Missing schedule file: "%s"', file_str)
|
||||
raise FileNotFoundError(file_str)
|
||||
|
||||
schema_filename = os.path.join(script_dir, "util/schedule_file_schema.json")
|
||||
|
||||
logger.debug('using schema validation file "%s"', schema_filename)
|
||||
|
||||
# make sure the Schema validation file is available
|
||||
if not os.path.exists(str(schema_filename)):
|
||||
msg = f'Pre-roll Schema Validation file "{schema_filename}" not found'
|
||||
logger.error(msg)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
# Open Schedule file
|
||||
try:
|
||||
with open(filename, "r", encoding="utf8") as file:
|
||||
contents = yaml.load(file, Loader=yaml.SafeLoader) # type: ignore
|
||||
except yaml.YAMLError as ye:
|
||||
logger.error("YAML Error: %s", filename, exc_info=ye)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=e)
|
||||
raise
|
||||
|
||||
# Validate the loaded YAML data against the required schema
|
||||
try:
|
||||
with open(schema_filename, "r", encoding="utf8") as schema_file:
|
||||
schema = json.loads(schema_file.read())
|
||||
|
||||
v = Validator(schema) # type: ignore
|
||||
except json.JSONDecodeError as je:
|
||||
logger.error("JSON Error: %s", schema_filename, exc_info=je)
|
||||
raise
|
||||
except SchemaError as se:
|
||||
logger.error("Schema Error %s", schema_filename, exc_info=se)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=e)
|
||||
raise
|
||||
|
||||
if not v.validate(contents): # type: ignore
|
||||
logger.error("Pre-roll Schedule YAML Validation Error: %s", v.errors) # type: ignore
|
||||
raise yaml.YAMLError(f"Pre-roll Schedule YAML Validation Error: {v.errors}") # type: ignore
|
||||
|
||||
return contents
|
||||
|
||||
|
||||
def prep_weekly_schedule(contents: dict[str, any]) -> List[ScheduleEntry]:
|
||||
"""
|
||||
Collect all weekly ScheduleEntries that are valid for the current datetime
|
||||
"""
|
||||
schedule_entries: List[ScheduleEntry] = []
|
||||
|
||||
if not contents.get("enabled", False):
|
||||
return schedule_entries
|
||||
|
||||
today = date.today()
|
||||
for i in range(1, 53):
|
||||
try:
|
||||
path = str(contents[i]) # type: ignore
|
||||
|
||||
if path:
|
||||
start, end = week_range(today.year, i)
|
||||
|
||||
entry = ScheduleEntry(
|
||||
type=ScheduleType.weekly.value,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
path=path,
|
||||
weight=contents.get("weight", 1),
|
||||
)
|
||||
|
||||
schedule_entries.append(entry)
|
||||
except KeyError:
|
||||
# skip KeyError for missing Weeks
|
||||
pass
|
||||
|
||||
return schedule_entries
|
||||
|
||||
|
||||
def prep_monthly_schedule(contents: dict[str, any]) -> List[ScheduleEntry]:
|
||||
"""
|
||||
Collect all monthly ScheduleEntries that are valid for the current datetime
|
||||
"""
|
||||
schedule_entries: List[ScheduleEntry] = []
|
||||
|
||||
if not contents.get("enabled", False):
|
||||
return schedule_entries
|
||||
|
||||
# Get the entry for the current month
|
||||
today = date.today()
|
||||
today_month_abbrev = date(today.year, today.month, 1).strftime("%b").lower()
|
||||
path = contents.get(today_month_abbrev, None)
|
||||
if not path:
|
||||
return schedule_entries
|
||||
|
||||
start, end = month_range(today.year, today.month)
|
||||
|
||||
logger.debug(f'Parsing paths for current month: {today_month_abbrev}')
|
||||
|
||||
entry = ScheduleEntry(
|
||||
type=ScheduleType.monthly.value,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
path=path,
|
||||
weight=contents.get("weight", 1),
|
||||
)
|
||||
|
||||
schedule_entries.append(entry)
|
||||
|
||||
return schedule_entries
|
||||
|
||||
|
||||
def prep_date_range_schedule(contents: dict[str, any]) -> List[ScheduleEntry]:
|
||||
"""
|
||||
Collect all date_range ScheduleEntries that are valid for the current datetime
|
||||
"""
|
||||
schedule_entries: List[ScheduleEntry] = []
|
||||
|
||||
if not contents.get("enabled", False):
|
||||
return schedule_entries
|
||||
|
||||
for _range in contents.get('ranges', []):
|
||||
path = _range.get("path", None)
|
||||
if not path:
|
||||
logger.error(f'Missing "path" entry in date_range: {_range}')
|
||||
continue
|
||||
|
||||
start = make_datetime(_range["start_date"], lowtime=True) # type: ignore
|
||||
end = make_datetime(_range["end_date"], lowtime=False) # type: ignore
|
||||
|
||||
# Skip if the current date is not within the range
|
||||
now = datetime.now()
|
||||
if start > now or end < now:
|
||||
logger.debug(f'Skipping date_range out of range: {_range}')
|
||||
continue
|
||||
|
||||
entry = ScheduleEntry(
|
||||
type=ScheduleType.date_range.value,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
path=path,
|
||||
weight=_range.get("weight", 1),
|
||||
)
|
||||
|
||||
schedule_entries.append(entry)
|
||||
|
||||
return schedule_entries
|
||||
|
||||
|
||||
def prep_misc_schedule(contents: dict[str, any]) -> List[ScheduleEntry]:
|
||||
"""
|
||||
Collect all misc ScheduleEntries
|
||||
"""
|
||||
schedule_entries: List[ScheduleEntry] = []
|
||||
|
||||
if not contents.get("enabled", False):
|
||||
return schedule_entries
|
||||
|
||||
today = date.today()
|
||||
path = contents.get("always_use", None)
|
||||
if not path:
|
||||
return schedule_entries
|
||||
|
||||
logger.debug(f'Parsing "misc" selections: {path}')
|
||||
|
||||
random_count = contents.get("random_count", -1)
|
||||
if random_count > -1:
|
||||
path_items = path.split(";")
|
||||
path_items = random.sample(population=path_items, k=random_count)
|
||||
path = ";".join(path_items)
|
||||
|
||||
entry = ScheduleEntry(
|
||||
type=ScheduleType.misc.value,
|
||||
start_date=datetime(today.year, today.month, today.day, 0, 0, 0),
|
||||
end_date=datetime(today.year, today.month, today.day, 23, 59, 59),
|
||||
path=path,
|
||||
weight=contents.get("weight", 1),
|
||||
)
|
||||
schedule_entries.append(entry)
|
||||
|
||||
return schedule_entries
|
||||
|
||||
|
||||
def prep_default_schedule(contents: dict[str, any]) -> List[ScheduleEntry]:
|
||||
"""
|
||||
Collect all default ScheduleEntries
|
||||
"""
|
||||
schedule_entries: List[ScheduleEntry] = []
|
||||
|
||||
if not contents.get("enabled", False):
|
||||
return schedule_entries
|
||||
|
||||
today = date.today()
|
||||
path = contents.get("path", None)
|
||||
if not path:
|
||||
return schedule_entries
|
||||
|
||||
logger.debug(f'Parsing "default" selections: {path}')
|
||||
|
||||
entry = ScheduleEntry(
|
||||
type=ScheduleType.default.value,
|
||||
start_date=datetime(today.year, today.month, today.day, 0, 0, 0),
|
||||
end_date=datetime(today.year, today.month, today.day, 23, 59, 59),
|
||||
path=path,
|
||||
weight=contents.get("weight", 1),
|
||||
)
|
||||
schedule_entries.append(entry)
|
||||
|
||||
return schedule_entries
|
||||
|
||||
|
||||
def pre_roll_schedule(schedule_file: Optional[str] = None) -> List[ScheduleEntry]:
|
||||
"""Return a listing of defined pre_roll schedules for searching/use
|
||||
|
||||
Args:
|
||||
schedule_file (str): path/to/schedule_pre_roll.yaml style config file (YAML Format)
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If no schedule config file exists
|
||||
|
||||
Returns:
|
||||
list: list of ScheduleEntries
|
||||
"""
|
||||
|
||||
contents = schedule_file_contents(schedule_file) # type: ignore
|
||||
|
||||
schedule_entries: List[ScheduleEntry] = []
|
||||
|
||||
for schedule_type in schedule_types():
|
||||
|
||||
section_contents = contents.get(schedule_type, None)
|
||||
if not section_contents:
|
||||
logger.info('"%s" section not included in schedule file; skipping', schedule_type)
|
||||
# continue to other sections
|
||||
continue
|
||||
|
||||
# Write a switch statement to handle each schedule type
|
||||
match schedule_type:
|
||||
case ScheduleType.weekly.value:
|
||||
weekly_schedule_entries = prep_weekly_schedule(section_contents)
|
||||
schedule_entries.extend(weekly_schedule_entries)
|
||||
|
||||
case ScheduleType.monthly.value:
|
||||
monthly_schedule_entries = prep_monthly_schedule(section_contents)
|
||||
schedule_entries.extend(monthly_schedule_entries)
|
||||
|
||||
case ScheduleType.date_range.value:
|
||||
date_range_schedule_entries = prep_date_range_schedule(section_contents)
|
||||
schedule_entries.extend(date_range_schedule_entries)
|
||||
|
||||
case ScheduleType.misc.value:
|
||||
misc_schedule_entries = prep_misc_schedule(section_contents)
|
||||
schedule_entries.extend(misc_schedule_entries)
|
||||
|
||||
case ScheduleType.default.value:
|
||||
default_schedule_entries = prep_default_schedule(section_contents)
|
||||
schedule_entries.extend(default_schedule_entries)
|
||||
|
||||
case _:
|
||||
logger.error('Unknown schedule_type "%s" detected', schedule_type)
|
||||
|
||||
# Sort list so most recent Ranges appear first
|
||||
schedule_entries.sort(reverse=True, key=lambda x: x.start_date)
|
||||
|
||||
logger.debug("***START Schedule Set to be used***")
|
||||
logger.debug(schedule_entries)
|
||||
logger.debug("***END Schedule Set to be used***")
|
||||
|
||||
return schedule_entries
|
||||
|
||||
|
||||
def build_listing_string(items: List[str], play_all: bool = False) -> str:
|
||||
"""Build the Plex formatted string of pre_roll paths
|
||||
|
||||
Args:
|
||||
items (list): List of pre_roll video paths to place into a string listing
|
||||
play_all (bool, optional): Play all videos. [Default: False (Random choice)]
|
||||
|
||||
Returns:
|
||||
string: CSV Listing (, or ;) based on play_all param of pre_roll video paths
|
||||
"""
|
||||
|
||||
if not items:
|
||||
return ""
|
||||
|
||||
if play_all:
|
||||
# use , to play all entries
|
||||
return ",".join(items)
|
||||
|
||||
return ";".join(items)
|
||||
|
||||
|
||||
def pre_roll_listing(schedule_entries: List[ScheduleEntry], for_datetime: Optional[datetime] = None) -> str:
|
||||
"""Return listing of pre_roll videos to be used by Plex
|
||||
|
||||
Args:
|
||||
schedule_entries (List[ScheduleEntry]): List of schedule entries (See: getPrerollSchedule)
|
||||
for_datetime (datetime, optional): Date to process pre-roll string for [Default: Today]
|
||||
Useful for simulating what different dates produce
|
||||
|
||||
Returns:
|
||||
string: listing of pre_roll video paths to be used for Extras. CSV style: (;|,)
|
||||
"""
|
||||
entries: list[str] = []
|
||||
default_entry_needed = True
|
||||
|
||||
_schedule_types = schedule_types()
|
||||
|
||||
# determine which date to build the listing for
|
||||
if for_datetime:
|
||||
if isinstance(for_datetime, datetime): # type: ignore
|
||||
check_datetime = for_datetime
|
||||
else:
|
||||
check_datetime = datetime.combine(for_datetime, datetime.now().time())
|
||||
else:
|
||||
check_datetime = datetime.now()
|
||||
|
||||
# process the schedule for the given date
|
||||
for entry in schedule_entries:
|
||||
entry_start = entry.start_date
|
||||
if not isinstance(entry_start, datetime): # type: ignore
|
||||
entry_start = datetime.combine(entry_start, datetime.min.time())
|
||||
|
||||
entry_end = entry.end_date
|
||||
if not isinstance(entry_end, datetime): # type: ignore
|
||||
entry_end = datetime.combine(entry_end, datetime.max.time())
|
||||
|
||||
logger.debug(
|
||||
'checking "%s" against: "%s" - "%s"', check_datetime, entry_start, entry_end
|
||||
)
|
||||
|
||||
# If current schedule entry is not valid for the current date, skip it
|
||||
# This shouldn't be needed, as ScheduleEntries are only added up to this point if valid for the current datetime
|
||||
if entry_start > check_datetime or entry_end < check_datetime:
|
||||
continue
|
||||
|
||||
if entry.type != ScheduleType.default.value: # Non-default entry, so don't need to add default entry
|
||||
default_entry_needed = False
|
||||
for _ in range(entry.weight): # Add entry to list multiple times based on weight
|
||||
entries.append(entry.path)
|
||||
else: # Default entry, only add if no other entries have been added
|
||||
if default_entry_needed:
|
||||
entries.append(entry.path) # Default will only be added once (no weight)
|
||||
|
||||
listing = build_listing_string(items=entries)
|
||||
|
||||
return listing
|
||||
|
||||
|
||||
def save_pre_roll_listing(plex: PlexServer, pre_roll_listing: Union[str, List[str]]) -> None:
|
||||
"""Save Plex Preroll info to PlexServer settings
|
||||
|
||||
Args:
|
||||
plex (PlexServer): Plex server to update
|
||||
pre_roll_listing (str, list[str]): csv listing or List of pre_roll paths to save
|
||||
"""
|
||||
# if happened to send in an Iterable List, merge to a string
|
||||
if isinstance(pre_roll_listing, list):
|
||||
pre_roll_listing = build_listing_string(list(pre_roll_listing))
|
||||
|
||||
logger.debug('Attempting save of pre-rolls: "%s"', pre_roll_listing)
|
||||
|
||||
plex.settings.get("cinemaTrailersPrerollID").set(pre_roll_listing) # type: ignore
|
||||
try:
|
||||
plex.settings.save() # type: ignore
|
||||
except Exception as e:
|
||||
logger.error('Unable to save Pre-Rolls to Server: "%s"', plex.friendlyName, exc_info=e) # type: ignore
|
||||
raise e
|
||||
|
||||
logger.info('Saved Pre-Rolls: Server: "%s" Pre-Rolls: "%s"', plex.friendlyName, pre_roll_listing) # type: ignore
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = arguments()
|
||||
|
||||
plexutil.init_logger(args.log_config_file)
|
||||
|
||||
cfg = plexutil.plex_config(args.config_file)
|
||||
|
||||
# Initialize Session information
|
||||
sess = requests.Session()
|
||||
# Ignore verifying the SSL certificate
|
||||
sess.verify = False # '/path/to/certfile'
|
||||
# If verify is set to a path of a directory (not a cert file),
|
||||
# the directory needs to be processed with the c_rehash utility
|
||||
# from OpenSSL.
|
||||
if sess.verify is False:
|
||||
# Disable the warning that the request is insecure, we know that...
|
||||
# import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # type: ignore
|
||||
|
||||
try:
|
||||
plex = PlexServer(cfg["PLEX_URL"], cfg["PLEX_TOKEN"], session=sess)
|
||||
except Exception as e:
|
||||
logger.error("Error connecting to Plex", exc_info=e)
|
||||
raise e
|
||||
|
||||
schedule_entries = pre_roll_schedule(args.schedule_file)
|
||||
pre_rolls = pre_roll_listing(schedule_entries=schedule_entries)
|
||||
|
||||
if args.do_test_run:
|
||||
msg = f"Test Run of Plex Pre-Rolls: **Nothing being saved**\n{pre_rolls}\n"
|
||||
logger.debug(msg)
|
||||
print(msg)
|
||||
else:
|
||||
save_pre_roll_listing(plex, pre_rolls)
|
@ -1,69 +0,0 @@
|
||||
# All keys must be in lowercase
|
||||
# All paths will be case-sensitive based on your environment (Linux, Windows)
|
||||
|
||||
# Order of precedence:
|
||||
# 1. Misc always_use
|
||||
# 2. Date range
|
||||
# 3. Weekly
|
||||
# 4. Monthly
|
||||
# 5. Default
|
||||
|
||||
# Additional items for preroll selection processing
|
||||
misc:
|
||||
enabled: true
|
||||
always_use: /path/to/video.mp4 # If enabled, always include these video files in pre-role listing
|
||||
random_count: 5 # If provided, randomly select this many videos from the list rather than all of them
|
||||
|
||||
# Schedule prerolls by date and time frames
|
||||
date_range:
|
||||
enabled: false
|
||||
ranges:
|
||||
# Each entry requires start_date, end_date, path values
|
||||
- start_date: 2020-01-01 # Jan 1st, 2020
|
||||
end_date: 2020-01-02 # Jan 2nd, 2020
|
||||
path: /path/to/video.mp4
|
||||
weight: 2 # Add these paths to the list twice (make up greater percentage of prerolls - more likely to be selected)
|
||||
- start_date: xxxx-07-04 # Every year on July 4th
|
||||
end_date: xxxx-07-04 # Every year on July 4th
|
||||
path: /path/to/video.mp4
|
||||
weight: 1
|
||||
- start_date: xxxx-xx-02 # Every year on the 2nd of every month
|
||||
end_date: xxxx-xx-03 # Every year on the 3rd of every month
|
||||
path: /path/to/video.mp4
|
||||
weight: 1
|
||||
- start_date: xxxx-xx-xx 08:00:00 # Every day at 8am
|
||||
end_date: xxxx-xx-xx 09:30:00 # Every day at 9:30am
|
||||
path: /path/to/holiday_video.mp4
|
||||
weight: 1
|
||||
|
||||
# Schedule prerolls by week of the year
|
||||
weekly:
|
||||
# No need to have all the week numbers; the script skips over missing entries
|
||||
# Each week number must be surrounded by quotes
|
||||
enabled: false
|
||||
"1":
|
||||
"2": /path/to/video.mkv
|
||||
"52":
|
||||
|
||||
# Schedule prerolls by month of the year
|
||||
monthly:
|
||||
# No need to have all the months here, script will skip over missing entries (logs a warning)
|
||||
# Keys must be lowercase, three-char month abbreviation
|
||||
enabled: false
|
||||
jan: /path/to/video.mp4
|
||||
feb:
|
||||
mar:
|
||||
apr:
|
||||
may: /path/to/video.mp4
|
||||
jun:
|
||||
jul:
|
||||
aug:
|
||||
sep: /path/to/video.mp4;/path/to/video.mp4
|
||||
oct:
|
||||
nov:
|
||||
dec:
|
||||
|
||||
# Use a default preroll if no other schedule is matched
|
||||
default:
|
||||
enabled: true
|
||||
path: /path/to/video.mp4
|
74
templates/plex_prerolls.xml
Normal file
74
templates/plex_prerolls.xml
Normal file
@ -0,0 +1,74 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>plex_prerolls</Name>
|
||||
<Repository>nwithan8/plex_prerolls:latest</Repository>
|
||||
<Registry>https://hub.docker.com/r/nwithan8/plex_prerolls</Registry>
|
||||
<Branch>
|
||||
<Tag>latest</Tag>
|
||||
<TagDescription>Latest stable release</TagDescription>
|
||||
</Branch>
|
||||
<Network>host</Network>
|
||||
<Shell>bash</Shell>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://forums.unraid.net/topic/133764-support-grtgbln-docker-templates</Support>
|
||||
<Project>https://github.com/nwithan8/tauticord</Project>
|
||||
<Overview>Tauticord is a Discord bot that displays live data from Tautulli, including stream summaries, bandwidth and library statistics.</Overview>
|
||||
<Category>Tools: MediaServer Status:Stable</Category>
|
||||
<Icon>https://raw.githubusercontent.com/nwithan8/tauticord/master/logo.png</Icon>
|
||||
<TemplateURL>https://raw.githubusercontent.com/nwithan8/unraid_templates/main/templates/tauticord.xml</TemplateURL>
|
||||
<Maintainer>
|
||||
<WebPage>https://github.com/nwithan8</WebPage>
|
||||
</Maintainer>
|
||||
<Config Name="Discord bot token" Target="TC_DISCORD_BOT_TOKEN" Default="" Description="Discord bot token" Type="Variable" Display="always" Required="true" Mask="true" />
|
||||
<Config Name="Discord server ID" Target="TC_DISCORD_SERVER_ID" Default="" Description="Discord server ID" Type="Variable" Display="always" Required="true" Mask="false" />
|
||||
<Config Name="Discord Nitro" Target="TC_DISCORD_NITRO" Default="" Description="Discord Nitro subscriber" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="URL to Tautulli" Target="TC_TAUTULLI_URL" Default="http://localhost:8181" Description="URL to Tautulli" Type="Variable" Display="always" Required="true" Mask="false">http://localhost:8181</Config>
|
||||
<Config Name="Tautulli API key" Target="TC_TAUTULLI_KEY" Default="" Description="Tautulli API key" Type="Variable" Display="always" Required="true" Mask="true" />
|
||||
<Config Name="Use self-signed SSL certificate" Target="TC_USE_SELF_SIGNED_CERT" Default="False" Description="Disable SSL verification (if using self-signed cert on server)" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Seconds between stream updates" Target="TC_REFRESH_SECONDS" Default="15" Description="Seconds between stream updates (15-second minimum)" Type="Variable" Display="always" Required="false" Mask="false">15</Config>
|
||||
<Config Name="Plex Media Server name" Target="TC_SERVER_NAME" Default="Plex" Description="Name of the Plex Media Server. If not provided, will use 'Plex'. If provided string is empty, will extract Plex Media Server name from Tautulli." Type="Variable" Display="always" Required="false" Mask="false">Plex</Config>
|
||||
<Config Name="Stream kill message" Target="TC_TERMINATE_MESSAGE" Default="Your stream has ended." Description="Message to send on stream kill" Type="Variable" Display="always" Required="false" Mask="false">Your stream has ended.</Config>
|
||||
<Config Name="Use 24-hour time" Target="TC_USE_24_HOUR_TIME" Default="False" Description="Whether to use 24-hour time" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Hide stream usernames" Target="TC_HIDE_USERNAMES" Default="False" Description="Whether to hide usernames in the streams view" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Hide stream player names" Target="TC_HIDE_PLAYER_NAMES" Default="False" Description="Whether to hide player names in the streams view" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Hide stream platforms" Target="TC_HIDE_PLATFORMS" Default="False" Description="Whether to hide platforms in the streams view" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Hide stream quality profiles" Target="TC_HIDE_QUALITY" Default="False" Description="Whether to hide quality profiles in the streams view" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Hide stream bandwidth" Target="TC_HIDE_BANDWIDTH" Default="False" Description="Whether to hide bandwidth in the streams view" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Hide stream transcoding statuses" Target="TC_HIDE_TRANSCODE" Default="False" Description="Whether to hide transcoding statuses in the streams view" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Hide stream progress" Target="TC_HIDE_PROGRESS" Default="False" Description="Whether to hide stream progress in the streams view" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Hide stream ETAs" Target="TC_HIDE_ETA" Default="False" Description="Whether to hide stream ETAs in the streams view" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Use friendly names" Target="TC_USE_FRIENDLY_NAMES" Default="False" Description="Use friendly names instead of usernames if available" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Thousands separator" Target="TC_THOUSANDS_SEPARATOR" Default="," Description="Symbol to separate thousands in numbers" Type="Variable" Display="always" Required="false" Mask="false">,</Config>
|
||||
<Config Name="Stream stats category name" Target="TC_VC_STATS_CATEGORY_NAME" Default="Tautulli Stats" Description="Name of the stream stats voice channel category" Type="Variable" Display="always" Required="false" Mask="false">Tautulli Stats</Config>
|
||||
<Config Name="Display stream count" Target="TC_VC_STREAM_COUNT" Default="False" Description="Whether to display stream count in voice channels" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Stream count voice channel ID" Target="TC_VC_STREAM_COUNT_CHANNEL_ID" Default="0" Description="Optional ID of the Discord voice channel to display stream count" Type="Variable" Display="always" Required="false" Mask="false">0</Config>
|
||||
<Config Name="Display transcode count" Target="TC_VC_TRANSCODE_COUNT" Default="False" Description="Whether to display transcode count in voice channels" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Transcode count voice channel ID" Target="TC_VC_TRANSCODE_COUNT_CHANNEL_ID" Default="0" Description="Optional ID of the Discord voice channel to display transcode count" Type="Variable" Display="always" Required="false" Mask="false">0</Config>
|
||||
<Config Name="Display bandwidth" Target="TC_VC_BANDWIDTH" Default="False" Description="Whether to display bandwidth in voice channels" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Bandwidth voice channel ID" Target="TC_VC_BANDWIDTH_CHANNEL_ID" Default="0" Description="Optional ID of the Discord voice channel to display bandwidth" Type="Variable" Display="always" Required="false" Mask="false">0</Config>
|
||||
<Config Name="Display local bandwidth" Target="TC_VC_LOCAL_BANDWIDTH" Default="False" Description="Whether to display local bandwidth in voice channels" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Local bandwidth voice channel ID" Target="TC_VC_LOCAL_BANDWIDTH_CHANNEL_ID" Default="0" Description="Optional ID of the Discord voice channel to display local bandwidth" Type="Variable" Display="always" Required="false" Mask="false">0</Config>
|
||||
<Config Name="Display remote bandwidth" Target="TC_VC_REMOTE_BANDWIDTH" Default="False" Description="Whether to display remote bandwidth in voice channels" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Remote bandwidth voice channel ID" Target="TC_VC_REMOTE_BANDWIDTH_CHANNEL_ID" Default="0" Description="Optional ID of the Discord voice channel to display remote bandwidth" Type="Variable" Display="always" Required="false" Mask="false">0</Config>
|
||||
<Config Name="Display Plex server status" Target="TC_VC_PLEX_STATUS" Default="False" Description="Whether to display Plex online status in a voice channel" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Plex status voice channel ID" Target="TC_VC_PLEX_STATUS_CHANNEL_ID" Default="0" Description="Optional ID of the Discord voice channel to display Plex online status" Type="Variable" Display="always" Required="false" Mask="false">0</Config>
|
||||
<Config Name="Display library stats" Target="TC_VC_LIBRARY_STATS" Default="False" Description="Whether to display library stats in voice channels" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Library stats category name" Target="TC_VC_LIBRARIES_CATEGORY_NAME" Default="Tautulli Libraries" Description="Name of the library stats voice channel category" Type="Variable" Display="always" Required="false" Mask="false">Tautulli Libraries</Config>
|
||||
<Config Name="Stat library names" Target="TC_VC_LIBRARY_NAMES" Default="" Description="Comma-separated list of names of libraries to display stats of" Type="Variable" Display="always" Required="false" Mask="false" />
|
||||
<Config Name="Seconds between library updates" Target="TC_VC_LIBRARY_REFRESH_SECONDS" Default="3600" Description="Seconds between library stats updates (5-minute minimum)" Type="Variable" Display="always" Required="false" Mask="false">3600</Config>
|
||||
<Config Name="Use emojis for library stats" Target="TC_VC_LIBRARY_USE_EMOJIS" Default="True" Description="Symbolize type of each library using emojis" Type="Variable" Display="always" Required="false" Mask="false">True</Config>
|
||||
<Config Name="Display TV series counts" Target="TC_VC_TV_SERIES_COUNT" Default="True" Description="Display series counts for all configured TV Show libraries" Type="Variable" Display="always" Required="false" Mask="false">True</Config>
|
||||
<Config Name="Display TV episode counts" Target="TC_VC_TV_EPISODE_COUNT" Default="True" Description="Display episode counts for all configured TV Show libraries" Type="Variable" Display="always" Required="false" Mask="false">True</Config>
|
||||
<Config Name="Display music artist counts" Target="TC_VC_MUSIC_ARTIST_COUNT" Default="True" Description="Display artist counts for all configured Music libraries" Type="Variable" Display="always" Required="false" Mask="false">True</Config>
|
||||
<Config Name="Display music track counts" Target="TC_VC_MUSIC_TRACK_COUNT" Default="True" Description="Display track counts for all configured Music libraries" Type="Variable" Display="always" Required="false" Mask="false">True</Config>
|
||||
<Config Name="Discord admin IDs" Target="TC_DISCORD_ADMIN_IDS" Default="" Description="Comma-separated list of IDs of Discord users with bot admin privileges" Type="Variable" Display="always" Required="false" Mask="false" />
|
||||
<Config Name="Post stream details" Target="TC_DISCORD_POST_SUMMARY_MESSAGE" Default="True" Description="Whether to post a stream details summary text message" Type="Variable" Display="always" Required="true" Mask="false">True</Config>
|
||||
<Config Name="Stream details text channel" Target="TC_DISCORD_CHANNEL_NAME" Default="tauticord" Description="Name of Discord text channel where the bot will post the stream details summary message" Type="Variable" Display="always" Required="true" Mask="false">tauticord</Config>
|
||||
<Config Name="Allow analytics" Target="TC_ALLOW_ANALYTICS" Default="True" Description="Whether to allow anonymous analytics collection" Type="Variable" Display="always" Required="false" Mask="false">True</Config>
|
||||
<Config Name="Performance stats category name" Target="TC_VC_PERFORMANCE_CATEGORY_NAME" Default="Performance" Description="Name of the performance stats voice channel category" Type="Variable" Display="always" Required="false" Mask="false">Performance</Config>
|
||||
<Config Name="Monitor CPU performance" Target="TC_MONITOR_CPU" Default="False" Description="Whether to monitor Tauticord Docker CPU performance" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Monitor memory performance" Target="TC_MONITOR_MEMORY" Default="False" Description="Whether to monitor Tauticord Docker memory performance" Type="Variable" Display="always" Required="false" Mask="false">False</Config>
|
||||
<Config Name="Timezone" Target="TZ" Default="UTC" Description="Timezone for the server" Type="Variable" Display="always" Required="false" Mask="false">UTC</Config>
|
||||
<Config Name="Config Path" Target="/config" Default="/mnt/user/appdata/tauticord/config" Mode="rw" Description="Where optional config file will be stored" Type="Path" Display="advanced" Required="true" Mask="false">/mnt/user/appdata/tauticord/config</Config>
|
||||
<Config Name="Log Path" Target="/logs" Default="/mnt/user/appdata/tauticord/logs" Mode="rw" Description="Where debug logs will be stored" Type="Path" Display="advanced" Required="true" Mask="false">/mnt/user/appdata/tauticord/logs</Config>
|
||||
</Container>
|
171
util/plexutil.py
171
util/plexutil.py
@ -1,171 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
"""Plex config parsing utilities
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: [description]
|
||||
KeyError: [description]
|
||||
"""
|
||||
import logging
|
||||
import logging.config
|
||||
import os
|
||||
import sys
|
||||
from configparser import ConfigParser
|
||||
from typing import Dict, Optional
|
||||
|
||||
from plexapi.server import CONFIG # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
filename = os.path.basename(sys.argv[0])
|
||||
SCRIPT_NAME = os.path.splitext(filename)[0]
|
||||
|
||||
|
||||
def plex_config(config_file: Optional[str] = "") -> Dict[str, str]:
|
||||
"""Return Plex Config parameters for connection info {PLEX_URL, PLEX_TOKEN}\n
|
||||
Attempts to use one of either:\n
|
||||
* supplier path/to/config file (INI Format)
|
||||
* local config.ini (primary)
|
||||
* PlexAPI system config.ini (secondary)
|
||||
|
||||
Args:
|
||||
config_file (str): path/to/config.ini style config file (INI Format)
|
||||
|
||||
Raises:
|
||||
KeyError: Config Params not found in config file(s)
|
||||
FileNotFoundError: Cannot find a config file
|
||||
|
||||
Returns:
|
||||
dict: Dict of config params {PLEX_URL, PLEX_TOKEN}
|
||||
"""
|
||||
|
||||
cfg: dict[str, str] = {}
|
||||
plex_url = ""
|
||||
plex_token = ""
|
||||
filename = ""
|
||||
use_local_config = False
|
||||
use_plexapi_config = False
|
||||
|
||||
# Look for a local Config.ini file, use settings if present
|
||||
local_config = ConfigParser()
|
||||
|
||||
if config_file == None or config_file == "":
|
||||
filename = "config.ini"
|
||||
else:
|
||||
filename = str(config_file)
|
||||
|
||||
# try reading a local file
|
||||
local_config.read(filename)
|
||||
|
||||
if len(local_config.sections()) > 0: # len(found_config) > 0:
|
||||
# if local config.ini file found, try to use local first
|
||||
if local_config.has_section("auth"):
|
||||
try:
|
||||
server = local_config["auth"]
|
||||
plex_url = server["server_baseurl"]
|
||||
plex_token = server["server_token"]
|
||||
|
||||
if len(plex_url) > 1 and len(plex_token) > 1:
|
||||
use_local_config = True
|
||||
except KeyError as e:
|
||||
logger.error("Key Value not found", exc_info=e)
|
||||
raise e
|
||||
else:
|
||||
msg = "[auth] section not found in LOCAL config.ini file"
|
||||
logger.error(msg)
|
||||
raise KeyError(msg)
|
||||
|
||||
if not use_local_config and len(CONFIG.sections()) > 0: # type: ignore
|
||||
# use PlexAPI Default ~/.config/plexapi/config.ini OR from PLEXAPI_CONFIG_PATH
|
||||
# IF not manually set locally in local Config.ini above
|
||||
# See https://python-plexapi.readthedocs.io/en/latest/configuration.html
|
||||
if CONFIG.has_section("auth"): # type: ignore
|
||||
try:
|
||||
server = CONFIG.data["auth"] # type: ignore
|
||||
plex_url: str = server.get("server_baseurl") # type: ignore
|
||||
plex_token: str = server.get("server_token") # type: ignore
|
||||
|
||||
if len(plex_url) > 1 and len(plex_token) > 1:
|
||||
use_plexapi_config = True
|
||||
except KeyError as e:
|
||||
logger.error("Key Value not found", exc_info=e)
|
||||
raise e
|
||||
else:
|
||||
msg = "[auth] section not found in PlexAPI MAIN config.ini file"
|
||||
logger.error(msg)
|
||||
raise KeyError(msg)
|
||||
|
||||
if not use_local_config and not use_plexapi_config:
|
||||
msg = "ConfigFile Error: No Plex config information found [server_baseurl, server_token]"
|
||||
logger.error(msg)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
cfg["PLEX_URL"] = plex_url
|
||||
cfg["PLEX_TOKEN"] = plex_token
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
def init_logger(log_config: str) -> None:
|
||||
"""load and configure a program logger using a supplier logging configuration file \n
|
||||
if possible the program will attempt to create log folders if not already existing
|
||||
|
||||
Args:
|
||||
log_config (str): path/to/logging.(conf|ini) style config file (INI Format)
|
||||
|
||||
Raises:
|
||||
KeyError: Problems processing logging config files
|
||||
FileNotFoundError: Problems with log file location, other
|
||||
"""
|
||||
|
||||
if os.path.exists(log_config):
|
||||
try:
|
||||
logging.config.fileConfig(log_config, disable_existing_loggers=False)
|
||||
except FileNotFoundError as e_fnf:
|
||||
# Assume this is related to a missing Log Folder
|
||||
# Try to create
|
||||
if e_fnf.filename and e_fnf.filename[-3:] == "log":
|
||||
logfile = e_fnf.filename
|
||||
logdir = os.path.dirname(logfile)
|
||||
|
||||
if not os.path.exists(logdir):
|
||||
try:
|
||||
logger.debug('Creating log folder "%s"', logdir)
|
||||
os.makedirs(logdir, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error('Error creating log folder "%s"', logdir, exc_info=e)
|
||||
raise e
|
||||
elif logger.handlers:
|
||||
# if logger config loaded, but some file error happened
|
||||
for h in logger.handlers:
|
||||
if isinstance(h, logging.FileHandler):
|
||||
logfile = h.baseFilename
|
||||
logdir = os.path.dirname(logfile)
|
||||
|
||||
if not os.path.exists(logdir):
|
||||
try:
|
||||
logger.debug('Creating log folder "%s"', logdir)
|
||||
|
||||
os.makedirs(logdir, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error('Error creating log folder "%s"', logdir, exc_info=e)
|
||||
raise e
|
||||
else:
|
||||
# not sure the issue, raise the exception
|
||||
raise e_fnf
|
||||
|
||||
# Assuming one of the create Log Folder worked, try again
|
||||
logging.config.fileConfig(log_config, disable_existing_loggers=False)
|
||||
|
||||
else:
|
||||
logger.debug('Logging Config file "%s" not available, will be using defaults', log_config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
msg = (
|
||||
"Script not meant to be run directly, please import into other scripts.\n\n"
|
||||
+ f"usage:\nimport {SCRIPT_NAME}"
|
||||
+ "\n"
|
||||
+ f"cfg = {SCRIPT_NAME}.getPlexConfig()"
|
||||
+ "\n"
|
||||
)
|
||||
logger.error(msg)
|
@ -1,419 +0,0 @@
|
||||
{
|
||||
"monthly": {
|
||||
"required": false,
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"enabled": {
|
||||
"required": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"jan": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"feb": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"mar": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"apr": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"may": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"jun": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"jul": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"aug": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sep": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"oct": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"nov": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"dec": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"weekly": {
|
||||
"required": false,
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"enabled": {
|
||||
"required": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"1": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"2": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"3": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"4": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"5": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"6": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"7": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"8": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"9": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"10": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"11": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"12": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"13": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"14": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"15": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"16": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"17": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"18": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"19": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"20": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"21": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"22": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"23": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"24": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"25": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"26": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"27": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"28": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"29": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"30": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"31": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"32": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"33": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"34": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"35": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"36": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"37": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"38": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"39": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"40": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"41": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"42": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"43": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"44": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"45": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"46": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"47": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"48": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"49": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"50": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"51": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"52": {
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"date_range": {
|
||||
"required": false,
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"enabled": {
|
||||
"required": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"ranges": {
|
||||
"required": true,
|
||||
"type": "list",
|
||||
"schema": {
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"start_date": {
|
||||
"required": true,
|
||||
"type": [
|
||||
"string",
|
||||
"date"
|
||||
]
|
||||
},
|
||||
"end_date": {
|
||||
"required": true,
|
||||
"type": [
|
||||
"string",
|
||||
"date"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"required": false,
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"enabled": {
|
||||
"required": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"always_use": {
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"random_count": {
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"required": false,
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"enabled": {
|
||||
"required": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user