- 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:
nwithan8 2023-12-08 17:50:12 -07:00
parent 7a8f97bb96
commit 1616d91ebe
25 changed files with 1232 additions and 1500 deletions

View File

@ -1,5 +0,0 @@
[MASTER]
disable=
C0114, # missing-module-docstring
C0103, # scake_case
W0621, # redefine variable from outer scope

View File

@ -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

View File

@ -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.)

View File

@ -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
View 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
View 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"

View File

@ -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

View File

@ -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

View File

@ -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
View File

247
modules/config_parser.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View File

@ -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

View File

@ -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
View 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)

View File

@ -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)

View File

@ -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

View 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>

View File

@ -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)

View File

@ -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
}
}
}
}