From 4baee3fafed55fcc66f11d9c6cb99c5453b20560 Mon Sep 17 00:00:00 2001 From: Bram Date: Sat, 22 Aug 2026 22:00:28 +0200 Subject: [PATCH] test jellyfin plugin, would be nice --- .gitea/workflows/release_jellyfin_plugin.yml | 174 +++++++++ .gitea/workflows/scaffold_jellyfin_plugin.yml | 149 +++++++ .gitignore | 5 +- Jellyfin/README.md | 24 ++ .../PluginConfiguration.cs.template | 10 + .../Configuration/configPage.html.template | 22 ++ Jellyfin/_template/Plugin/Plugin.cs.template | 55 +++ .../_template/Plugin/Plugin.csproj.template | 30 ++ Jellyfin/_template/README.md.template | 22 ++ Jellyfin/_template/build.yaml.template | 14 + .../.gitignore | 5 + .../Jellyfin.Plugin.PersonalRecordings.sln | 34 ++ .../Configuration/PluginConfiguration.cs | 30 ++ .../Configuration/configPage.html | 79 ++++ .../Jellyfin.Plugin.PersonalRecordings.csproj | 33 ++ .../Plugin.cs | 58 +++ .../PluginServiceRegistrator.cs | 20 + .../Services/OwnershipStore.cs | 277 +++++++++++++ .../Services/RecordingMoverHost.cs | 368 ++++++++++++++++++ .../Services/TimerOwnershipHost.cs | 216 ++++++++++ .../README.md | 63 +++ .../build.yaml | 15 + Jellyfin/manifest.json | 11 + 23 files changed, 1713 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/release_jellyfin_plugin.yml create mode 100644 .gitea/workflows/scaffold_jellyfin_plugin.yml create mode 100644 Jellyfin/README.md create mode 100644 Jellyfin/_template/Plugin/Configuration/PluginConfiguration.cs.template create mode 100644 Jellyfin/_template/Plugin/Configuration/configPage.html.template create mode 100644 Jellyfin/_template/Plugin/Plugin.cs.template create mode 100644 Jellyfin/_template/Plugin/Plugin.csproj.template create mode 100644 Jellyfin/_template/README.md.template create mode 100644 Jellyfin/_template/build.yaml.template create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/.gitignore create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings.sln create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Configuration/PluginConfiguration.cs create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Configuration/configPage.html create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Jellyfin.Plugin.PersonalRecordings.csproj create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Plugin.cs create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/PluginServiceRegistrator.cs create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/OwnershipStore.cs create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/RecordingMoverHost.cs create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/TimerOwnershipHost.cs create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/README.md create mode 100644 Jellyfin/jellyfin-plugin-personal-recordings/build.yaml create mode 100644 Jellyfin/manifest.json diff --git a/.gitea/workflows/release_jellyfin_plugin.yml b/.gitea/workflows/release_jellyfin_plugin.yml new file mode 100644 index 0000000..6b99918 --- /dev/null +++ b/.gitea/workflows/release_jellyfin_plugin.yml @@ -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//v1.2.3 + if [[ ! "$TAG" =~ ^jellyfin/([a-z0-9-]+)/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "Tag must match jellyfin//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" diff --git a/.gitea/workflows/scaffold_jellyfin_plugin.yml b/.gitea/workflows/scaffold_jellyfin_plugin.yml new file mode 100644 index 0000000..49408c5 --- /dev/null +++ b/.gitea/workflows/scaffold_jellyfin_plugin.yml @@ -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" < 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 diff --git a/.gitignore b/.gitignore index 09bf5d6..4488c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *.pyc -__pycache__/ \ No newline at end of file +__pycache__/ +**/bin/ +**/obj/ +**/artifacts/ diff --git a/Jellyfin/README.md b/Jellyfin/README.md new file mode 100644 index 0000000..1a972b0 --- /dev/null +++ b/Jellyfin/README.md @@ -0,0 +1,24 @@ +# Jellyfin plugins + +Custom Jellyfin plugins for this monorepo. + +## Catalog URL + +Add this raw URL in Jellyfin → Plugins → Repositories: + +```text +https://///raw/branch//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//vX.Y.Z`) diff --git a/Jellyfin/_template/Plugin/Configuration/PluginConfiguration.cs.template b/Jellyfin/_template/Plugin/Configuration/PluginConfiguration.cs.template new file mode 100644 index 0000000..d1db1bf --- /dev/null +++ b/Jellyfin/_template/Plugin/Configuration/PluginConfiguration.cs.template @@ -0,0 +1,10 @@ +using MediaBrowser.Model.Plugins; + +namespace {{NAMESPACE}}.Configuration; + +/// +/// Plugin configuration. +/// +public class PluginConfiguration : BasePluginConfiguration +{ +} diff --git a/Jellyfin/_template/Plugin/Configuration/configPage.html.template b/Jellyfin/_template/Plugin/Configuration/configPage.html.template new file mode 100644 index 0000000..9ee0ebc --- /dev/null +++ b/Jellyfin/_template/Plugin/Configuration/configPage.html.template @@ -0,0 +1,22 @@ + + + + + {{PLUGIN_NAME}} + + +
+
+
+

{{PLUGIN_DESCRIPTION}}

+

No settings yet.

+
+
+ +
+ + diff --git a/Jellyfin/_template/Plugin/Plugin.cs.template b/Jellyfin/_template/Plugin/Plugin.cs.template new file mode 100644 index 0000000..dea7ace --- /dev/null +++ b/Jellyfin/_template/Plugin/Plugin.cs.template @@ -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}}; + +/// +/// {{PLUGIN_NAME}} plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "{{PLUGIN_NAME}}"; + + /// + public override string Description => "{{PLUGIN_DESCRIPTION}}"; + + /// + public override Guid Id => Guid.Parse("{{PLUGIN_GUID}}"); + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.configPage.html", + GetType().Namespace) + } + ]; + } +} diff --git a/Jellyfin/_template/Plugin/Plugin.csproj.template b/Jellyfin/_template/Plugin/Plugin.csproj.template new file mode 100644 index 0000000..7a45e97 --- /dev/null +++ b/Jellyfin/_template/Plugin/Plugin.csproj.template @@ -0,0 +1,30 @@ + + + + net9.0 + {{NAMESPACE}} + {{ASSEMBLY_NAME}} + enable + enable + true + false + 1.0.0.0 + 1.0.0.0 + 1.0.0.0 + + + + + runtime + + + runtime + + + + + + + + + diff --git a/Jellyfin/_template/README.md.template b/Jellyfin/_template/README.md.template new file mode 100644 index 0000000..a36bf03 --- /dev/null +++ b/Jellyfin/_template/README.md.template @@ -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). diff --git a/Jellyfin/_template/build.yaml.template b/Jellyfin/_template/build.yaml.template new file mode 100644 index 0000000..34ed147 --- /dev/null +++ b/Jellyfin/_template/build.yaml.template @@ -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 diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/.gitignore b/Jellyfin/jellyfin-plugin-personal-recordings/.gitignore new file mode 100644 index 0000000..f38dcbc --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +artifacts/ +*.user +.vs/ diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings.sln b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings.sln new file mode 100644 index 0000000..d225b1a --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings.sln @@ -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 diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Configuration/PluginConfiguration.cs b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..1c56605 --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Configuration/PluginConfiguration.cs @@ -0,0 +1,30 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.PersonalRecordings.Configuration; + +/// +/// Plugin configuration. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Gets or sets a value indicating whether the plugin is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the root folder for per-user recordings (e.g. /recordings). + /// Files are moved to {TargetRootPath}/{Username}/. + /// + public string TargetRootPath { get; set; } = "/recordings"; + + /// + /// Gets or sets the poll interval in seconds for completed recordings. + /// + public int PollIntervalSeconds { get; set; } = 60; + + /// + /// Gets or sets a value indicating whether to only log moves without changing files. + /// + public bool DryRun { get; set; } +} diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Configuration/configPage.html b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Configuration/configPage.html new file mode 100644 index 0000000..d4e3988 --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Configuration/configPage.html @@ -0,0 +1,79 @@ + + + + + Personal Recordings + + +
+
+
+
+
+ +
When enabled, completed recordings are moved into per-user folders.
+
+
+ + +
Recordings are moved to {TargetRootPath}/{Username}/ (default: /recordings)
+
+
+ + +
How often to check for completed recordings (minimum 15).
+
+
+ +
Log intended moves without changing files.
+
+
+ +
+
+
+
+ +
+ + diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Jellyfin.Plugin.PersonalRecordings.csproj b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Jellyfin.Plugin.PersonalRecordings.csproj new file mode 100644 index 0000000..cac9224 --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Jellyfin.Plugin.PersonalRecordings.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + Jellyfin.Plugin.PersonalRecordings + Jellyfin.Plugin.PersonalRecordings + enable + enable + true + false + 1.0.0.0 + 1.0.0.0 + 1.0.0.0 + + + + + runtime + + + runtime + + + runtime + + + + + + + + + diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Plugin.cs b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Plugin.cs new file mode 100644 index 0000000..88aba81 --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Plugin.cs @@ -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; + +/// +/// Moves completed Live TV recordings into per-user folders based on who scheduled them. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Application paths. + /// XML serializer. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "Personal Recordings"; + + /// + public override string Description => + "Tracks which user scheduled a Live TV recording and moves completed files into /recordings//."; + + /// + public override Guid Id => Guid.Parse("7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a"); + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.configPage.html", + GetType().Namespace) + } + ]; + } +} diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/PluginServiceRegistrator.cs b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/PluginServiceRegistrator.cs new file mode 100644 index 0000000..79b2f9d --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/PluginServiceRegistrator.cs @@ -0,0 +1,20 @@ +using Jellyfin.Plugin.PersonalRecordings.Services; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.PersonalRecordings; + +/// +/// Registers plugin services. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + serviceCollection.AddHostedService(); + } +} diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/OwnershipStore.cs b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/OwnershipStore.cs new file mode 100644 index 0000000..79d6a69 --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/OwnershipStore.cs @@ -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; + +/// +/// Persists timer/series ownership mappings. +/// +public class OwnershipStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ILogger _logger; + private readonly string _storePath; + private readonly object _lock = new(); + private OwnershipData _data = new(); + + /// + /// Initializes a new instance of the class. + /// + /// Application paths. + /// Logger. + public OwnershipStore(IApplicationPaths applicationPaths, ILogger logger) + { + _logger = logger; + var dir = Path.Combine(applicationPaths.PluginConfigurationsPath, "Jellyfin.Plugin.PersonalRecordings"); + Directory.CreateDirectory(dir); + _storePath = Path.Combine(dir, "ownership.json"); + Load(); + } + + /// + /// Saves ownership for a timer. + /// + 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); + } + + /// + /// Saves ownership for a series timer. + /// + 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); + } + + /// + /// Tries to get ownership for a timer, including inheritance from a series timer. + /// + 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; + } + } + + /// + /// Tries to get series timer ownership. + /// + public bool TryGetSeriesOwner(string seriesTimerId, out OwnershipRecord record) + { + lock (_lock) + { + return _data.SeriesTimers.TryGetValue(seriesTimerId, out record!); + } + } + + /// + /// Marks a timer as moved. + /// + public void MarkMoved(string timerId, string destinationPath) + { + lock (_lock) + { + if (_data.Timers.TryGetValue(timerId, out var record)) + { + record.Moved = true; + record.DestinationPath = destinationPath; + Save(); + } + } + } + + /// + /// Removes timer ownership. + /// + public void RemoveTimer(string timerId) + { + lock (_lock) + { + if (_data.Timers.Remove(timerId)) + { + Save(); + } + } + } + + /// + /// Removes series timer ownership. + /// + public void RemoveSeriesTimer(string seriesTimerId) + { + lock (_lock) + { + if (_data.SeriesTimers.Remove(seriesTimerId)) + { + Save(); + } + } + } + + /// + /// Returns a snapshot of timer ownership records that are not yet moved. + /// + 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(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); + } + } +} + +/// +/// Ownership persistence root. +/// +public class OwnershipData +{ + /// + /// Gets timer ownership keyed by timer id. + /// + public Dictionary Timers { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets series timer ownership keyed by series timer id. + /// + public Dictionary SeriesTimers { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +/// +/// Ownership for a timer or series timer. +/// +public class OwnershipRecord +{ + /// + /// Gets or sets the Jellyfin user id. + /// + public Guid UserId { get; set; } + + /// + /// Gets or sets the Jellyfin username (folder name). + /// + public string Username { get; set; } = string.Empty; + + /// + /// Gets or sets the parent series timer id when applicable. + /// + public string? SeriesTimerId { get; set; } + + /// + /// Gets or sets the program/timer name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the scheduled start time (UTC). + /// + public DateTime? StartDate { get; set; } + + /// + /// Gets or sets a value indicating whether the recording was already moved. + /// + public bool Moved { get; set; } + + /// + /// Gets or sets the destination path after a successful move. + /// + public string? DestinationPath { get; set; } +} diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/RecordingMoverHost.cs b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/RecordingMoverHost.cs new file mode 100644 index 0000000..73464bd --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/RecordingMoverHost.cs @@ -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; + +/// +/// Moves completed recordings into per-user folders under the configured target root. +/// +public class RecordingMoverHost : IHostedService, IDisposable +{ + private readonly ILogger _logger; + private readonly ILiveTvManager _liveTvManager; + private readonly ILibraryMonitor _libraryMonitor; + private readonly OwnershipStore _ownershipStore; + private CancellationTokenSource? _cts; + private Task? _loop; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + public RecordingMoverHost( + ILogger logger, + ILiveTvManager liveTvManager, + ILibraryMonitor libraryMonitor, + OwnershipStore ownershipStore) + { + _logger = logger; + _liveTvManager = liveTvManager; + _libraryMonitor = libraryMonitor; + _ownershipStore = ownershipStore; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _loop = Task.Run(() => RunAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + 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) + { + } + } + } + + /// + 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 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()) + .Where(t => t.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(t.Id)) + .ToDictionary(t => t.Id!, StringComparer.OrdinalIgnoreCase); + + IReadOnlyList recordings = Array.Empty(); + try + { + var dtoOptions = new DtoOptions(true) + { + Fields = new List { ItemFields.Path } + }; + var result = await _liveTvManager.GetRecordingsAsync( + new RecordingQuery + { + Status = RecordingStatus.Completed, + EnableTotalRecordCount = false + }, + dtoOptions).ConfigureAwait(false); + recordings = result.Items ?? Array.Empty(); + } + 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 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('.'); + } +} diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/TimerOwnershipHost.cs b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/TimerOwnershipHost.cs new file mode 100644 index 0000000..9e0a9bb --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/Jellyfin.Plugin.PersonalRecordings/Services/TimerOwnershipHost.cs @@ -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; + +/// +/// Captures which Jellyfin user created Live TV timers / series timers. +/// +public class TimerOwnershipHost : IHostedService +{ + private const string UserIdClaimType = "Jellyfin-UserId"; + + private readonly ILogger _logger; + private readonly ILiveTvManager _liveTvManager; + private readonly IUserManager _userManager; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly OwnershipStore _ownershipStore; + + /// + /// Initializes a new instance of the class. + /// + public TimerOwnershipHost( + ILogger logger, + ILiveTvManager liveTvManager, + IUserManager userManager, + IHttpContextAccessor httpContextAccessor, + OwnershipStore ownershipStore) + { + _logger = logger; + _liveTvManager = liveTvManager; + _userManager = userManager; + _httpContextAccessor = httpContextAccessor; + _ownershipStore = ownershipStore; + } + + /// + 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; + } + + /// + 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 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 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 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 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; + } +} diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/README.md b/Jellyfin/jellyfin-plugin-personal-recordings/README.md new file mode 100644 index 0000000..274effd --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/README.md @@ -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://///raw/branch//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 /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. diff --git a/Jellyfin/jellyfin-plugin-personal-recordings/build.yaml b/Jellyfin/jellyfin-plugin-personal-recordings/build.yaml new file mode 100644 index 0000000..24a7b18 --- /dev/null +++ b/Jellyfin/jellyfin-plugin-personal-recordings/build.yaml @@ -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 diff --git a/Jellyfin/manifest.json b/Jellyfin/manifest.json new file mode 100644 index 0000000..dfa04cc --- /dev/null +++ b/Jellyfin/manifest.json @@ -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//.", + "overview": "Move completed Live TV recordings into per-user folders", + "owner": "bram", + "category": "Live TV", + "versions": [] + } +]