Merge branch 'new_test_framework'

This commit is contained in:
ConfusedPolarBear 2022-08-24 02:03:24 -05:00
commit 4f00fcf858
33 changed files with 1990 additions and 253 deletions

3
.gitignore vendored
View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
selenium >= 4.3.0

View File

@ -0,0 +1,3 @@
module github.com/confusedpolarbear/intro_skipper_verifier
go 1.17

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
package structs
type PublicInfo struct {
Version string
OperatingSystem string
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
module github.com/confusedpolarbear/intro_skipper_wrapper
go 1.17

View File

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

View File

@ -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:"-"`
}

View File

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

View File

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

View File

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

View File

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

View File

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