Merge branch 'new_test_framework'
This commit is contained in:
commit
4f00fcf858
3
.gitignore
vendored
3
.gitignore
vendored
@ -5,6 +5,3 @@ BenchmarkDotNet.Artifacts/
|
|||||||
|
|
||||||
# Ignore pre compiled web interface
|
# Ignore pre compiled web interface
|
||||||
docker/dist
|
docker/dist
|
||||||
|
|
||||||
# Ignore user provided end to end test results
|
|
||||||
ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/expected/*.json
|
|
||||||
|
14
ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/.gitignore
vendored
Normal file
14
ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Binaries
|
||||||
|
/verifier/verifier
|
||||||
|
/run_tests
|
||||||
|
/plugin_binaries/
|
||||||
|
|
||||||
|
# Wrapper configuration and base configuration files
|
||||||
|
config.json
|
||||||
|
/config/
|
||||||
|
|
||||||
|
# Timestamp reports
|
||||||
|
/reports/
|
||||||
|
|
||||||
|
# Selenium screenshots
|
||||||
|
selenium/screenshots/
|
@ -1,14 +1,52 @@
|
|||||||
# End to end testing framework
|
# End to end testing framework
|
||||||
|
|
||||||
This folder holds scripts used in performing end to end testing of the plugin. The script:
|
## wrapper
|
||||||
|
|
||||||
1. **Erases all currently discovered introduction timestamps**
|
The wrapper script (compiled as `run_tests`) runs multiple tests on Jellyfin servers to verify that the plugin works as intended. It tests:
|
||||||
2. Runs the Analyze episodes task
|
|
||||||
3. Waits for the analysis to complete
|
|
||||||
4. Checks that the current results are within one second of a previous result
|
|
||||||
|
|
||||||
## Usage
|
- Introduction timestamp accuracy (using `verifier`)
|
||||||
|
- Web interface functionality (using `selenium/main.py`)
|
||||||
|
|
||||||
1. Save the response returned by `/Intros/All?api_key=KEY` to a file somewhere.
|
## verifier
|
||||||
2. Set the environment variable `JELLYFIN_TOKEN` to the access token of an administrator.
|
|
||||||
3. Run `python3 main.py -f FILENAME`
|
### Description
|
||||||
|
|
||||||
|
This program is responsible for:
|
||||||
|
* Saving all discovered introduction timestamps into a report
|
||||||
|
* Comparing two reports against each other to find episodes that:
|
||||||
|
* Are missing introductions in both reports
|
||||||
|
* Have introductions in both reports, but with different timestamps
|
||||||
|
* Newly discovered introductions
|
||||||
|
* Introductions that were discovered previously, but not anymore
|
||||||
|
* Validating the schema of returned `Intro` objects from the `/IntroTimestamps` API endpoint
|
||||||
|
|
||||||
|
### Usage examples
|
||||||
|
* Generate intro timestamp report from a local server:
|
||||||
|
* `./verifier -address http://127.0.0.1:8096 -key api_key`
|
||||||
|
* Generate intro timestamp report from a remote server, polling for task completion every 20 seconds:
|
||||||
|
* `./verifier -address https://example.com -key api_key -poll 20s -o example.json`
|
||||||
|
* Compare two previously generated reports:
|
||||||
|
* `./verifier -r1 v0.1.5.json -r2 v0.1.6.json`
|
||||||
|
* Validate the API schema for three episodes:
|
||||||
|
* `./verifier -address http://127.0.0.1:8096 -key api_key -validate id1,id2,id3`
|
||||||
|
|
||||||
|
## Selenium web interface tests
|
||||||
|
|
||||||
|
Selenium is used to verify that the plugin's web interface works as expected. It simulates a user:
|
||||||
|
|
||||||
|
* Clicking the skip intro button
|
||||||
|
* Checks that clicking the button skips the intro and keeps playing the video
|
||||||
|
* Changing settings (will be added in the future)
|
||||||
|
* Maximum degree of parallelism
|
||||||
|
* Selecting libraries for analysis
|
||||||
|
* EDL settings
|
||||||
|
* Introduction requirements
|
||||||
|
* Auto skip
|
||||||
|
* Show/hide skip prompt
|
||||||
|
* Timestamp editor (will be added in the future)
|
||||||
|
* Displays timestamps
|
||||||
|
* Modifies timestamps
|
||||||
|
* Erases season timestamps
|
||||||
|
* Fingerprint visualizer (will be added in the future)
|
||||||
|
* Suggests shifts
|
||||||
|
* Visualizer canvas is drawn on
|
||||||
|
10
ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh
Executable file
10
ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "[+] Building timestamp verifier"
|
||||||
|
(cd verifier && go build -o verifier) || exit 1
|
||||||
|
|
||||||
|
echo "[+] Building test wrapper"
|
||||||
|
(cd wrapper && go test ./... && go build -o ../run_tests) || exit 1
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[+] All programs built successfully"
|
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"library": "/full/path/to/test/library/on/host",
|
||||||
|
"episode": "Episode title to search for"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"comment": "Optional comment to identify this server",
|
||||||
|
"image": "ghcr.io/confusedpolarbear/jellyfin-intro-skipper:latest",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "hunter2",
|
||||||
|
"base": "config/official",
|
||||||
|
"browsers": [
|
||||||
|
"chrome",
|
||||||
|
"firefox"
|
||||||
|
], // supported values are "chrome" and "firefox".
|
||||||
|
"tests": [
|
||||||
|
"skip_button", // test skip intro button
|
||||||
|
"settings" // test plugin administration page
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
chrome:
|
||||||
|
image: selenium/standalone-chrome:103.0
|
||||||
|
shm_size: 2gb
|
||||||
|
ports:
|
||||||
|
- 4444:4444
|
||||||
|
environment:
|
||||||
|
- SE_NODE_SESSION_TIMEOUT=10
|
||||||
|
|
||||||
|
firefox:
|
||||||
|
image: selenium/standalone-firefox:103.0
|
||||||
|
shm_size: 2gb
|
||||||
|
ports:
|
||||||
|
- 4445:4444
|
||||||
|
environment:
|
||||||
|
- SE_NODE_SESSION_TIMEOUT=10
|
||||||
|
|
||||||
|
chrome_video:
|
||||||
|
image: selenium/video
|
||||||
|
environment:
|
||||||
|
- DISPLAY_CONTAINER_NAME=chrome
|
||||||
|
- FILE_NAME=chrome_video.mp4
|
||||||
|
volumes:
|
||||||
|
- /tmp/selenium/videos:/videos
|
||||||
|
|
||||||
|
firefox_video:
|
||||||
|
image: selenium/video
|
||||||
|
environment:
|
||||||
|
- DISPLAY_CONTAINER_NAME=firefox
|
||||||
|
- FILE_NAME=firefox_video.mp4
|
||||||
|
volumes:
|
||||||
|
- /tmp/selenium/videos:/videos
|
@ -1,228 +0,0 @@
|
|||||||
import argparse, json, os, random, time
|
|
||||||
import requests
|
|
||||||
|
|
||||||
# Server address
|
|
||||||
addr = ""
|
|
||||||
|
|
||||||
# Authentication token
|
|
||||||
token = ""
|
|
||||||
|
|
||||||
# GUID of the analyze episodes scheduled task
|
|
||||||
taskId = "8863329048cc357f7dfebf080f2fe204"
|
|
||||||
|
|
||||||
# Parse CLI arguments
|
|
||||||
def parse_args():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"-s",
|
|
||||||
help="Server address (if different than http://127.0.0.1:8096)",
|
|
||||||
type=str,
|
|
||||||
dest="address",
|
|
||||||
default="http://127.0.0.1:8096",
|
|
||||||
metavar="ADDRESS",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"-freq",
|
|
||||||
help="Interval to poll task completion state at (default is 10 seconds)",
|
|
||||||
type=int,
|
|
||||||
dest="frequency",
|
|
||||||
default=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"-f",
|
|
||||||
help="Expected intro timestamps (as previously retrieved from /Intros/All)",
|
|
||||||
type=str,
|
|
||||||
dest="expected",
|
|
||||||
default="expected/dev.json",
|
|
||||||
metavar="FILENAME",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--skip-analysis",
|
|
||||||
help="Skip reanalyzing episodes and just validate timestamps and API versioning",
|
|
||||||
dest="skip",
|
|
||||||
action="store_true",
|
|
||||||
)
|
|
||||||
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
# Send an HTTP request and return the response
|
|
||||||
def send(url, method="GET", log=True):
|
|
||||||
global addr, token
|
|
||||||
|
|
||||||
# Construct URL
|
|
||||||
r = None
|
|
||||||
url = addr + url
|
|
||||||
|
|
||||||
# Log request
|
|
||||||
if log:
|
|
||||||
print(f"{method} {url} ", end="")
|
|
||||||
|
|
||||||
# Send auth token
|
|
||||||
headers = {"Authorization": f"MediaBrowser Token={token}"}
|
|
||||||
|
|
||||||
# Send the request
|
|
||||||
if method == "GET":
|
|
||||||
r = requests.get(url, headers=headers)
|
|
||||||
elif method == "POST":
|
|
||||||
r = requests.post(url, headers=headers)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown method {method}")
|
|
||||||
|
|
||||||
# Log status code
|
|
||||||
if log:
|
|
||||||
print(f"{r.status_code}\n")
|
|
||||||
|
|
||||||
# Check status code
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def close_enough(expected, actual):
|
|
||||||
# TODO: make customizable
|
|
||||||
return abs(expected - actual) <= 2
|
|
||||||
|
|
||||||
|
|
||||||
# Validate that all episodes in expected have a similar entry in actual.
|
|
||||||
def validate(expected, actual):
|
|
||||||
good = 0
|
|
||||||
bad = 0
|
|
||||||
total = len(expected)
|
|
||||||
|
|
||||||
for i in expected:
|
|
||||||
if i not in actual:
|
|
||||||
print(f"[!] Cound not find episode {i}")
|
|
||||||
bad += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
ex = expected[i]
|
|
||||||
ac = actual[i]
|
|
||||||
|
|
||||||
start = close_enough(ex["IntroStart"], ac["IntroStart"])
|
|
||||||
end = close_enough(ex["IntroEnd"], ac["IntroEnd"])
|
|
||||||
|
|
||||||
# If both the start and end times are close enough, keep going
|
|
||||||
if start and end:
|
|
||||||
good += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Oops
|
|
||||||
bad += 1
|
|
||||||
|
|
||||||
print(f"[!] Episode {i} is not correct")
|
|
||||||
print(
|
|
||||||
f"expected {ex['IntroStart']} => {ex['IntroEnd']} but found {ac['IntroStart']} => {ac['IntroEnd']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("Statistics:")
|
|
||||||
print(f"Correct: {good} ({int((good * 100) / total)}%)")
|
|
||||||
print(f"Incorrect: {bad}")
|
|
||||||
print(f"Total: {total}")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
global addr, token
|
|
||||||
|
|
||||||
# Validate arguments
|
|
||||||
args = parse_args()
|
|
||||||
addr = args.address
|
|
||||||
|
|
||||||
# Validate token
|
|
||||||
token = os.environ.get("JELLYFIN_TOKEN")
|
|
||||||
if token is None:
|
|
||||||
print(
|
|
||||||
"Administrator access token is required, set environment variable JELLYFIN_TOKEN and try again"
|
|
||||||
)
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
# Validate expected timestamps
|
|
||||||
expected = []
|
|
||||||
with open(args.expected, "r") as f:
|
|
||||||
expected = json.load(f)
|
|
||||||
|
|
||||||
print(f"[+] Found {len(expected)} expected timestamps\n")
|
|
||||||
|
|
||||||
if not args.skip:
|
|
||||||
# Erase old intro timestamps
|
|
||||||
print("[+] Erasing previously discovered introduction timestamps")
|
|
||||||
send("/Intros/EraseTimestamps", "POST")
|
|
||||||
|
|
||||||
# Run analyze episodes task
|
|
||||||
print("[+] Starting episode analysis task")
|
|
||||||
send(f"/ScheduledTasks/Running/{taskId}", "POST")
|
|
||||||
else:
|
|
||||||
print("[+] Not running episode analysis")
|
|
||||||
args.frequency = 0
|
|
||||||
|
|
||||||
# Poll for completion
|
|
||||||
print("[+] Waiting for analysis task to complete")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
time.sleep(args.frequency)
|
|
||||||
task = send(f"/ScheduledTasks/{taskId}", "GET", False).json()
|
|
||||||
state = task["State"]
|
|
||||||
|
|
||||||
# Calculate percentage analyzed
|
|
||||||
percent = 0
|
|
||||||
|
|
||||||
if state == "Idle":
|
|
||||||
percent = 100
|
|
||||||
|
|
||||||
elif state == "Running":
|
|
||||||
percent = 0
|
|
||||||
if "CurrentProgressPercentage" in task:
|
|
||||||
percent = task["CurrentProgressPercentage"]
|
|
||||||
|
|
||||||
# Print percentage analyzed
|
|
||||||
print(f"\r[+] Episodes analyzed: {percent}%", end="")
|
|
||||||
if percent == 100:
|
|
||||||
print("\n")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Download actual intro timestamps
|
|
||||||
print("[+] Getting actual timestamps")
|
|
||||||
intros = send("/Intros/All")
|
|
||||||
|
|
||||||
actual = intros.json()
|
|
||||||
|
|
||||||
# Store actual episodes to the filesystem
|
|
||||||
with open("/tmp/actual.json", "w") as f:
|
|
||||||
f.write(intros.text)
|
|
||||||
|
|
||||||
# Verify timestamps
|
|
||||||
print(f"[+] Found {len(actual)} actual timestamps\n")
|
|
||||||
|
|
||||||
validate(expected, actual)
|
|
||||||
|
|
||||||
# Select some episodes to validate
|
|
||||||
keys = []
|
|
||||||
for i in expected:
|
|
||||||
keys.append(i)
|
|
||||||
keys = random.choices(keys, k=min(len(keys), 10))
|
|
||||||
|
|
||||||
# Validate API version 1 (both implicitly and explicitly versioned)
|
|
||||||
for version in ["v1 (implicit)", "v1 (explicit)"]:
|
|
||||||
print()
|
|
||||||
print(f"[+] Validating API version: {version} with {len(keys)} episodes")
|
|
||||||
|
|
||||||
if version.find("implicit") != -1:
|
|
||||||
version = ""
|
|
||||||
else:
|
|
||||||
version = "v1"
|
|
||||||
|
|
||||||
for episode in keys:
|
|
||||||
ac = send(
|
|
||||||
f"/Episode/{episode}/IntroTimestamps/{version}", "GET", False
|
|
||||||
).json()
|
|
||||||
|
|
||||||
print(ac)
|
|
||||||
|
|
||||||
|
|
||||||
main()
|
|
@ -0,0 +1,203 @@
|
|||||||
|
import argparse, os, time
|
||||||
|
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
|
||||||
|
# Driver function
|
||||||
|
def main():
|
||||||
|
# Parse CLI arguments and store in a dictionary
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-host", help="Jellyfin server address with protocol and port.")
|
||||||
|
parser.add_argument("-username", help="Username.")
|
||||||
|
parser.add_argument("-password", help="Password.")
|
||||||
|
parser.add_argument("-name", help="Name of episode to search for.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--tests", help="Space separated list of Selenium tests to run.", type=str, nargs="+"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--browsers",
|
||||||
|
help="Space separated list of browsers to run tests with.",
|
||||||
|
type=str,
|
||||||
|
nargs="+",
|
||||||
|
choices=["chrome", "firefox"],
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
server = {
|
||||||
|
"host": args.host,
|
||||||
|
"username": args.username,
|
||||||
|
"password": args.password,
|
||||||
|
"episode": args.name,
|
||||||
|
"browsers": args.browsers,
|
||||||
|
"tests": args.tests,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print the server info for debugging and run the test
|
||||||
|
print()
|
||||||
|
print(f"Browsers: {server['browsers']}")
|
||||||
|
print(f"Address: {server['host']}")
|
||||||
|
print(f"Username: {server['username']}")
|
||||||
|
print(f"Episode: \"{server['episode']}\"")
|
||||||
|
print(f"Tests: {server['tests']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Setup the list of drivers to run tests with
|
||||||
|
if server["browsers"] is None:
|
||||||
|
print("[!] --browsers is required")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
drivers = []
|
||||||
|
if "chrome" in server["browsers"]:
|
||||||
|
drivers = [("http://127.0.0.1:4444", "Chrome")]
|
||||||
|
if "firefox" in server["browsers"]:
|
||||||
|
drivers.append(("http://127.0.0.1:4445", "Firefox"))
|
||||||
|
|
||||||
|
# Test with all selected drivers
|
||||||
|
for driver in drivers:
|
||||||
|
print(f"[!] Starting new test run using {driver[1]}")
|
||||||
|
test_server(server, driver[0], driver[1])
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# Main server test function
|
||||||
|
def test_server(server, executor, driver_type):
|
||||||
|
# Configure Selenium to use a remote driver
|
||||||
|
print(f"[+] Configuring Selenium to use executor {executor} of type {driver_type}")
|
||||||
|
|
||||||
|
opts = None
|
||||||
|
if driver_type == "Chrome":
|
||||||
|
opts = webdriver.ChromeOptions()
|
||||||
|
elif driver_type == "Firefox":
|
||||||
|
opts = webdriver.FirefoxOptions()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown driver type {driver_type}")
|
||||||
|
|
||||||
|
driver = webdriver.Remote(command_executor=executor, options=opts)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait up to two seconds when finding an element before reporting failure
|
||||||
|
driver.implicitly_wait(2)
|
||||||
|
|
||||||
|
# Login to Jellyfin
|
||||||
|
driver.get(make_url(server, "/"))
|
||||||
|
|
||||||
|
print(f"[+] Authenticating as {server['username']}")
|
||||||
|
login(driver, server)
|
||||||
|
|
||||||
|
if "skip_button" in server["tests"]:
|
||||||
|
# Play the user specified episode and verify skip intro button functionality. This episode is expected to:
|
||||||
|
# * already have been analyzed for an introduction
|
||||||
|
# * have an introduction at the beginning of the episode
|
||||||
|
print("[+] Testing skip intro button")
|
||||||
|
test_skip_button(driver, server)
|
||||||
|
|
||||||
|
print("[+] All tests completed successfully")
|
||||||
|
finally:
|
||||||
|
# Unconditionally end the Selenium session
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
def login(driver, server):
|
||||||
|
# Append the Enter key to the password to submit the form
|
||||||
|
us = server["username"]
|
||||||
|
pw = server["password"] + Keys.ENTER
|
||||||
|
|
||||||
|
# Fill out and submit the login form
|
||||||
|
driver.find_element(By.ID, "txtManualName").send_keys(us)
|
||||||
|
driver.find_element(By.ID, "txtManualPassword").send_keys(pw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_button(driver, server):
|
||||||
|
print(f" [+] Searching for episode \"{server['episode']}\"")
|
||||||
|
|
||||||
|
search = driver.find_element(By.CSS_SELECTOR, ".headerSearchButton span.search")
|
||||||
|
|
||||||
|
if driver.capabilities["browserName"] == "firefox":
|
||||||
|
# Work around a FF bug where the search element isn't considered clickable right away
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Click the search button
|
||||||
|
search.click()
|
||||||
|
|
||||||
|
# Type the episode name
|
||||||
|
driver.find_element(By.CSS_SELECTOR, ".searchfields-txtSearch").send_keys(
|
||||||
|
server["episode"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Click the first episode in the search results
|
||||||
|
driver.find_element(
|
||||||
|
By.CSS_SELECTOR, ".searchResults button[data-type='Episode']"
|
||||||
|
).click()
|
||||||
|
|
||||||
|
# Wait for the episode page to finish loading by searching for the episode description (overview)
|
||||||
|
driver.find_element(By.CSS_SELECTOR, ".overview")
|
||||||
|
|
||||||
|
print(f" [+] Waiting for playback to start")
|
||||||
|
|
||||||
|
# Click the play button in the toolbar
|
||||||
|
driver.find_element(
|
||||||
|
By.CSS_SELECTOR, "div.mainDetailButtons span.play_arrow"
|
||||||
|
).click()
|
||||||
|
|
||||||
|
# Wait for playback to start by searching for the lower OSD control bar
|
||||||
|
driver.find_element(By.CSS_SELECTOR, ".osdControls")
|
||||||
|
|
||||||
|
# Let the video play a little bit so the position before clicking the button can be logged
|
||||||
|
print(" [+] Playing video")
|
||||||
|
time.sleep(2)
|
||||||
|
screenshot(driver, "skip_button_pre_skip")
|
||||||
|
assert_video_playing(driver)
|
||||||
|
|
||||||
|
# Find the skip intro button and click it, logging the new video position after the seek is preformed
|
||||||
|
print(" [+] Clicking skip intro button")
|
||||||
|
driver.find_element(By.CSS_SELECTOR, "button.btnSkipIntro").click()
|
||||||
|
time.sleep(1)
|
||||||
|
screenshot(driver, "skip_button_post_skip")
|
||||||
|
assert_video_playing(driver)
|
||||||
|
|
||||||
|
# Keep playing the video for a few seconds to ensure that:
|
||||||
|
# * the intro was successfully skipped
|
||||||
|
# * video playback continued automatically post button click
|
||||||
|
print(" [+] Verifying post skip position")
|
||||||
|
time.sleep(4)
|
||||||
|
|
||||||
|
screenshot(driver, "skip_button_post_play")
|
||||||
|
assert_video_playing(driver)
|
||||||
|
|
||||||
|
|
||||||
|
# Utility functions
|
||||||
|
def make_url(server, url):
|
||||||
|
final = server["host"] + url
|
||||||
|
print(f"[+] Navigating to {final}")
|
||||||
|
return final
|
||||||
|
|
||||||
|
|
||||||
|
def screenshot(driver, filename):
|
||||||
|
dest = f"screenshots/{filename}.png"
|
||||||
|
driver.save_screenshot(dest)
|
||||||
|
|
||||||
|
|
||||||
|
# Returns the current video playback position and if the video is paused.
|
||||||
|
# Will raise an exception if playback is paused as the video shouldn't ever pause when using this plugin.
|
||||||
|
def assert_video_playing(driver):
|
||||||
|
ret = driver.execute_script(
|
||||||
|
"""
|
||||||
|
const video = document.querySelector("video");
|
||||||
|
return {
|
||||||
|
"position": video.currentTime,
|
||||||
|
"paused": video.paused
|
||||||
|
};
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if ret["paused"]:
|
||||||
|
raise Exception("Video should not be paused")
|
||||||
|
|
||||||
|
print(f" [+] Video playback position: {ret['position']}")
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
@ -0,0 +1 @@
|
|||||||
|
selenium >= 4.3.0
|
@ -0,0 +1,3 @@
|
|||||||
|
module github.com/confusedpolarbear/intro_skipper_verifier
|
||||||
|
|
||||||
|
go 1.17
|
@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gets the contents of the provided URL or panics.
|
||||||
|
func SendRequest(method, url, apiKey string) []byte {
|
||||||
|
http.DefaultClient.Timeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Construct the request
|
||||||
|
req, err := http.NewRequest(method, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the authorization token
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf(`MediaBrowser Token="%s"`, apiKey))
|
||||||
|
|
||||||
|
// Send the request
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
|
||||||
|
if !strings.Contains(url, "hideUrl") {
|
||||||
|
fmt.Printf("[+] %s %s: %d\n", method, url, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panic if any error occurred
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for API key validity
|
||||||
|
if res.StatusCode == http.StatusUnauthorized {
|
||||||
|
panic("Server returned 401 (Unauthorized). Check API key validity and try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and return the entire body
|
||||||
|
defer res.Body.Close()
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetServerInfo(hostAddress, apiKey string) structs.PublicInfo {
|
||||||
|
var info structs.PublicInfo
|
||||||
|
|
||||||
|
fmt.Println("[+] Getting server information")
|
||||||
|
rawInfo := SendRequest("GET", hostAddress+"/System/Info/Public", apiKey)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(rawInfo, &info); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPluginConfiguration(hostAddress, apiKey string) structs.PluginConfiguration {
|
||||||
|
var config structs.PluginConfiguration
|
||||||
|
|
||||||
|
fmt.Println("[+] Getting plugin configuration")
|
||||||
|
rawConfig := SendRequest("GET", hostAddress+"/Plugins/c83d86bb-a1e0-4c35-a113-e2101cf4ee6b/Configuration", apiKey)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(rawConfig, &config); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func flags() {
|
||||||
|
// Report generation
|
||||||
|
hostAddress := flag.String("address", "", "Address of Jellyfin server to extract intro information from.")
|
||||||
|
apiKey := flag.String("key", "", "Administrator API key to authenticate with.")
|
||||||
|
keepTimestamps := flag.Bool("keep", false, "Keep the current timestamps instead of erasing and reanalyzing.")
|
||||||
|
pollInterval := flag.Duration("poll", 10*time.Second, "Interval to poll task completion at.")
|
||||||
|
reportDestination := flag.String("o", "", "Report destination filename. Defaults to intros-ADDRESS-TIMESTAMP.json.")
|
||||||
|
|
||||||
|
// Report comparison
|
||||||
|
report1 := flag.String("r1", "", "First report.")
|
||||||
|
report2 := flag.String("r2", "", "Second report.")
|
||||||
|
|
||||||
|
// API schema validator
|
||||||
|
ids := flag.String("validate", "", "Comma separated item ids to validate the API schema for.")
|
||||||
|
|
||||||
|
// Print usage examples
|
||||||
|
flag.CommandLine.Usage = func() {
|
||||||
|
flag.CommandLine.Output().Write([]byte("Flags:\n"))
|
||||||
|
flag.PrintDefaults()
|
||||||
|
|
||||||
|
usage := "\nUsage:\n" +
|
||||||
|
"Generate intro timestamp report from a local server:\n" +
|
||||||
|
"./verifier -address http://127.0.0.1:8096 -key api_key\n\n" +
|
||||||
|
|
||||||
|
"Generate intro timestamp report from a remote server, polling for task completion every 20 seconds:\n" +
|
||||||
|
"./verifier -address https://example.com -key api_key -poll 20s -o example.json\n\n" +
|
||||||
|
|
||||||
|
"Compare two previously generated reports:\n" +
|
||||||
|
"./verifier -r1 v0.1.5.json -r2 v0.1.6.json\n\n" +
|
||||||
|
|
||||||
|
"Validate the API schema for some item ids:\n" +
|
||||||
|
"./verifier -address http://127.0.0.1:8096 -key api_key -validate id1,id2,id3\n"
|
||||||
|
|
||||||
|
flag.CommandLine.Output().Write([]byte(usage))
|
||||||
|
}
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *hostAddress != "" && *apiKey != "" {
|
||||||
|
if *ids == "" {
|
||||||
|
generateReport(*hostAddress, *apiKey, *reportDestination, *keepTimestamps, *pollInterval)
|
||||||
|
} else {
|
||||||
|
validateApiSchema(*hostAddress, *apiKey, *ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if *report1 != "" && *report2 != "" {
|
||||||
|
compareReports(*report1, *report2, *reportDestination)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
panic("Either (-address and -key) or (-r1 and -r2) are required.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flags()
|
||||||
|
}
|
@ -0,0 +1,389 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<!-- TODO: when templating this, pre-populate the ignored shows value with something pulled from a config file -->
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
/* dark mode */
|
||||||
|
body {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* enable borders on the table row */
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td {
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* remove top & bottom margins */
|
||||||
|
.report-info *,
|
||||||
|
.episode * {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visually separate the report header from the contents */
|
||||||
|
.report-info .report {
|
||||||
|
background-color: #0c3c55;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report h3 {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report.stats {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* prevent the details from taking up the entire width of the screen */
|
||||||
|
.show>details {
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* indent season headers some */
|
||||||
|
.season {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* indent individual episode timestamps some more */
|
||||||
|
.episode {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* if an intro was not found previously but is now, that's good */
|
||||||
|
.episode[data-warning="improvement"] {
|
||||||
|
background-color: #044b04;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* if an intro was found previously but isn't now, that's bad */
|
||||||
|
.episode[data-warning="only_previous"],
|
||||||
|
.episode[data-warning="missing"] {
|
||||||
|
background-color: firebrick;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* if an intro was found on both runs but the timestamps are pretty different, that's interesting */
|
||||||
|
.episode[data-warning="different"] {
|
||||||
|
background-color: #b77600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stats.warning {
|
||||||
|
border: 2px solid firebrick;
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="report-info">
|
||||||
|
<h2 class="margin-bottom:1em">Intro Timestamp Differential</h2>
|
||||||
|
|
||||||
|
<div class="report old">
|
||||||
|
<h3 style="margin-top:0.5em">First report</h3>
|
||||||
|
|
||||||
|
{{ block "ReportInfo" .OldReport }}
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom: 1px solid black">
|
||||||
|
<td>Path</td>
|
||||||
|
<td><code>{{ .Path }}</code></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>Jellyfin</td>
|
||||||
|
<td>{{ .ServerInfo.Version }} on {{ .ServerInfo.OperatingSystem }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Analysis Settings</td>
|
||||||
|
<td>{{ printAnalysisSettings .PluginConfig }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid black">
|
||||||
|
<td>Introduction Requirements</td>
|
||||||
|
<td>{{ printIntroductionReqs .PluginConfig }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>Start time</td>
|
||||||
|
<td>{{ printTime .StartedAt }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>End time</td>
|
||||||
|
<td>{{ printTime .FinishedAt }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Duration</td>
|
||||||
|
<td>{{ printDuration .Runtime }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report new">
|
||||||
|
<h3 style="padding-top:0.5em">Second report</h3>
|
||||||
|
|
||||||
|
{{ template "ReportInfo" .NewReport }}
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="report stats">
|
||||||
|
<h3 style="padding-top:0.5em">Statistics</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Total episodes</td>
|
||||||
|
<td id="statTotal"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Never found</td>
|
||||||
|
<td id="statMissing"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Changed</td>
|
||||||
|
<td id="statChanged"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Gains</td>
|
||||||
|
<td id="statGain"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Losses</td>
|
||||||
|
<td id="statLoss"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report settings">
|
||||||
|
<h3 style="padding-top:0.5em">Settings</h3>
|
||||||
|
|
||||||
|
<form style="display:table">
|
||||||
|
<label for="minimumPercentage">Minimum percentage</label>
|
||||||
|
<input id="minimumPercentage" type="number" value="85" min="0" max="100"
|
||||||
|
style="margin-left: 5px; max-width: 100px" /> <br />
|
||||||
|
|
||||||
|
<label for="ignoreShows">Ignored shows</label>
|
||||||
|
<input id="ignoredShows" type="text" /> <br />
|
||||||
|
|
||||||
|
<button id="btnUpdate" type="button">Update</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* store a reference to the data before the range query */}}
|
||||||
|
{{ $p := . }}
|
||||||
|
|
||||||
|
{{/* sort the show names and iterate over them */}}
|
||||||
|
{{ range $name := sortShows .OldReport.Shows }}
|
||||||
|
<div class="show" id="{{ $name }}">
|
||||||
|
<details>
|
||||||
|
{{/* get the unsorted seasons for this show */}}
|
||||||
|
{{ $seasons := index $p.OldReport.Shows $name }}
|
||||||
|
|
||||||
|
{{/* log the show name and number of seasons */}}
|
||||||
|
<summary>
|
||||||
|
<span class="showTitle">
|
||||||
|
<strong>{{ $name }}</strong>
|
||||||
|
<span id="stats"></span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="seasons">
|
||||||
|
{{/* sort the seasons to ensure they display in numerical order */}}
|
||||||
|
{{ range $seasonNumber := (sortSeasons $seasons) }}
|
||||||
|
<div class="season" id="{{ $name }}-{{ $seasonNumber }}">
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<span>
|
||||||
|
<strong>Season {{ $seasonNumber }}</strong>
|
||||||
|
<span id="stats"></span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
{{/* compare each episode in the old report to the same episode in the new report */}}
|
||||||
|
{{ range $episode := index $seasons $seasonNumber }}
|
||||||
|
|
||||||
|
{{/* lookup and compare both episodes */}}
|
||||||
|
{{ $comparison := compareEpisodes $episode.EpisodeId $p }}
|
||||||
|
{{ $old := $comparison.Old }}
|
||||||
|
{{ $new := $comparison.New }}
|
||||||
|
|
||||||
|
{{/* set attributes indicating if an intro was found in the old and new reports */}}
|
||||||
|
<div class="episode" data-warning="{{ $comparison.WarningShort }}">
|
||||||
|
<p>{{ $episode.Title }}</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Old: {{ $old.IntroStart }} - {{ $old.IntroEnd }}
|
||||||
|
(<span class="duration old">{{ $old.Duration }}</span>)
|
||||||
|
(valid: {{ $old.Valid }}) <br />
|
||||||
|
|
||||||
|
New: {{ $new.IntroStart }} - {{ $new.IntroEnd }}
|
||||||
|
(<span class="duration new">{{ $new.Duration }}</span>)
|
||||||
|
(valid: {{ $new.Valid }}) <br />
|
||||||
|
|
||||||
|
{{ if ne $comparison.WarningShort "okay" }}
|
||||||
|
Warning: {{ $comparison.Warning }}
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function count(parent, warning) {
|
||||||
|
const sel = `div.episode[data-warning='${warning}']`
|
||||||
|
|
||||||
|
// Don't include hidden elements in the count
|
||||||
|
let count = 0;
|
||||||
|
for (const elem of parent.querySelectorAll(sel)) {
|
||||||
|
// offsetParent is defined when the element is not hidden
|
||||||
|
if (elem.offsetParent) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPercent(part, whole) {
|
||||||
|
const percent = Math.round((part * 10_000) / whole) / 100;
|
||||||
|
return `${part} (${percent}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(selector, text) {
|
||||||
|
document.querySelector(selector).textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets the minimum percentage of episodes in a group (a series or season)
|
||||||
|
// that must have a detected introduction.
|
||||||
|
function getMinimumPercentage() {
|
||||||
|
const value = document.querySelector("#minimumPercentage").value;
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets the average duration for all episodes in a parent group.
|
||||||
|
// durationClass must be either "old" or "new".
|
||||||
|
function getAverageDuration(parent, durationClass) {
|
||||||
|
// Get all durations in the parent
|
||||||
|
const elems = parent.querySelectorAll(".duration." + durationClass);
|
||||||
|
|
||||||
|
// Calculate the average duration, ignoring any episode without an intro
|
||||||
|
let totalDuration = 0;
|
||||||
|
let totalEpisodes = 0;
|
||||||
|
for (const e of elems) {
|
||||||
|
const dur = Number(e.textContent);
|
||||||
|
if (dur === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDuration += dur;
|
||||||
|
totalEpisodes++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalEpisodes === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(totalDuration / totalEpisodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate statistics for all episodes in a parent element (a series or a season).
|
||||||
|
function setGroupStatistics(parent) {
|
||||||
|
// Count the total number of episodes.
|
||||||
|
const total = parent.querySelectorAll("div.episode").length;
|
||||||
|
|
||||||
|
// Count how many episodes have no warnings.
|
||||||
|
const okayCount = count(parent, "okay") + count(parent, "improvement");
|
||||||
|
const okayPercent = Math.round((okayCount * 100) / total);
|
||||||
|
const isOkay = okayPercent >= getMinimumPercentage();
|
||||||
|
|
||||||
|
// Calculate the previous and current average durations
|
||||||
|
const oldDuration = getAverageDuration(parent, "old");
|
||||||
|
const newDuration = getAverageDuration(parent, "new");
|
||||||
|
|
||||||
|
// Display the statistics
|
||||||
|
const stats = parent.querySelector("#stats");
|
||||||
|
stats.textContent = `${okayCount} / ${total} (${okayPercent}%) okay. r1 ${oldDuration} r2 ${newDuration}`;
|
||||||
|
|
||||||
|
if (!isOkay) {
|
||||||
|
stats.classList.add("warning");
|
||||||
|
} else {
|
||||||
|
stats.classList.remove("warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlobalStatistics() {
|
||||||
|
// Display all shows
|
||||||
|
for (const show of document.querySelectorAll("div.show")) {
|
||||||
|
show.style.display = "unset";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide any shows that are ignored
|
||||||
|
for (let ignored of document.querySelector("#ignoredShows").value.split(",")) {
|
||||||
|
const elem = document.querySelector(`div.show[id='${ignored}']`);
|
||||||
|
if (!elem) {
|
||||||
|
console.warn("unable to find show", ignored);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
elem.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = document.querySelectorAll("div.episode").length;
|
||||||
|
const missing = count(document, "missing");
|
||||||
|
const different = count(document, "different")
|
||||||
|
const gain = count(document, "improvement");
|
||||||
|
const loss = count(document, "only_previous");
|
||||||
|
const okay = total - missing - different - loss;
|
||||||
|
|
||||||
|
setText("#statTotal", getPercent(okay, total));
|
||||||
|
setText("#statMissing", getPercent(missing, total));
|
||||||
|
setText("#statChanged", getPercent(different, total));
|
||||||
|
setText("#statGain", getPercent(gain, total));
|
||||||
|
setText("#statLoss", getPercent(loss, total));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatistics() {
|
||||||
|
for (const series of document.querySelectorAll("div.show")) {
|
||||||
|
setGroupStatistics(series);
|
||||||
|
|
||||||
|
for (const season of series.querySelectorAll("div.season")) {
|
||||||
|
setGroupStatistics(season);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display statistics for all episodes and by groups
|
||||||
|
updateGlobalStatistics();
|
||||||
|
updateStatistics();
|
||||||
|
|
||||||
|
// Add event handlers
|
||||||
|
document.querySelector("#minimumPercentage").addEventListener("input", updateStatistics);
|
||||||
|
document.querySelector("#btnUpdate").addEventListener("click", updateGlobalStatistics);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed report.html
|
||||||
|
var reportTemplate []byte
|
||||||
|
|
||||||
|
func compareReports(oldReportPath, newReportPath, destination string) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Populate the destination filename if none was provided
|
||||||
|
if destination == "" {
|
||||||
|
destination = fmt.Sprintf("report-%d.html", start.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the report for writing
|
||||||
|
f, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
} else {
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
|
||||||
|
fmt.Printf("First report: %s\n", oldReportPath)
|
||||||
|
fmt.Printf("Second report: %s\n", newReportPath)
|
||||||
|
fmt.Printf("Destination: %s\n\n", destination)
|
||||||
|
|
||||||
|
// Unmarshal both reports
|
||||||
|
oldReport, newReport := unmarshalReport(oldReportPath), unmarshalReport(newReportPath)
|
||||||
|
|
||||||
|
fmt.Println("[+] Comparing reports")
|
||||||
|
|
||||||
|
// Setup a function map with helper functions to use in the template
|
||||||
|
tmp := template.New("report")
|
||||||
|
|
||||||
|
funcs := make(template.FuncMap)
|
||||||
|
|
||||||
|
funcs["printTime"] = func(t time.Time) string {
|
||||||
|
return t.Format(time.RFC1123)
|
||||||
|
}
|
||||||
|
|
||||||
|
funcs["printDuration"] = func(d time.Duration) string {
|
||||||
|
return d.Round(time.Second).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
funcs["printAnalysisSettings"] = func(pc structs.PluginConfiguration) string {
|
||||||
|
return pc.AnalysisSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
funcs["printIntroductionReqs"] = func(pc structs.PluginConfiguration) string {
|
||||||
|
return pc.IntroductionRequirements()
|
||||||
|
}
|
||||||
|
|
||||||
|
funcs["sortShows"] = templateSortShows
|
||||||
|
funcs["sortSeasons"] = templateSortSeason
|
||||||
|
funcs["compareEpisodes"] = templateCompareEpisodes
|
||||||
|
tmp.Funcs(funcs)
|
||||||
|
|
||||||
|
// Load the template or panic
|
||||||
|
report := template.Must(tmp.Parse(string(reportTemplate)))
|
||||||
|
|
||||||
|
err = report.Execute(f,
|
||||||
|
structs.TemplateReportData{
|
||||||
|
OldReport: oldReport,
|
||||||
|
NewReport: newReport,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log success
|
||||||
|
fmt.Printf("[+] Reports successfully compared in %s\n", time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalReport(path string) structs.Report {
|
||||||
|
// Read the provided report
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal
|
||||||
|
var report structs.Report
|
||||||
|
if err := json.Unmarshal(contents, &report); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup maps and template data for later use
|
||||||
|
report.Path = path
|
||||||
|
report.Shows = make(map[string]structs.Seasons)
|
||||||
|
report.IntroMap = make(map[string]structs.Intro)
|
||||||
|
|
||||||
|
// Sort episodes by show and season
|
||||||
|
for _, intro := range report.Intros {
|
||||||
|
// Round the duration to the nearest second to avoid showing 8 decimal places in the report
|
||||||
|
intro.Duration = float32(math.Round(float64(intro.Duration)))
|
||||||
|
|
||||||
|
show, season := intro.Series, intro.Season
|
||||||
|
|
||||||
|
// If this show hasn't been seen before, allocate space for it
|
||||||
|
if _, ok := report.Shows[show]; !ok {
|
||||||
|
report.Shows[show] = make(structs.Seasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store this intro in the season of this show
|
||||||
|
episodes := report.Shows[show][season]
|
||||||
|
episodes = append(episodes, intro)
|
||||||
|
report.Shows[show][season] = episodes
|
||||||
|
|
||||||
|
// Store a reference to this intro in a lookup table
|
||||||
|
report.IntroMap[intro.EpisodeId] = intro
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print report info
|
||||||
|
fmt.Printf("Report %s:\n", path)
|
||||||
|
fmt.Printf("Generated with Jellyfin %s running on %s\n", report.ServerInfo.Version, report.ServerInfo.OperatingSystem)
|
||||||
|
fmt.Printf("Analysis settings: %s\n", report.PluginConfig.AnalysisSettings())
|
||||||
|
fmt.Printf("Introduction reqs: %s\n", report.PluginConfig.IntroductionRequirements())
|
||||||
|
fmt.Printf("Episodes analyzed: %d\n", len(report.Intros))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// report template helper functions
|
||||||
|
|
||||||
|
// Sort show names alphabetically
|
||||||
|
func templateSortShows(shows map[string]structs.Seasons) []string {
|
||||||
|
var showNames []string
|
||||||
|
|
||||||
|
for show := range shows {
|
||||||
|
showNames = append(showNames, show)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(showNames)
|
||||||
|
|
||||||
|
return showNames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort season numbers
|
||||||
|
func templateSortSeason(show structs.Seasons) []int {
|
||||||
|
var keys []int
|
||||||
|
|
||||||
|
for season := range show {
|
||||||
|
keys = append(keys, season)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Ints(keys)
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the episode with the provided ID in the old report to the episode in the new report.
|
||||||
|
func templateCompareEpisodes(id string, reports structs.TemplateReportData) structs.IntroPair {
|
||||||
|
var pair structs.IntroPair
|
||||||
|
var tolerance int = 5
|
||||||
|
|
||||||
|
// Locate both episodes
|
||||||
|
pair.Old = reports.OldReport.IntroMap[id]
|
||||||
|
pair.New = reports.NewReport.IntroMap[id]
|
||||||
|
|
||||||
|
// Mark the timestamps as similar if they are within a few seconds of each other
|
||||||
|
similar := func(oldTime, newTime float32) bool {
|
||||||
|
diff := math.Abs(float64(newTime) - float64(oldTime))
|
||||||
|
return diff <= float64(tolerance)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pair.Old.Valid && !pair.New.Valid {
|
||||||
|
// If an intro was found previously, but not now, flag it
|
||||||
|
pair.WarningShort = "only_previous"
|
||||||
|
pair.Warning = "Introduction found in previous report, but not the current one"
|
||||||
|
|
||||||
|
} else if !pair.Old.Valid && pair.New.Valid {
|
||||||
|
// If an intro was not found previously, but found now, flag it
|
||||||
|
pair.WarningShort = "improvement"
|
||||||
|
pair.Warning = "New introduction discovered"
|
||||||
|
|
||||||
|
} else if !pair.Old.Valid && !pair.New.Valid {
|
||||||
|
// If an intro has never been found for this episode
|
||||||
|
pair.WarningShort = "missing"
|
||||||
|
pair.Warning = "No introduction has ever been found for this episode"
|
||||||
|
|
||||||
|
} else if !similar(pair.Old.IntroStart, pair.New.IntroStart) || !similar(pair.Old.IntroEnd, pair.New.IntroEnd) {
|
||||||
|
// If the intro timestamps are too different, flag it
|
||||||
|
pair.WarningShort = "different"
|
||||||
|
pair.Warning = fmt.Sprintf("Timestamps differ by more than %d seconds", tolerance)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No warning was generated
|
||||||
|
pair.WarningShort = "okay"
|
||||||
|
pair.Warning = "Okay"
|
||||||
|
}
|
||||||
|
|
||||||
|
return pair
|
||||||
|
}
|
@ -0,0 +1,158 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var spinners []string
|
||||||
|
var spinnerIndex int
|
||||||
|
|
||||||
|
func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamps bool, pollInterval time.Duration) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Setup the spinner
|
||||||
|
spinners = strings.Split("⣷⣯⣟⡿⢿⣻⣽⣾", "")
|
||||||
|
spinnerIndex = -1 // start the spinner on the first graphic
|
||||||
|
|
||||||
|
// Setup the filename to save intros to
|
||||||
|
if reportDestination == "" {
|
||||||
|
reportDestination = fmt.Sprintf("intros-%s-%d.json", hostAddress, time.Now().Unix())
|
||||||
|
reportDestination = strings.ReplaceAll(reportDestination, "http://", "")
|
||||||
|
reportDestination = strings.ReplaceAll(reportDestination, "https://", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the file is writable
|
||||||
|
if err := os.WriteFile(reportDestination, nil, 0600); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
|
||||||
|
fmt.Printf("Address: %s\n", hostAddress)
|
||||||
|
fmt.Printf("Destination: %s\n", reportDestination)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Get Jellyfin server information and plugin configuration
|
||||||
|
info := GetServerInfo(hostAddress, apiKey)
|
||||||
|
config := GetPluginConfiguration(hostAddress, apiKey)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Printf("Jellyfin OS: %s\n", info.OperatingSystem)
|
||||||
|
fmt.Printf("Jellyfin version: %s\n", info.Version)
|
||||||
|
fmt.Printf("Analysis settings: %s\n", config.AnalysisSettings())
|
||||||
|
fmt.Printf("Introduction reqs: %s\n", config.IntroductionRequirements())
|
||||||
|
fmt.Printf("Erase timestamps: %t\n", !keepTimestamps)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// If not keeping timestamps, run the fingerprint task.
|
||||||
|
// Otherwise, log that the task isn't being run
|
||||||
|
if !keepTimestamps {
|
||||||
|
runAnalysisAndWait(hostAddress, apiKey, pollInterval)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[+] Using previously discovered intros")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Save all intros from the server
|
||||||
|
fmt.Println("[+] Saving intros")
|
||||||
|
|
||||||
|
var report structs.Report
|
||||||
|
rawIntros := SendRequest("GET", hostAddress+"/Intros/All", apiKey)
|
||||||
|
if err := json.Unmarshal(rawIntros, &report.Intros); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the durations of all intros
|
||||||
|
for i := range report.Intros {
|
||||||
|
intro := report.Intros[i]
|
||||||
|
intro.Duration = intro.IntroEnd - intro.IntroStart
|
||||||
|
report.Intros[i] = intro
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("[+] Saving report")
|
||||||
|
|
||||||
|
// TODO: also save analysis statistics
|
||||||
|
// Store timing data, server information, and plugin configuration
|
||||||
|
report.StartedAt = start
|
||||||
|
report.FinishedAt = time.Now()
|
||||||
|
report.Runtime = report.FinishedAt.Sub(report.StartedAt)
|
||||||
|
report.ServerInfo = info
|
||||||
|
report.PluginConfig = config
|
||||||
|
|
||||||
|
// Marshal the report
|
||||||
|
marshalled, err := json.Marshal(report)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(reportDestination, marshalled, 0600); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[+] Done")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAnalysisAndWait(hostAddress, apiKey string, pollInterval time.Duration) {
|
||||||
|
type taskInfo struct {
|
||||||
|
State string
|
||||||
|
CurrentProgressPercentage int
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[+] Erasing previously discovered intros")
|
||||||
|
SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("[+] Starting analysis task")
|
||||||
|
SendRequest("POST", hostAddress+"/ScheduledTasks/Running/8863329048cc357f7dfebf080f2fe204", apiKey)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("[+] Waiting for analysis task to complete")
|
||||||
|
fmt.Print("[+] Episodes analyzed: 0%")
|
||||||
|
|
||||||
|
var info taskInfo // Last known scheduled task state
|
||||||
|
var lastQuery time.Time // Time the task info was last updated
|
||||||
|
|
||||||
|
for {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Update the spinner
|
||||||
|
if spinnerIndex++; spinnerIndex >= len(spinners) {
|
||||||
|
spinnerIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\r[%s] Episodes analyzed: %d%%", spinners[spinnerIndex], info.CurrentProgressPercentage)
|
||||||
|
|
||||||
|
if info.CurrentProgressPercentage == 100 {
|
||||||
|
fmt.Printf("\r[+]") // reset the spinner
|
||||||
|
fmt.Println()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest task state & unmarshal (only if enough time has passed since the last update)
|
||||||
|
if time.Since(lastQuery) <= pollInterval {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lastQuery = time.Now()
|
||||||
|
|
||||||
|
raw := SendRequest("GET", hostAddress+"/ScheduledTasks/8863329048cc357f7dfebf080f2fe204?hideUrl=1", apiKey)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(raw, &info); err != nil {
|
||||||
|
fmt.Printf("[!] Unable to unmarshal response into taskInfo struct: %s\n", err)
|
||||||
|
fmt.Printf("%s\n", raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the latest task state
|
||||||
|
switch info.State {
|
||||||
|
case "Idle":
|
||||||
|
info.CurrentProgressPercentage = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,116 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Given a comma separated list of item IDs, validate the returned API schema.
|
||||||
|
func validateApiSchema(hostAddress, apiKey, rawIds string) {
|
||||||
|
// Iterate over the raw item IDs and validate the schema of API responses
|
||||||
|
ids := strings.Split(rawIds, ",")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
|
||||||
|
fmt.Printf("Address: %s\n", hostAddress)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Get Jellyfin server information
|
||||||
|
info := GetServerInfo(hostAddress, apiKey)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Printf("Jellyfin OS: %s\n", info.OperatingSystem)
|
||||||
|
fmt.Printf("Jellyfin version: %s\n", info.Version)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
fmt.Printf("[+] Validating item %s\n", id)
|
||||||
|
|
||||||
|
fmt.Println(" [+] Validating API v1 (implicitly versioned)")
|
||||||
|
intro, schema := getTimestampsV1(hostAddress, apiKey, id, "")
|
||||||
|
validateV1Intro(id, intro, schema)
|
||||||
|
|
||||||
|
fmt.Println(" [+] Validating API v1 (explicitly versioned)")
|
||||||
|
intro, schema = getTimestampsV1(hostAddress, apiKey, id, "v1")
|
||||||
|
validateV1Intro(id, intro, schema)
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Validated %d items in %s\n", len(ids), time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates the returned intro object, panicking on any error.
|
||||||
|
func validateV1Intro(id string, intro structs.Intro, schema map[string]interface{}) {
|
||||||
|
// Validate the item ID
|
||||||
|
if intro.EpisodeId != id {
|
||||||
|
panic(fmt.Sprintf("Intro struct has incorrect item ID. Expected '%s', found '%s'", id, intro.EpisodeId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the intro start and end times
|
||||||
|
if intro.IntroStart < 0 || intro.IntroEnd < 0 {
|
||||||
|
panic("Intro struct has a negative intro start or end time")
|
||||||
|
}
|
||||||
|
|
||||||
|
if intro.ShowSkipPromptAt > intro.IntroStart {
|
||||||
|
panic("Intro struct show prompt time is after intro start")
|
||||||
|
}
|
||||||
|
|
||||||
|
if intro.HideSkipPromptAt > intro.IntroEnd {
|
||||||
|
panic("Intro struct hide prompt time is after intro end")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the intro duration
|
||||||
|
if duration := intro.IntroEnd - intro.IntroStart; duration < 15 {
|
||||||
|
panic(fmt.Sprintf("Intro struct has duration %0.2f but the minimum allowed is 15", duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the intro is marked as valid.
|
||||||
|
if !intro.Valid {
|
||||||
|
panic("Intro struct is not marked as valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any extraneous properties
|
||||||
|
allowedProperties := []string{"EpisodeId", "Valid", "IntroStart", "IntroEnd", "ShowSkipPromptAt", "HideSkipPromptAt"}
|
||||||
|
|
||||||
|
for schemaKey := range schema {
|
||||||
|
okay := false
|
||||||
|
|
||||||
|
for _, allowed := range allowedProperties {
|
||||||
|
if allowed == schemaKey {
|
||||||
|
okay = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !okay {
|
||||||
|
panic(fmt.Sprintf("Intro object contains unknown key '%s'", schemaKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets the timestamps for the provided item or panics.
|
||||||
|
func getTimestampsV1(hostAddress, apiKey, id, version string) (structs.Intro, map[string]interface{}) {
|
||||||
|
var rawResponse map[string]interface{}
|
||||||
|
var intro structs.Intro
|
||||||
|
|
||||||
|
// Make an authenticated GET request to {Host}/Episode/{ItemId}/IntroTimestamps/{Version}
|
||||||
|
raw := SendRequest("GET", fmt.Sprintf("%s/Episode/%s/IntroTimestamps/%s?hideUrl=1", hostAddress, id, version), apiKey)
|
||||||
|
|
||||||
|
// Unmarshal the response as a version 1 API response, ignoring any unknown fields.
|
||||||
|
if err := json.Unmarshal(raw, &intro); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second, unmarshal the response into a map so that any unknown fields can be detected and alerted on.
|
||||||
|
if err := json.Unmarshal(raw, &rawResponse); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return intro, rawResponse
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package structs
|
||||||
|
|
||||||
|
type Intro struct {
|
||||||
|
EpisodeId string
|
||||||
|
|
||||||
|
Series string
|
||||||
|
Season int
|
||||||
|
Title string
|
||||||
|
|
||||||
|
IntroStart float32
|
||||||
|
IntroEnd float32
|
||||||
|
Duration float32
|
||||||
|
Valid bool
|
||||||
|
|
||||||
|
ShowSkipPromptAt float32
|
||||||
|
HideSkipPromptAt float32
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
package structs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PluginConfiguration struct {
|
||||||
|
CacheFingerprints bool
|
||||||
|
MaxParallelism int
|
||||||
|
SelectedLibraries string
|
||||||
|
|
||||||
|
AnalysisPercent int
|
||||||
|
AnalysisLengthLimit int
|
||||||
|
MinimumIntroDuration int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c PluginConfiguration) AnalysisSettings() string {
|
||||||
|
// If no libraries have been selected, display a star.
|
||||||
|
// Otherwise, quote each library before displaying the slice.
|
||||||
|
var libs []string
|
||||||
|
if c.SelectedLibraries == "" {
|
||||||
|
libs = []string{"*"}
|
||||||
|
} else {
|
||||||
|
for _, tmp := range strings.Split(c.SelectedLibraries, ",") {
|
||||||
|
tmp = `"` + strings.TrimSpace(tmp) + `"`
|
||||||
|
libs = append(libs, tmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"cfp=%t thr=%d lbs=%v",
|
||||||
|
c.CacheFingerprints,
|
||||||
|
c.MaxParallelism,
|
||||||
|
libs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c PluginConfiguration) IntroductionRequirements() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"per=%d%% max=%dm min=%ds",
|
||||||
|
c.AnalysisPercent,
|
||||||
|
c.AnalysisLengthLimit,
|
||||||
|
c.MinimumIntroDuration)
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package structs
|
||||||
|
|
||||||
|
type PublicInfo struct {
|
||||||
|
Version string
|
||||||
|
OperatingSystem string
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package structs
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Seasons map[int][]Intro
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
Path string `json:"-"`
|
||||||
|
|
||||||
|
StartedAt time.Time
|
||||||
|
FinishedAt time.Time
|
||||||
|
Runtime time.Duration
|
||||||
|
|
||||||
|
ServerInfo PublicInfo
|
||||||
|
PluginConfig PluginConfiguration
|
||||||
|
|
||||||
|
Intros []Intro
|
||||||
|
|
||||||
|
// Intro lookup table. Only populated when loading a report.
|
||||||
|
IntroMap map[string]Intro `json:"-"`
|
||||||
|
|
||||||
|
// Intros which have been sorted by show and season number. Only populated when loading a report.
|
||||||
|
Shows map[string]Seasons `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data passed to the report template.
|
||||||
|
type TemplateReportData struct {
|
||||||
|
// First report.
|
||||||
|
OldReport Report
|
||||||
|
|
||||||
|
// Second report.
|
||||||
|
NewReport Report
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pair of introductions from an old and new reports.
|
||||||
|
type IntroPair struct {
|
||||||
|
Old Intro
|
||||||
|
New Intro
|
||||||
|
|
||||||
|
// Recognized warning types:
|
||||||
|
// * okay: no warning
|
||||||
|
// * different: timestamps are too dissimilar
|
||||||
|
// * only_previous: introduction found in old report but not new one
|
||||||
|
WarningShort string
|
||||||
|
|
||||||
|
// If this pair of intros is not okay, a short description about the cause
|
||||||
|
Warning string
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run an external program
|
||||||
|
func RunProgram(program string, args []string, timeout time.Duration) {
|
||||||
|
// Flag if we are starting or stopping a container
|
||||||
|
managingContainer := program == "docker"
|
||||||
|
|
||||||
|
// Create context and command
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, program, args...)
|
||||||
|
|
||||||
|
// Stringify and censor the program's arguments
|
||||||
|
strArgs := redactString(strings.Join(args, " "))
|
||||||
|
fmt.Printf(" [+] Running %s %s\n", program, strArgs)
|
||||||
|
|
||||||
|
// Setup pipes
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the command
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream any messages to the terminal
|
||||||
|
for _, r := range []io.Reader{stdout, stderr} {
|
||||||
|
// Don't log stdout from the container
|
||||||
|
if managingContainer && r == stdout {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Split(bufio.ScanRunes)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
fmt.Print(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redacts sensitive command line arguments.
|
||||||
|
func redactString(raw string) string {
|
||||||
|
redactionRegex := regexp.MustCompilePOSIX(`-(user|pass|key) [^ ]+`)
|
||||||
|
return redactionRegex.ReplaceAllString(raw, "-$1 REDACTED")
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestStringRedaction(t *testing.T) {
|
||||||
|
raw := "-key deadbeef -first second -user admin -third fourth -pass hunter2"
|
||||||
|
expected := "-key REDACTED -first second -user REDACTED -third fourth -pass REDACTED"
|
||||||
|
actual := redactString(raw)
|
||||||
|
|
||||||
|
if expected != actual {
|
||||||
|
t.Errorf(`String was redacted incorrectly: "%s"`, actual)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
module github.com/confusedpolarbear/intro_skipper_wrapper
|
||||||
|
|
||||||
|
go 1.17
|
@ -0,0 +1,334 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IP address to use when connecting to local containers.
|
||||||
|
var containerAddress string
|
||||||
|
|
||||||
|
// Path to compiled plugin DLL to install in local containers.
|
||||||
|
var pluginPath string
|
||||||
|
|
||||||
|
func flags() {
|
||||||
|
flag.StringVar(&pluginPath, "dll", "", "Path to plugin DLL to install in container images.")
|
||||||
|
flag.StringVar(&containerAddress, "caddr", "", "IP address to use when connecting to local containers.")
|
||||||
|
flag.Parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flags()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
fmt.Printf("[+] Start time: %s\n", start)
|
||||||
|
|
||||||
|
// Load list of servers
|
||||||
|
fmt.Println("[+] Loading configuration")
|
||||||
|
config := loadConfiguration()
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Start Selenium by bringing up the compose file in detatched mode
|
||||||
|
fmt.Println("[+] Starting Selenium")
|
||||||
|
RunProgram("docker-compose", []string{"up", "-d"}, 10*time.Second)
|
||||||
|
|
||||||
|
// If any error occurs, bring Selenium down before exiting
|
||||||
|
defer func() {
|
||||||
|
fmt.Println("[+] Stopping Selenium")
|
||||||
|
RunProgram("docker-compose", []string{"down"}, 15*time.Second)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Test all provided Jellyfin servers
|
||||||
|
for _, server := range config.Servers {
|
||||||
|
if server.Skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var configurationDirectory string
|
||||||
|
var apiKey string
|
||||||
|
var seleniumArgs []string
|
||||||
|
|
||||||
|
// LSIO containers use some slighly different paths & permissions
|
||||||
|
lsioImage := strings.Contains(server.Image, "linuxserver")
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("[+] Testing %s\n", server.Comment)
|
||||||
|
|
||||||
|
if server.Docker {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Setup a temporary folder for the container's configuration
|
||||||
|
configurationDirectory, err = os.MkdirTemp("/dev/shm", "jf-e2e-*")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the contents of the base configuration directory to a temp folder for the container
|
||||||
|
src, dst := server.Base+"/.", configurationDirectory
|
||||||
|
fmt.Printf(" [+] Copying %s to %s\n", src, dst)
|
||||||
|
RunProgram("cp", []string{"-ar", src, dst}, 10*time.Second)
|
||||||
|
|
||||||
|
// Create a folder to install the plugin into
|
||||||
|
pluginDirectory := path.Join(configurationDirectory, "plugins", "intro-skipper")
|
||||||
|
if lsioImage {
|
||||||
|
pluginDirectory = path.Join(configurationDirectory, "data", "plugins", "intro-skipper")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(" [+] Creating plugin directory")
|
||||||
|
if err := os.MkdirAll(pluginDirectory, 0700); err != nil {
|
||||||
|
fmt.Printf(" [!] Failed to create plugin directory: %s\n", err)
|
||||||
|
goto cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is an LSIO container, adjust the permissions on the plugin directory
|
||||||
|
if lsioImage {
|
||||||
|
if err := os.Chown(pluginDirectory, 911, 911); err != nil {
|
||||||
|
fmt.Printf(" [!] Failed to change plugin directory UID/GID: %s\n", err)
|
||||||
|
goto cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install the plugin
|
||||||
|
fmt.Printf(" [+] Copying plugin %s to %s\n", pluginPath, pluginDirectory)
|
||||||
|
RunProgram("cp", []string{pluginPath, pluginDirectory}, 2*time.Second)
|
||||||
|
|
||||||
|
/* Start the container with the following settings:
|
||||||
|
* Name: jf-e2e
|
||||||
|
* Port: 8097
|
||||||
|
* Media: Mounted to /media, read only
|
||||||
|
*/
|
||||||
|
containerArgs := []string{"run", "--name", "jf-e2e", "--rm", "-p", "8097:8096",
|
||||||
|
"-v", fmt.Sprintf("%s:%s:rw", configurationDirectory, "/config"),
|
||||||
|
"-v", fmt.Sprintf("%s:%s:ro", config.Common.Library, "/media"),
|
||||||
|
server.Image}
|
||||||
|
|
||||||
|
fmt.Printf(" [+] Starting container %s\n", server.Image)
|
||||||
|
go RunProgram("docker", containerArgs, 60*time.Second)
|
||||||
|
|
||||||
|
// Wait for the container to fully start
|
||||||
|
waitForServerStartup(server.Address)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[+] Remote instance, assuming plugin is already installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get an API key
|
||||||
|
apiKey = login(server)
|
||||||
|
|
||||||
|
// Analyze episodes and save report
|
||||||
|
fmt.Println(" [+] Analyzing episodes")
|
||||||
|
fmt.Print("\033[37;1m") // change the color of the verifier's text
|
||||||
|
RunProgram(
|
||||||
|
"./verifier/verifier",
|
||||||
|
[]string{
|
||||||
|
"-address", server.Address,
|
||||||
|
"-key", apiKey, "-o",
|
||||||
|
fmt.Sprintf("reports/%s-%d.json", server.Comment, start.Unix())},
|
||||||
|
5*time.Minute)
|
||||||
|
fmt.Print("\033[39;0m") // reset terminal text color
|
||||||
|
|
||||||
|
// Setup base Selenium arguments
|
||||||
|
seleniumArgs = []string{
|
||||||
|
"-u", // force stdout to be unbuffered
|
||||||
|
"main.py",
|
||||||
|
"-host", server.Address,
|
||||||
|
"-user", server.Username,
|
||||||
|
"-pass", server.Password,
|
||||||
|
"-name", config.Common.Episode}
|
||||||
|
|
||||||
|
// Append all requested Selenium tests
|
||||||
|
seleniumArgs = append(seleniumArgs, "--tests")
|
||||||
|
seleniumArgs = append(seleniumArgs, server.Tests...)
|
||||||
|
|
||||||
|
// Append all requested browsers
|
||||||
|
seleniumArgs = append(seleniumArgs, "--browsers")
|
||||||
|
seleniumArgs = append(seleniumArgs, server.Browsers...)
|
||||||
|
|
||||||
|
// Run Selenium
|
||||||
|
os.Chdir("selenium")
|
||||||
|
RunProgram("python3", seleniumArgs, time.Minute)
|
||||||
|
os.Chdir("..")
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
if server.Docker {
|
||||||
|
// Stop the container
|
||||||
|
fmt.Println(" [+] Stopping and removing container")
|
||||||
|
RunProgram("docker", []string{"stop", "jf-e2e"}, 10*time.Second)
|
||||||
|
|
||||||
|
// Cleanup the container's configuration
|
||||||
|
fmt.Printf(" [+] Deleting %s\n", configurationDirectory)
|
||||||
|
|
||||||
|
if err := os.RemoveAll(configurationDirectory); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login to the specified Jellyfin server and return an API key
|
||||||
|
func login(server Server) string {
|
||||||
|
type AuthenticateUserByName struct {
|
||||||
|
AccessToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(" [+] Sending authentication request")
|
||||||
|
|
||||||
|
// Create request body
|
||||||
|
rawBody := fmt.Sprintf(`{"Username":"%s","Pw":"%s"}`, server.Username, server.Password)
|
||||||
|
body := bytes.NewBufferString(rawBody)
|
||||||
|
|
||||||
|
// Create the request
|
||||||
|
req, err := http.NewRequest(
|
||||||
|
"POST",
|
||||||
|
fmt.Sprintf("%s/Users/AuthenticateByName", server.Address),
|
||||||
|
body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set(
|
||||||
|
"X-Emby-Authorization",
|
||||||
|
`MediaBrowser Client="JF E2E Tests", Version="0.0.1", DeviceId="E2E", Device="E2E"`)
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
} else if res.StatusCode != http.StatusOK {
|
||||||
|
panic(fmt.Sprintf("authentication returned code %d", res.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
// Read body
|
||||||
|
fullBody, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal body and return token
|
||||||
|
var token AuthenticateUserByName
|
||||||
|
if err := json.Unmarshal(fullBody, &token); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait up to ten seconds for the provided Jellyfin server to fully startup
|
||||||
|
func waitForServerStartup(address string) {
|
||||||
|
attempts := 10
|
||||||
|
fmt.Println(" [+] Waiting for server to finish starting")
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Sleep in between requests
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
|
// Ping the /System/Info/Public endpoint
|
||||||
|
res, err := http.Get(fmt.Sprintf("%s/System/Info/Public", address))
|
||||||
|
|
||||||
|
// If the server didn't return 200 OK, loop
|
||||||
|
if err != nil || res.StatusCode != http.StatusOK {
|
||||||
|
if attempts--; attempts <= 0 {
|
||||||
|
panic("server is taking too long to startup")
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume startup has finished, break
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read configuration from config.json
|
||||||
|
func loadConfiguration() Configuration {
|
||||||
|
var config Configuration
|
||||||
|
|
||||||
|
// Load the contents of the configuration file
|
||||||
|
raw, err := os.ReadFile("config.json")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal
|
||||||
|
if err := json.Unmarshal(raw, &config); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print debugging info
|
||||||
|
fmt.Printf("Library: %s\n", config.Common.Library)
|
||||||
|
fmt.Printf("Episode: \"%s\"\n", config.Common.Episode)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Check the validity of all entries
|
||||||
|
for i, server := range config.Servers {
|
||||||
|
// If this is an entry for a local container, ensure the server address is correct
|
||||||
|
if server.Image != "" {
|
||||||
|
// Ensure that values were provided for the host's IP address, base configuration directory,
|
||||||
|
// and a path to the compiled plugin DLL to install.
|
||||||
|
if containerAddress == "" {
|
||||||
|
panic("The -caddr argument is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.Base == "" {
|
||||||
|
panic("Original configuration directory is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if pluginPath == "" {
|
||||||
|
panic("The -dll argument is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
server.Address = fmt.Sprintf("http://%s:8097", containerAddress)
|
||||||
|
server.Docker = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no browsers were specified, default to Chrome (for speed)
|
||||||
|
if len(server.Browsers) == 0 {
|
||||||
|
server.Browsers = []string{"chrome"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no tests were specified, only test that the plugin settings page works
|
||||||
|
if len(server.Tests) == 0 {
|
||||||
|
server.Tests = []string{"settings"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that an address was provided
|
||||||
|
if len(server.Address) == 0 {
|
||||||
|
panic("Server address is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("===== Server: %s =====\n", server.Comment)
|
||||||
|
|
||||||
|
if server.Skip {
|
||||||
|
fmt.Println("Skip: true")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Docker: %t\n", server.Docker)
|
||||||
|
if server.Docker {
|
||||||
|
fmt.Printf("Image: %s\n", server.Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Address: %s\n", server.Address)
|
||||||
|
fmt.Printf("Browsers: %v\n", server.Browsers)
|
||||||
|
fmt.Printf("Tests: %v\n", server.Tests)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
config.Servers[i] = server
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("=================")
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type Configuration struct {
|
||||||
|
Common Common `json:"common"`
|
||||||
|
Servers []Server `json:"servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Common struct {
|
||||||
|
Library string `json:"library"`
|
||||||
|
Episode string `json:"episode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Skip bool `json:"skip"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
Browsers []string `json:"browsers"`
|
||||||
|
Tests []string `json:"tests"`
|
||||||
|
|
||||||
|
// These properties are set at runtime
|
||||||
|
Docker bool `json:"-"`
|
||||||
|
}
|
@ -48,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details>
|
<details id="edl">
|
||||||
<summary>EDL file generation</summary>
|
<summary>EDL file generation</summary>
|
||||||
|
|
||||||
<div class="selectContainer">
|
<div class="selectContainer">
|
||||||
@ -102,7 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details id="intro_reqs">
|
||||||
<summary>Modify introduction requirements</summary>
|
<summary>Modify introduction requirements</summary>
|
||||||
|
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -73,11 +74,32 @@ public class SkipIntroController : ControllerBase
|
|||||||
/// Get all introductions. Only used by the end to end testing script.
|
/// Get all introductions. Only used by the end to end testing script.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <response code="200">All introductions have been returned.</response>
|
/// <response code="200">All introductions have been returned.</response>
|
||||||
/// <returns>Dictionary of Intro objects.</returns>
|
/// <returns>List of IntroWithMetadata objects.</returns>
|
||||||
[Authorize(Policy = "RequiresElevation")]
|
[Authorize(Policy = "RequiresElevation")]
|
||||||
[HttpGet("Intros/All")]
|
[HttpGet("Intros/All")]
|
||||||
public ActionResult<Dictionary<Guid, Intro>> GetAllIntros()
|
public ActionResult<List<IntroWithMetadata>> GetAllIntros()
|
||||||
{
|
{
|
||||||
return Plugin.Instance!.Intros;
|
List<IntroWithMetadata> intros = new();
|
||||||
|
|
||||||
|
// Get metadata for all intros
|
||||||
|
foreach (var intro in Plugin.Instance!.Intros)
|
||||||
|
{
|
||||||
|
// Get the details of the item from Jellyfin
|
||||||
|
var rawItem = Plugin.Instance!.GetItem(intro.Key);
|
||||||
|
if (rawItem is not Episode episode)
|
||||||
|
{
|
||||||
|
throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Associate the metadata with the intro
|
||||||
|
intros.Add(
|
||||||
|
new IntroWithMetadata(
|
||||||
|
episode.SeriesName,
|
||||||
|
episode.AiredSeasonNumber ?? 0,
|
||||||
|
episode.Name,
|
||||||
|
intro.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return intros;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,3 +87,42 @@ public class Intro
|
|||||||
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action);
|
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An Intro class with episode metadata. Only used in end to end testing programs.
|
||||||
|
/// </summary>
|
||||||
|
public class IntroWithMetadata : Intro
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="IntroWithMetadata"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series">Series name.</param>
|
||||||
|
/// <param name="season">Season number.</param>
|
||||||
|
/// <param name="title">Episode title.</param>
|
||||||
|
/// <param name="intro">Intro timestamps.</param>
|
||||||
|
public IntroWithMetadata(string series, int season, string title, Intro intro)
|
||||||
|
{
|
||||||
|
Series = series;
|
||||||
|
Season = season;
|
||||||
|
Title = title;
|
||||||
|
|
||||||
|
EpisodeId = intro.EpisodeId;
|
||||||
|
IntroStart = intro.IntroStart;
|
||||||
|
IntroEnd = intro.IntroEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the series name of the TV episode associated with this intro.
|
||||||
|
/// </summary>
|
||||||
|
public string Series { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the season number of the TV episode associated with this intro.
|
||||||
|
/// </summary>
|
||||||
|
public int Season { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the title of the TV episode associated with this intro.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; }
|
||||||
|
}
|
||||||
|
@ -5,6 +5,7 @@ using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
|||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Plugins;
|
using MediaBrowser.Common.Plugins;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
@ -142,6 +143,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal BaseItem GetItem(Guid id)
|
||||||
|
{
|
||||||
|
return _libraryManager.GetItemById(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the full path for an item.
|
/// Gets the full path for an item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -149,7 +155,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
/// <returns>Full path to item.</returns>
|
/// <returns>Full path to item.</returns>
|
||||||
internal string GetItemPath(Guid id)
|
internal string GetItemPath(Guid id)
|
||||||
{
|
{
|
||||||
return _libraryManager.GetItemById(id).Path;
|
return GetItem(id).Path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
@ -7,18 +7,19 @@
|
|||||||
|
|
||||||
## Release plugin
|
## Release plugin
|
||||||
|
|
||||||
1. Build release DLL with `dotnet build -c Release`
|
1. Run package plugin action and download bundle
|
||||||
2. Zip release DLL
|
2. Combine generated `manifest.json` with main plugin manifest
|
||||||
3. Update and commit latest changelog and manifest
|
3. Test plugin manifest
|
||||||
4. Test plugin manifest
|
|
||||||
1. Replace manifest URL with local IP address
|
1. Replace manifest URL with local IP address
|
||||||
2. Serve release ZIP and manifest with `python3 -m http.server`
|
2. Serve release ZIP and manifest with `python3 -m http.server`
|
||||||
3. Test updating plugin
|
3. Test updating plugin
|
||||||
5. Tag and push latest commit
|
4. Create release on GitHub with the following files:
|
||||||
6. Create release on GitHub with the following files:
|
|
||||||
1. Archived plugin DLL
|
1. Archived plugin DLL
|
||||||
2. Latest web interface
|
2. Link to the latest web interface
|
||||||
|
|
||||||
## Release container
|
## Release container
|
||||||
|
|
||||||
1. Run publish container action
|
1. Run publish container action
|
||||||
|
2. Update `latest` tag
|
||||||
|
1. `docker tag ghcr.io/confusedpolarbear/jellyfin-intro-skipper:{COMMIT,latest}`
|
||||||
|
2. `docker push ghcr.io/confusedpolarbear/jellyfin-intro-skipper:latest`
|
||||||
|
Loading…
x
Reference in New Issue
Block a user