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
|
||||
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
|
||||
|
||||
This folder holds scripts used in performing end to end testing of the plugin. The script:
|
||||
## wrapper
|
||||
|
||||
1. **Erases all currently discovered introduction timestamps**
|
||||
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
|
||||
The wrapper script (compiled as `run_tests`) runs multiple tests on Jellyfin servers to verify that the plugin works as intended. It tests:
|
||||
|
||||
## 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.
|
||||
2. Set the environment variable `JELLYFIN_TOKEN` to the access token of an administrator.
|
||||
3. Run `python3 main.py -f FILENAME`
|
||||
## verifier
|
||||
|
||||
### 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>
|
||||
|
||||
<details>
|
||||
<details id="edl">
|
||||
<summary>EDL file generation</summary>
|
||||
|
||||
<div class="selectContainer">
|
||||
@ -102,7 +102,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<details id="intro_reqs">
|
||||
<summary>Modify introduction requirements</summary>
|
||||
|
||||
<div class="inputContainer">
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Mime;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -73,11 +74,32 @@ public class SkipIntroController : ControllerBase
|
||||
/// Get all introductions. Only used by the end to end testing script.
|
||||
/// </summary>
|
||||
/// <response code="200">All introductions have been returned.</response>
|
||||
/// <returns>Dictionary of Intro objects.</returns>
|
||||
/// <returns>List of IntroWithMetadata objects.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.Plugins;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
@ -142,6 +143,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
}
|
||||
}
|
||||
|
||||
internal BaseItem GetItem(Guid id)
|
||||
{
|
||||
return _libraryManager.GetItemById(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path for an item.
|
||||
/// </summary>
|
||||
@ -149,7 +155,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// <returns>Full path to item.</returns>
|
||||
internal string GetItemPath(Guid id)
|
||||
{
|
||||
return _libraryManager.GetItemById(id).Path;
|
||||
return GetItem(id).Path;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@ -7,18 +7,19 @@
|
||||
|
||||
## Release plugin
|
||||
|
||||
1. Build release DLL with `dotnet build -c Release`
|
||||
2. Zip release DLL
|
||||
3. Update and commit latest changelog and manifest
|
||||
4. Test plugin manifest
|
||||
1. Run package plugin action and download bundle
|
||||
2. Combine generated `manifest.json` with main plugin manifest
|
||||
3. Test plugin manifest
|
||||
1. Replace manifest URL with local IP address
|
||||
2. Serve release ZIP and manifest with `python3 -m http.server`
|
||||
3. Test updating plugin
|
||||
5. Tag and push latest commit
|
||||
6. Create release on GitHub with the following files:
|
||||
4. Create release on GitHub with the following files:
|
||||
1. Archived plugin DLL
|
||||
2. Latest web interface
|
||||
2. Link to the latest web interface
|
||||
|
||||
## Release container
|
||||
|
||||
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