test jellyfin plugin, would be nice
This commit is contained in:
@@ -0,0 +1,174 @@
|
|||||||
|
name: Release Jellyfin plugin
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "jellyfin/*/v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Parse tag
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
# jellyfin/<plugin-slug>/v1.2.3
|
||||||
|
if [[ ! "$TAG" =~ ^jellyfin/([a-z0-9-]+)/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||||
|
echo "Tag must match jellyfin/<plugin-slug>/vX.Y.Z (got: $TAG)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SLUG="${BASH_REMATCH[1]}"
|
||||||
|
VERSION="${BASH_REMATCH[2]}"
|
||||||
|
PLUGIN_DIR="Jellyfin/${SLUG}"
|
||||||
|
if [[ ! -f "${PLUGIN_DIR}/build.yaml" ]]; then
|
||||||
|
echo "Missing ${PLUGIN_DIR}/build.yaml"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "slug=${SLUG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "plugin_dir=${PLUGIN_DIR}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version4=${VERSION}.0" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "9.0.x"
|
||||||
|
|
||||||
|
- name: Install jprm
|
||||||
|
run: |
|
||||||
|
python3 -m venv /tmp/jprm-venv
|
||||||
|
/tmp/jprm-venv/bin/pip install --upgrade pip jprm
|
||||||
|
|
||||||
|
- name: Build plugin package
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PLUGIN_DIR="${{ steps.meta.outputs.plugin_dir }}"
|
||||||
|
mkdir -p "${PLUGIN_DIR}/artifacts"
|
||||||
|
sed -i -E "s/^version:.*/version: \"${{ steps.meta.outputs.version4 }}\"/" "${PLUGIN_DIR}/build.yaml"
|
||||||
|
/tmp/jprm-venv/bin/jprm --verbosity=info plugin build "${PLUGIN_DIR}" --output "${PLUGIN_DIR}/artifacts"
|
||||||
|
ARTIFACT=$(find "${PLUGIN_DIR}/artifacts" -type f -name '*.zip' | head -n1)
|
||||||
|
if [[ -z "$ARTIFACT" ]]; then
|
||||||
|
echo "No zip produced by jprm"
|
||||||
|
ls -laR "${PLUGIN_DIR}/artifacts" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NAME=$(basename "$ARTIFACT")
|
||||||
|
CHECKSUM=$(sha256sum "$ARTIFACT" | awk '{print $1}')
|
||||||
|
echo "artifact=${ARTIFACT}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "artifact_name=${NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "checksum=${CHECKSUM}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Built ${NAME} sha256=${CHECKSUM}"
|
||||||
|
|
||||||
|
- name: Create Gitea release and upload asset
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
SLUG="${{ steps.meta.outputs.slug }}"
|
||||||
|
ARTIFACT="${{ steps.build.outputs.artifact }}"
|
||||||
|
ARTIFACT_NAME="${{ steps.build.outputs.artifact_name }}"
|
||||||
|
|
||||||
|
BODY=$(printf 'Jellyfin plugin **%s** %s\n\nAdd the raw Jellyfin/manifest.json URL from this repo as a plugin repository.' \
|
||||||
|
"$SLUG" "$VERSION")
|
||||||
|
BODY_JSON=$(printf '%s' "$BODY" | jq -Rs .)
|
||||||
|
|
||||||
|
RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"${TAG}\",
|
||||||
|
\"name\": \"${SLUG} ${VERSION}\",
|
||||||
|
\"body\": ${BODY_JSON},
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
RESP_BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
if [[ "$HTTP_CODE" != "201" && "$HTTP_CODE" != "200" ]]; then
|
||||||
|
echo "Failed to create release: HTTP $HTTP_CODE"
|
||||||
|
echo "$RESP_BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RELEASE_ID=$(echo "$RESP_BODY" | jq -r '.id')
|
||||||
|
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
curl -sS -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary "@${ARTIFACT}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${ARTIFACT_NAME}"
|
||||||
|
|
||||||
|
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${TAG}/${ARTIFACT_NAME}"
|
||||||
|
echo "download_url=${DOWNLOAD_URL}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Update Jellyfin/manifest.json
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SLUG="${{ steps.meta.outputs.slug }}"
|
||||||
|
VERSION="${{ steps.meta.outputs.version4 }}"
|
||||||
|
CHECKSUM="${{ steps.build.outputs.checksum }}"
|
||||||
|
DOWNLOAD_URL="${{ steps.release.outputs.download_url }}"
|
||||||
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
PLUGIN_DIR="${{ steps.meta.outputs.plugin_dir }}"
|
||||||
|
GUID=$(grep -E '^guid:' "${PLUGIN_DIR}/build.yaml" | head -1 | sed -E 's/guid:[[:space:]]*"?([^"]*)"?/\1/')
|
||||||
|
TARGET_ABI=$(grep -E '^targetAbi:' "${PLUGIN_DIR}/build.yaml" | head -1 | sed -E 's/targetAbi:[[:space:]]*"?([^"]*)"?/\1/')
|
||||||
|
|
||||||
|
NEW_VERSION=$(jq -n \
|
||||||
|
--arg version "$VERSION" \
|
||||||
|
--arg changelog "Release ${VERSION}" \
|
||||||
|
--arg targetAbi "$TARGET_ABI" \
|
||||||
|
--arg sourceUrl "$DOWNLOAD_URL" \
|
||||||
|
--arg checksum "$CHECKSUM" \
|
||||||
|
--arg timestamp "$TIMESTAMP" \
|
||||||
|
'{
|
||||||
|
version: $version,
|
||||||
|
changelog: $changelog,
|
||||||
|
targetAbi: $targetAbi,
|
||||||
|
sourceUrl: $sourceUrl,
|
||||||
|
checksum: $checksum,
|
||||||
|
timestamp: $timestamp
|
||||||
|
}')
|
||||||
|
|
||||||
|
jq --arg guid "$GUID" --argjson newver "$NEW_VERSION" '
|
||||||
|
map(if .guid == $guid then .versions = ([$newver] + (.versions // [])) else . end)
|
||||||
|
' Jellyfin/manifest.json > Jellyfin/manifest.json.tmp
|
||||||
|
mv Jellyfin/manifest.json.tmp Jellyfin/manifest.json
|
||||||
|
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@local"
|
||||||
|
# Detach from tag checkout: update default branch
|
||||||
|
DEFAULT_BRANCH=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')
|
||||||
|
git fetch origin "$DEFAULT_BRANCH"
|
||||||
|
git checkout -B "$DEFAULT_BRANCH" "origin/${DEFAULT_BRANCH}"
|
||||||
|
# Re-apply manifest change if checkout overwrote
|
||||||
|
jq --arg guid "$GUID" --argjson newver "$NEW_VERSION" '
|
||||||
|
map(if .guid == $guid then .versions = ([$newver] + (.versions // [])) else . end)
|
||||||
|
' Jellyfin/manifest.json > Jellyfin/manifest.json.tmp
|
||||||
|
mv Jellyfin/manifest.json.tmp Jellyfin/manifest.json
|
||||||
|
|
||||||
|
git add Jellyfin/manifest.json
|
||||||
|
if git diff --staged --quiet; then
|
||||||
|
echo "Manifest already up to date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "chore(jellyfin): publish ${SLUG} ${VERSION} to catalog"
|
||||||
|
git push origin "$DEFAULT_BRANCH"
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
name: Scaffold Jellyfin plugin
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
plugin_slug:
|
||||||
|
description: "Directory name under Jellyfin/ (e.g. jellyfin-plugin-myfeature)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
plugin_name:
|
||||||
|
description: "Display name in Jellyfin"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
overview:
|
||||||
|
description: "Short overview"
|
||||||
|
required: false
|
||||||
|
default: "Jellyfin plugin"
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
description: "Longer description"
|
||||||
|
required: false
|
||||||
|
default: "Jellyfin plugin scaffolded from template"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scaffold:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create plugin from template
|
||||||
|
env:
|
||||||
|
PLUGIN_SLUG: ${{ inputs.plugin_slug }}
|
||||||
|
PLUGIN_NAME: ${{ inputs.plugin_name }}
|
||||||
|
PLUGIN_OVERVIEW: ${{ inputs.overview }}
|
||||||
|
PLUGIN_DESCRIPTION: ${{ inputs.description }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SLUG="${PLUGIN_SLUG}"
|
||||||
|
if [[ ! "$SLUG" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
|
||||||
|
echo "plugin_slug must be lowercase alphanumeric/hyphen"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET="Jellyfin/${SLUG}"
|
||||||
|
if [[ -e "$TARGET" ]]; then
|
||||||
|
echo "Target already exists: $TARGET"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PascalCase assembly from slug: jellyfin-plugin-foo-bar -> Jellyfin.Plugin.FooBar
|
||||||
|
PASCAL=$(echo "$SLUG" | sed -E 's/^jellyfin-plugin-//; s/^jellyfin-//' | awk -F'-' '{
|
||||||
|
out="Jellyfin.Plugin"
|
||||||
|
for (i=1; i<=NF; i++) {
|
||||||
|
if ($i == "") continue
|
||||||
|
w=toupper(substr($i,1,1)) substr($i,2)
|
||||||
|
out=out "." w
|
||||||
|
}
|
||||||
|
print out
|
||||||
|
}')
|
||||||
|
ASSEMBLY="$PASCAL"
|
||||||
|
NAMESPACE="$PASCAL"
|
||||||
|
PAGE_ID=$(echo "$PASCAL" | tr -d '.')
|
||||||
|
GUID=$(cat /proc/sys/kernel/random/uuid)
|
||||||
|
|
||||||
|
mkdir -p "${TARGET}/${ASSEMBLY}/Configuration"
|
||||||
|
|
||||||
|
replace() {
|
||||||
|
local src="$1"
|
||||||
|
local dst="$2"
|
||||||
|
sed -e "s/{{PLUGIN_SLUG}}/${SLUG}/g" \
|
||||||
|
-e "s/{{PLUGIN_NAME}}/${PLUGIN_NAME}/g" \
|
||||||
|
-e "s/{{PLUGIN_OVERVIEW}}/${PLUGIN_OVERVIEW}/g" \
|
||||||
|
-e "s/{{PLUGIN_DESCRIPTION}}/${PLUGIN_DESCRIPTION}/g" \
|
||||||
|
-e "s/{{PLUGIN_GUID}}/${GUID}/g" \
|
||||||
|
-e "s/{{ASSEMBLY_NAME}}/${ASSEMBLY}/g" \
|
||||||
|
-e "s/{{NAMESPACE}}/${NAMESPACE}/g" \
|
||||||
|
-e "s/{{PAGE_ID}}/${PAGE_ID}/g" \
|
||||||
|
"$src" > "$dst"
|
||||||
|
}
|
||||||
|
|
||||||
|
replace Jellyfin/_template/build.yaml.template "${TARGET}/build.yaml"
|
||||||
|
replace Jellyfin/_template/README.md.template "${TARGET}/README.md"
|
||||||
|
replace Jellyfin/_template/Plugin/Plugin.csproj.template "${TARGET}/${ASSEMBLY}/${ASSEMBLY}.csproj"
|
||||||
|
replace Jellyfin/_template/Plugin/Plugin.cs.template "${TARGET}/${ASSEMBLY}/Plugin.cs"
|
||||||
|
replace Jellyfin/_template/Plugin/Configuration/PluginConfiguration.cs.template \
|
||||||
|
"${TARGET}/${ASSEMBLY}/Configuration/PluginConfiguration.cs"
|
||||||
|
replace Jellyfin/_template/Plugin/Configuration/configPage.html.template \
|
||||||
|
"${TARGET}/${ASSEMBLY}/Configuration/configPage.html"
|
||||||
|
|
||||||
|
cat > "${TARGET}/${ASSEMBLY}.sln" <<EOF
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "${ASSEMBLY}", "${ASSEMBLY}/${ASSEMBLY}.csproj", "{${GUID}}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{${GUID}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{${GUID}}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{${GUID}}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{${GUID}}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Fix accidental leading spaces in generated sln from heredoc
|
||||||
|
sed -i 's/^ //' "${TARGET}/${ASSEMBLY}.sln"
|
||||||
|
|
||||||
|
# Append to aggregated catalog if missing
|
||||||
|
if ! jq -e --arg g "$GUID" 'map(select(.guid == $g)) | length > 0' Jellyfin/manifest.json >/dev/null; then
|
||||||
|
jq --arg guid "$GUID" \
|
||||||
|
--arg name "$PLUGIN_NAME" \
|
||||||
|
--arg desc "$PLUGIN_DESCRIPTION" \
|
||||||
|
--arg overview "$PLUGIN_OVERVIEW" \
|
||||||
|
'. + [{
|
||||||
|
guid: $guid,
|
||||||
|
name: $name,
|
||||||
|
description: $desc,
|
||||||
|
overview: $overview,
|
||||||
|
owner: "bram",
|
||||||
|
category: "General",
|
||||||
|
versions: []
|
||||||
|
}]' Jellyfin/manifest.json > Jellyfin/manifest.json.tmp
|
||||||
|
mv Jellyfin/manifest.json.tmp Jellyfin/manifest.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created ${TARGET}"
|
||||||
|
echo "guid=${GUID}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "assembly=${ASSEMBLY}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@local"
|
||||||
|
git add Jellyfin/
|
||||||
|
if git diff --staged --quiet; then
|
||||||
|
echo "Nothing to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "chore(jellyfin): scaffold ${{ inputs.plugin_slug }}"
|
||||||
|
git push
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/artifacts/
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Jellyfin plugins
|
||||||
|
|
||||||
|
Custom Jellyfin plugins for this monorepo.
|
||||||
|
|
||||||
|
## Catalog URL
|
||||||
|
|
||||||
|
Add this raw URL in Jellyfin → Plugins → Repositories:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<your-gitea>/<owner>/<repo>/raw/branch/<default>/Jellyfin/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `manifest.json` | Aggregated plugin catalog for Jellyfin |
|
||||||
|
| `_template/` | Scaffold source for new plugins |
|
||||||
|
| `jellyfin-plugin-*/` | Individual plugin projects |
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
- **Scaffold:** `.gitea/workflows/scaffold_jellyfin_plugin.yml` (`workflow_dispatch`)
|
||||||
|
- **Release:** `.gitea/workflows/release_jellyfin_plugin.yml` (tag `jellyfin/<slug>/vX.Y.Z`)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace {{NAMESPACE}}.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{PLUGIN_NAME}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="{{PAGE_ID}}ConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-button">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<p>{{PLUGIN_DESCRIPTION}}</p>
|
||||||
|
<p>No settings yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var {{PAGE_ID}}Config = {
|
||||||
|
pluginUniqueId: '{{PLUGIN_GUID}}'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using {{NAMESPACE}}.Configuration;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Plugins;
|
||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
|
namespace {{NAMESPACE}};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// {{PLUGIN_NAME}} plugin.
|
||||||
|
/// </summary>
|
||||||
|
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||||
|
: base(applicationPaths, xmlSerializer)
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Name => "{{PLUGIN_NAME}}";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "{{PLUGIN_DESCRIPTION}}";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override Guid Id => Guid.Parse("{{PLUGIN_GUID}}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current plugin instance.
|
||||||
|
/// </summary>
|
||||||
|
public static Plugin? Instance { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = Name,
|
||||||
|
EmbeddedResourcePath = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}.Configuration.configPage.html",
|
||||||
|
GetType().Namespace)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<RootNamespace>{{NAMESPACE}}</RootNamespace>
|
||||||
|
<AssemblyName>{{ASSEMBLY_NAME}}</AssemblyName>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<Version>1.0.0.0</Version>
|
||||||
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="10.11.3">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Model" Version="10.11.3">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="Configuration\configPage.html" />
|
||||||
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# {{PLUGIN_NAME}}
|
||||||
|
|
||||||
|
{{PLUGIN_DESCRIPTION}}
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
Tag and push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag jellyfin/{{PLUGIN_SLUG}}/v1.0.0
|
||||||
|
git push origin jellyfin/{{PLUGIN_SLUG}}/v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Install from the aggregated catalog:
|
||||||
|
|
||||||
|
`Jellyfin/manifest.json` (raw URL on your Gitea instance).
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: "{{PLUGIN_NAME}}"
|
||||||
|
guid: "{{PLUGIN_GUID}}"
|
||||||
|
version: "1.0.0.0"
|
||||||
|
targetAbi: "10.11.0.0"
|
||||||
|
framework: "net9.0"
|
||||||
|
owner: "bram"
|
||||||
|
overview: "{{PLUGIN_OVERVIEW}}"
|
||||||
|
description: >
|
||||||
|
{{PLUGIN_DESCRIPTION}}
|
||||||
|
category: "General"
|
||||||
|
artifacts:
|
||||||
|
- "{{ASSEMBLY_NAME}}.dll"
|
||||||
|
changelog: |-
|
||||||
|
- Initial scaffold
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
artifacts/
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.PersonalRecordings", "Jellyfin.Plugin.PersonalRecordings\Jellyfin.Plugin.PersonalRecordings.csproj", "{40736576-3424-4219-8E79-30A917C2ECE9}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{40736576-3424-4219-8E79-30A917C2ECE9}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.PersonalRecordings.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the plugin is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the root folder for per-user recordings (e.g. /recordings).
|
||||||
|
/// Files are moved to {TargetRootPath}/{Username}/.
|
||||||
|
/// </summary>
|
||||||
|
public string TargetRootPath { get; set; } = "/recordings";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the poll interval in seconds for completed recordings.
|
||||||
|
/// </summary>
|
||||||
|
public int PollIntervalSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to only log moves without changing files.
|
||||||
|
/// </summary>
|
||||||
|
public bool DryRun { get; set; }
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Personal Recordings</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="PersonalRecordingsConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<form id="PersonalRecordingsConfigForm">
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Enable personal recording moves</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription">When enabled, completed recordings are moved into per-user folders.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="TargetRootPath">Target root path</label>
|
||||||
|
<input id="TargetRootPath" name="TargetRootPath" type="text" is="emby-input" />
|
||||||
|
<div class="fieldDescription">Recordings are moved to {TargetRootPath}/{Username}/ (default: /recordings)</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="PollIntervalSeconds">Poll interval (seconds)</label>
|
||||||
|
<input id="PollIntervalSeconds" name="PollIntervalSeconds" type="number" is="emby-input" min="15" />
|
||||||
|
<div class="fieldDescription">How often to check for completed recordings (minimum 15).</div>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="DryRun" name="DryRun" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Dry run</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription">Log intended moves without changing files.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var PersonalRecordingsConfig = {
|
||||||
|
pluginUniqueId: '7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector('#PersonalRecordingsConfigPage')
|
||||||
|
.addEventListener('pageshow', function () {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(PersonalRecordingsConfig.pluginUniqueId).then(function (config) {
|
||||||
|
document.querySelector('#Enabled').checked = config.Enabled;
|
||||||
|
document.querySelector('#TargetRootPath').value = config.TargetRootPath || '/recordings';
|
||||||
|
document.querySelector('#PollIntervalSeconds').value = config.PollIntervalSeconds || 60;
|
||||||
|
document.querySelector('#DryRun').checked = !!config.DryRun;
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#PersonalRecordingsConfigForm')
|
||||||
|
.addEventListener('submit', function (e) {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(PersonalRecordingsConfig.pluginUniqueId).then(function (config) {
|
||||||
|
config.Enabled = document.querySelector('#Enabled').checked;
|
||||||
|
config.TargetRootPath = document.querySelector('#TargetRootPath').value;
|
||||||
|
config.PollIntervalSeconds = parseInt(document.querySelector('#PollIntervalSeconds').value, 10) || 60;
|
||||||
|
config.DryRun = document.querySelector('#DryRun').checked;
|
||||||
|
ApiClient.updatePluginConfiguration(PersonalRecordingsConfig.pluginUniqueId, config).then(function (result) {
|
||||||
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<RootNamespace>Jellyfin.Plugin.PersonalRecordings</RootNamespace>
|
||||||
|
<AssemblyName>Jellyfin.Plugin.PersonalRecordings</AssemblyName>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<Version>1.0.0.0</Version>
|
||||||
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="10.11.3">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Model" Version="10.11.3">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="Configuration\configPage.html" />
|
||||||
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using Jellyfin.Plugin.PersonalRecordings.Configuration;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Plugins;
|
||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.PersonalRecordings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves completed Live TV recordings into per-user folders based on who scheduled them.
|
||||||
|
/// </summary>
|
||||||
|
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">Application paths.</param>
|
||||||
|
/// <param name="xmlSerializer">XML serializer.</param>
|
||||||
|
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||||
|
: base(applicationPaths, xmlSerializer)
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Name => "Personal Recordings";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description =>
|
||||||
|
"Tracks which user scheduled a Live TV recording and moves completed files into /recordings/<Username>/.";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override Guid Id => Guid.Parse("7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current plugin instance.
|
||||||
|
/// </summary>
|
||||||
|
public static Plugin? Instance { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = Name,
|
||||||
|
EmbeddedResourcePath = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}.Configuration.configPage.html",
|
||||||
|
GetType().Namespace)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
using Jellyfin.Plugin.PersonalRecordings.Services;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.PersonalRecordings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers plugin services.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||||
|
{
|
||||||
|
serviceCollection.AddSingleton<OwnershipStore>();
|
||||||
|
serviceCollection.AddHostedService<TimerOwnershipHost>();
|
||||||
|
serviceCollection.AddHostedService<RecordingMoverHost>();
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
@@ -0,0 +1,277 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.PersonalRecordings.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists timer/series ownership mappings.
|
||||||
|
/// </summary>
|
||||||
|
public class OwnershipStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly ILogger<OwnershipStore> _logger;
|
||||||
|
private readonly string _storePath;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private OwnershipData _data = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="OwnershipStore"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">Application paths.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public OwnershipStore(IApplicationPaths applicationPaths, ILogger<OwnershipStore> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
var dir = Path.Combine(applicationPaths.PluginConfigurationsPath, "Jellyfin.Plugin.PersonalRecordings");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
_storePath = Path.Combine(dir, "ownership.json");
|
||||||
|
Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves ownership for a timer.
|
||||||
|
/// </summary>
|
||||||
|
public void SetTimerOwner(string timerId, Guid userId, string username, string? seriesTimerId, string? name, DateTime? startDate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(timerId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_data.Timers[timerId] = new OwnershipRecord
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Username = username,
|
||||||
|
SeriesTimerId = seriesTimerId,
|
||||||
|
Name = name,
|
||||||
|
StartDate = startDate,
|
||||||
|
Moved = false
|
||||||
|
};
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Recorded timer {TimerId} owner {Username}", timerId, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves ownership for a series timer.
|
||||||
|
/// </summary>
|
||||||
|
public void SetSeriesOwner(string seriesTimerId, Guid userId, string username, string? name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(seriesTimerId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_data.SeriesTimers[seriesTimerId] = new OwnershipRecord
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Username = username,
|
||||||
|
Name = name,
|
||||||
|
Moved = false
|
||||||
|
};
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Recorded series timer {SeriesTimerId} owner {Username}", seriesTimerId, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get ownership for a timer, including inheritance from a series timer.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetTimerOwner(string timerId, string? seriesTimerId, out OwnershipRecord record)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_data.Timers.TryGetValue(timerId, out record!))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(seriesTimerId)
|
||||||
|
&& _data.SeriesTimers.TryGetValue(seriesTimerId, out record!))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
record = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get series timer ownership.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetSeriesOwner(string seriesTimerId, out OwnershipRecord record)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _data.SeriesTimers.TryGetValue(seriesTimerId, out record!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a timer as moved.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkMoved(string timerId, string destinationPath)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_data.Timers.TryGetValue(timerId, out var record))
|
||||||
|
{
|
||||||
|
record.Moved = true;
|
||||||
|
record.DestinationPath = destinationPath;
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes timer ownership.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveTimer(string timerId)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_data.Timers.Remove(timerId))
|
||||||
|
{
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes series timer ownership.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveSeriesTimer(string seriesTimerId)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_data.SeriesTimers.Remove(seriesTimerId))
|
||||||
|
{
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a snapshot of timer ownership records that are not yet moved.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<(string TimerId, OwnershipRecord Record)> GetPendingTimers()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _data.Timers
|
||||||
|
.Where(kv => !kv.Value.Moved)
|
||||||
|
.Select(kv => (kv.Key, kv.Value))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(_storePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(_storePath);
|
||||||
|
var data = JsonSerializer.Deserialize<OwnershipData>(json, JsonOptions);
|
||||||
|
if (data is not null)
|
||||||
|
{
|
||||||
|
_data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to load ownership store from {Path}", _storePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(_data, JsonOptions);
|
||||||
|
File.WriteAllText(_storePath, json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save ownership store to {Path}", _storePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ownership persistence root.
|
||||||
|
/// </summary>
|
||||||
|
public class OwnershipData
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets timer ownership keyed by timer id.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, OwnershipRecord> Timers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets series timer ownership keyed by series timer id.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, OwnershipRecord> SeriesTimers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ownership for a timer or series timer.
|
||||||
|
/// </summary>
|
||||||
|
public class OwnershipRecord
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Jellyfin user id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Jellyfin username (folder name).
|
||||||
|
/// </summary>
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the parent series timer id when applicable.
|
||||||
|
/// </summary>
|
||||||
|
public string? SeriesTimerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the program/timer name.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the scheduled start time (UTC).
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the recording was already moved.
|
||||||
|
/// </summary>
|
||||||
|
public bool Moved { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the destination path after a successful move.
|
||||||
|
/// </summary>
|
||||||
|
public string? DestinationPath { get; set; }
|
||||||
|
}
|
||||||
+368
@@ -0,0 +1,368 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.LiveTv;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.LiveTv;
|
||||||
|
using MediaBrowser.Model.Querying;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.PersonalRecordings.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves completed recordings into per-user folders under the configured target root.
|
||||||
|
/// </summary>
|
||||||
|
public class RecordingMoverHost : IHostedService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<RecordingMoverHost> _logger;
|
||||||
|
private readonly ILiveTvManager _liveTvManager;
|
||||||
|
private readonly ILibraryMonitor _libraryMonitor;
|
||||||
|
private readonly OwnershipStore _ownershipStore;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _loop;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RecordingMoverHost"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public RecordingMoverHost(
|
||||||
|
ILogger<RecordingMoverHost> logger,
|
||||||
|
ILiveTvManager liveTvManager,
|
||||||
|
ILibraryMonitor libraryMonitor,
|
||||||
|
OwnershipStore ownershipStore)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_liveTvManager = liveTvManager;
|
||||||
|
_libraryMonitor = libraryMonitor;
|
||||||
|
_ownershipStore = ownershipStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
_loop = Task.Run(() => RunAsync(_cts.Token), CancellationToken.None);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_cts is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _cts.CancelAsync().ConfigureAwait(false);
|
||||||
|
if (_loop is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _loop.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cts?.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error while processing personal recordings");
|
||||||
|
}
|
||||||
|
|
||||||
|
var delay = GetPollInterval();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(delay), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetPollInterval()
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
var seconds = config?.PollIntervalSeconds ?? 60;
|
||||||
|
return Math.Clamp(seconds, 15, 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
if (config is null || !config.Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetRoot = config.TargetRootPath?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(targetRoot))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Personal Recordings: TargetRootPath is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = _ownershipStore.GetPendingTimers();
|
||||||
|
if (pending.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryResult<TimerInfoDto> timers;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
timers = await _liveTvManager.GetTimers(new TimerQuery(), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to query Live TV timers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var completedById = (timers.Items ?? Array.Empty<TimerInfoDto>())
|
||||||
|
.Where(t => t.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(t.Id))
|
||||||
|
.ToDictionary(t => t.Id!, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
IReadOnlyList<BaseItemDto> recordings = Array.Empty<BaseItemDto>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dtoOptions = new DtoOptions(true)
|
||||||
|
{
|
||||||
|
Fields = new List<ItemFields> { ItemFields.Path }
|
||||||
|
};
|
||||||
|
var result = await _liveTvManager.GetRecordingsAsync(
|
||||||
|
new RecordingQuery
|
||||||
|
{
|
||||||
|
Status = RecordingStatus.Completed,
|
||||||
|
EnableTotalRecordCount = false
|
||||||
|
},
|
||||||
|
dtoOptions).ConfigureAwait(false);
|
||||||
|
recordings = result.Items ?? Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to query completed recordings");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (timerId, ownership) in pending)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!completedById.TryGetValue(timerId, out var timer))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourcePath = FindRecordingPath(timer, ownership, recordings);
|
||||||
|
if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Completed timer {TimerId} ({Name}) has no local file yet",
|
||||||
|
timerId,
|
||||||
|
timer.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeUser = SanitizeFolderName(ownership.Username);
|
||||||
|
if (string.IsNullOrWhiteSpace(safeUser))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid username for timer {TimerId}", timerId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userRoot = Path.GetFullPath(Path.Combine(targetRoot, safeUser));
|
||||||
|
var fullSource = Path.GetFullPath(sourcePath);
|
||||||
|
|
||||||
|
if (fullSource.StartsWith(userRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(Path.GetDirectoryName(fullSource), userRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_ownershipStore.MarkMoved(timerId, fullSource);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(userRoot);
|
||||||
|
var destination = Path.Combine(userRoot, Path.GetFileName(fullSource));
|
||||||
|
destination = EnsureUniquePath(destination);
|
||||||
|
|
||||||
|
if (config.DryRun)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"DryRun: would move {Source} -> {Destination}",
|
||||||
|
fullSource,
|
||||||
|
destination);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MoveFile(fullSource, destination);
|
||||||
|
TryMoveSidecar(fullSource, destination);
|
||||||
|
_ownershipStore.MarkMoved(timerId, destination);
|
||||||
|
_libraryMonitor.ReportFileSystemChanged(fullSource);
|
||||||
|
_libraryMonitor.ReportFileSystemChanged(destination);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Moved recording for {Username}: {Source} -> {Destination}",
|
||||||
|
ownership.Username,
|
||||||
|
fullSource,
|
||||||
|
destination);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to move {Source} to {Destination}", fullSource, destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindRecordingPath(
|
||||||
|
TimerInfoDto timer,
|
||||||
|
OwnershipRecord ownership,
|
||||||
|
IReadOnlyList<BaseItemDto> recordings)
|
||||||
|
{
|
||||||
|
var name = timer.Name ?? ownership.Name;
|
||||||
|
var start = timer.StartDate != default ? timer.StartDate : ownership.StartDate;
|
||||||
|
|
||||||
|
BaseItemDto? match = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
match = recordings.FirstOrDefault(r =>
|
||||||
|
!string.IsNullOrWhiteSpace(r.Path)
|
||||||
|
&& string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& MatchesStartWindow(r, start));
|
||||||
|
}
|
||||||
|
|
||||||
|
match ??= recordings.FirstOrDefault(r =>
|
||||||
|
!string.IsNullOrWhiteSpace(r.Path)
|
||||||
|
&& !string.IsNullOrWhiteSpace(name)
|
||||||
|
&& (r.Name?.Contains(name, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||||
|
|
||||||
|
return match?.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesStartWindow(BaseItemDto item, DateTime? start)
|
||||||
|
{
|
||||||
|
if (!start.HasValue)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.PremiereDate.HasValue
|
||||||
|
&& Math.Abs((item.PremiereDate.Value - start.Value).TotalMinutes) < 30)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.DateCreated is DateTime created)
|
||||||
|
{
|
||||||
|
return Math.Abs((created - start.Value).TotalHours) < 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MoveFile(string source, string destination)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Move(source, destination);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
File.Copy(source, destination, overwrite: false);
|
||||||
|
File.Delete(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryMoveSidecar(string sourceMediaPath, string destinationMediaPath)
|
||||||
|
{
|
||||||
|
var sourceDir = Path.GetDirectoryName(sourceMediaPath);
|
||||||
|
var destDir = Path.GetDirectoryName(destinationMediaPath);
|
||||||
|
if (sourceDir is null || destDir is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseName = Path.GetFileNameWithoutExtension(sourceMediaPath);
|
||||||
|
foreach (var sidecar in Directory.EnumerateFiles(sourceDir, baseName + ".*"))
|
||||||
|
{
|
||||||
|
if (string.Equals(sidecar, sourceMediaPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dest = Path.Combine(destDir, Path.GetFileName(sidecar));
|
||||||
|
dest = EnsureUniquePath(dest);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MoveFile(sidecar, dest);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort for nfo/jpg/etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EnsureUniquePath(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dir = Path.GetDirectoryName(path) ?? ".";
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
for (var i = 1; i < 1000; i++)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir, $"{name} ({i}){ext}");
|
||||||
|
if (!File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(dir, $"{name}-{Guid.NewGuid():N}{ext}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFolderName(string username)
|
||||||
|
{
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
var cleaned = new string(username.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray());
|
||||||
|
return cleaned.Trim().TrimEnd('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
using System;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Events;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.LiveTv;
|
||||||
|
using MediaBrowser.Model.LiveTv;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.PersonalRecordings.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures which Jellyfin user created Live TV timers / series timers.
|
||||||
|
/// </summary>
|
||||||
|
public class TimerOwnershipHost : IHostedService
|
||||||
|
{
|
||||||
|
private const string UserIdClaimType = "Jellyfin-UserId";
|
||||||
|
|
||||||
|
private readonly ILogger<TimerOwnershipHost> _logger;
|
||||||
|
private readonly ILiveTvManager _liveTvManager;
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly OwnershipStore _ownershipStore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TimerOwnershipHost"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public TimerOwnershipHost(
|
||||||
|
ILogger<TimerOwnershipHost> logger,
|
||||||
|
ILiveTvManager liveTvManager,
|
||||||
|
IUserManager userManager,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
OwnershipStore ownershipStore)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_liveTvManager = liveTvManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_ownershipStore = ownershipStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_liveTvManager.TimerCreated += OnTimerCreated;
|
||||||
|
_liveTvManager.SeriesTimerCreated += OnSeriesTimerCreated;
|
||||||
|
_liveTvManager.TimerCancelled += OnTimerCancelled;
|
||||||
|
_liveTvManager.SeriesTimerCancelled += OnSeriesTimerCancelled;
|
||||||
|
_logger.LogInformation("Personal Recordings: listening for Live TV timer events");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_liveTvManager.TimerCreated -= OnTimerCreated;
|
||||||
|
_liveTvManager.SeriesTimerCreated -= OnSeriesTimerCreated;
|
||||||
|
_liveTvManager.TimerCancelled -= OnTimerCancelled;
|
||||||
|
_liveTvManager.SeriesTimerCancelled -= OnSeriesTimerCancelled;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var timerId = e.Argument.Id;
|
||||||
|
if (string.IsNullOrWhiteSpace(timerId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimerInfoDto? timer = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
timer = await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not load timer {TimerId} after create", timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var seriesTimerId = timer?.SeriesTimerId;
|
||||||
|
if (TryResolveUser(out var userId, out var username))
|
||||||
|
{
|
||||||
|
_ownershipStore.SetTimerOwner(
|
||||||
|
timerId,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
seriesTimerId,
|
||||||
|
timer?.Name,
|
||||||
|
timer?.StartDate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(seriesTimerId)
|
||||||
|
&& _ownershipStore.TryGetSeriesOwner(seriesTimerId, out var seriesOwner))
|
||||||
|
{
|
||||||
|
_ownershipStore.SetTimerOwner(
|
||||||
|
timerId,
|
||||||
|
seriesOwner.UserId,
|
||||||
|
seriesOwner.Username,
|
||||||
|
seriesTimerId,
|
||||||
|
timer?.Name ?? seriesOwner.Name,
|
||||||
|
timer?.StartDate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Timer {TimerId} created without resolvable user context; recording will not be moved",
|
||||||
|
timerId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling TimerCreated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnSeriesTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var seriesTimerId = e.Argument.Id;
|
||||||
|
if (string.IsNullOrWhiteSpace(seriesTimerId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SeriesTimerInfoDto? seriesTimer = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
seriesTimer = await _liveTvManager.GetSeriesTimer(seriesTimerId, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not load series timer {SeriesTimerId} after create", seriesTimerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryResolveUser(out var userId, out var username))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Series timer {SeriesTimerId} created without resolvable user context",
|
||||||
|
seriesTimerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ownershipStore.SetSeriesOwner(seriesTimerId, userId, username, seriesTimer?.Name);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling SeriesTimerCreated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(e.Argument.Id))
|
||||||
|
{
|
||||||
|
_ownershipStore.RemoveTimer(e.Argument.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling TimerCancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSeriesTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(e.Argument.Id))
|
||||||
|
{
|
||||||
|
_ownershipStore.RemoveSeriesTimer(e.Argument.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling SeriesTimerCancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryResolveUser(out Guid userId, out string username)
|
||||||
|
{
|
||||||
|
userId = default;
|
||||||
|
username = string.Empty;
|
||||||
|
|
||||||
|
var principal = _httpContextAccessor.HttpContext?.User;
|
||||||
|
if (principal is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var claim = principal.FindFirst(UserIdClaimType)?.Value
|
||||||
|
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(claim) || !Guid.TryParse(claim, out userId) || userId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
if (user is null || string.IsNullOrWhiteSpace(user.Username))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
username = user.Username;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Personal Recordings (Jellyfin plugin)
|
||||||
|
|
||||||
|
Tracks which Jellyfin user scheduled a Live TV recording and moves completed files into `{TargetRoot}/{Username}/` (default `/recordings/Bram`).
|
||||||
|
|
||||||
|
Jellyfin core does not store timer ownership. This plugin captures the authenticated user on `TimerCreated` / `SeriesTimerCreated` (via `HttpContext`) and later moves completed recordings.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Jellyfin **10.11.x** (ABI `10.11.0.0`, `net9.0`)
|
||||||
|
- Live TV recordings enabled
|
||||||
|
- Writable target root (e.g. mount `/recordings` into the Jellyfin container)
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### Catalog (Gitea)
|
||||||
|
|
||||||
|
1. Build/release via tag (see below), or wait for CI
|
||||||
|
2. In Jellyfin: **Dashboard → Plugins → Repositories → +**
|
||||||
|
3. Repository URL (raw catalog):
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<your-gitea>/<owner>/<repo>/raw/branch/<default>/Jellyfin/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Catalog → install **Personal Recordings** → restart Jellyfin
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build -c Release
|
||||||
|
# copy Jellyfin.Plugin.PersonalRecordings/bin/Release/net9.0/Jellyfin.Plugin.PersonalRecordings.dll
|
||||||
|
# into <jellyfin-data>/plugins/PersonalRecordings/
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Jellyfin.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Setting | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| Enabled | true | Master switch |
|
||||||
|
| Target root path | `/recordings` | Files go to `{path}/{Username}/` |
|
||||||
|
| Poll interval | 60 | Seconds between completion checks |
|
||||||
|
| Dry run | false | Log only, no file moves |
|
||||||
|
|
||||||
|
## Release (monorepo CI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag jellyfin/jellyfin-plugin-personal-recordings/v1.0.0
|
||||||
|
git push origin jellyfin/jellyfin-plugin-personal-recordings/v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflow: [`.gitea/workflows/release_jellyfin_plugin.yml`](../../.gitea/workflows/release_jellyfin_plugin.yml)
|
||||||
|
|
||||||
|
## New plugins under `Jellyfin/`
|
||||||
|
|
||||||
|
Use **Actions → Scaffold Jellyfin plugin** (`scaffold_jellyfin_plugin.yml`) with a slug like `jellyfin-plugin-myfeature`. That creates boilerplate from `Jellyfin/_template/` and registers the plugin in `Jellyfin/manifest.json`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Series child timers inherit ownership from the series timer when there is no HTTP user.
|
||||||
|
- Moves run only when timer status is **Completed**.
|
||||||
|
- Sidecar files (`*.nfo`, images with the same basename) are moved best-effort.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: "Personal Recordings"
|
||||||
|
guid: "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a"
|
||||||
|
version: "1.0.0.0"
|
||||||
|
targetAbi: "10.11.0.0"
|
||||||
|
framework: "net9.0"
|
||||||
|
owner: "bram"
|
||||||
|
overview: "Move completed Live TV recordings into per-user folders"
|
||||||
|
description: >
|
||||||
|
Tracks which Jellyfin user scheduled a Live TV recording and moves
|
||||||
|
completed files into {TargetRoot}/{Username}/ (default /recordings/Bram).
|
||||||
|
category: "Live TV"
|
||||||
|
artifacts:
|
||||||
|
- "Jellyfin.Plugin.PersonalRecordings.dll"
|
||||||
|
changelog: |-
|
||||||
|
- Initial release: ownership tracking + completed recording moves
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"guid": "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a",
|
||||||
|
"name": "Personal Recordings",
|
||||||
|
"description": "Tracks which Jellyfin user scheduled a Live TV recording and moves completed files into /recordings/<Username>/.",
|
||||||
|
"overview": "Move completed Live TV recordings into per-user folders",
|
||||||
|
"owner": "bram",
|
||||||
|
"category": "Live TV",
|
||||||
|
"versions": []
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user