How moving from local VLC/PotPlayer control to Jellyfin required a second productization pass across configuration, dependencies, clean installs, signing, and antivirus trust.
When I first turned Smart Home Cinema – Voice Control from a personal Windows automation project into software other people could use, the project was centered on local movie playback with VLC and PotPlayer.
That earlier work taught me that reliable automation and distributable software are different engineering problems. In my first Coder Legion article, I focused mainly on the local-control side of that story: making voice-driven playback predictable, dealing with player behavior, reducing hidden assumptions, and moving from personal automation toward something that could behave like a product.
The productization side appeared there too, but mostly as one part of a broader reliability story.
Jellyfin gave me a reason to return to that subject in much more detail.
By the time Smart Home Cinema – Voice Control expanded to Jellyfin, the project already had an established Windows productization model. What changed was the environment: Jellyfin introduced a new set of configuration, runtime, and deployment assumptions that the earlier VLC/PotPlayer branch did not have to handle.
Local VLC/PotPlayer control kept the player, the automation layer, and the media workflow on the Windows machine. Jellyfin introduced a server/client architecture. Playback could happen on a television while the Windows control software ran elsewhere. The product now had to deal with server configuration, discovered clients, target selection, additional runtime dependencies, and a release path that had to work across that larger system.
At the stage covered here, the two Jellyfin editions being prepared for distribution were Voice Assistant and Local Voice. Voice Assistant accepted commands through an external assistant path. Local Voice used a microphone connected to the PC and local speech recognition. Both controlled Jellyfin, but they entered the system through different input paths and required different runtime components.
The core Jellyfin control path already worked. A command could reach the Windows control layer, pass through the Jellyfin integration, and produce the expected action on the selected television.
So the question was no longer whether working software needed to be productized.
It was this:
What does product-ready mean when the architecture itself has changed?
Some lessons from the classic branch carried over. Others appeared only because Jellyfin introduced assumptions that had never existed in the local-player version.
A Working EXE Is Not a Release
One of the first Jellyfin-specific problems was surprisingly basic: knowing which build was actually the release build.
During development, several workspaces and intermediate outputs had accumulated. That is normal while experimenting, testing fixes, and separating editions. It also creates a release risk.
An executable can launch successfully and still be the wrong executable to ship.
The important questions became:
- Which source tree is authoritative?
- Which outputs were built from that source?
- Which files belong in the final payload?
- Which files are development artifacts?
- Which components are intentionally different between editions?
- Can the release be reconstructed from a controlled staging area rather than from whatever happens to be on the workstation?
The solution was to treat release provenance as part of the Jellyfin release process.
Voice Assistant and Local Voice received separate authoritative release-candidate staging areas. The final installers were built from those controlled payloads rather than from arbitrary development directories.
This sounds procedural, but it changed the meaning of "the build works."
A release candidate now had an identity. I could say where it came from, what belonged in it, and why.
That mattered because the two Jellyfin editions shared some components while legitimately differing in others. The Setup Tool and activation component could be identical across both editions. The control components were allowed to diverge where Local Voice required additional behavior.
Shared architecture did not require every binary to have the same hash.
Familiar Problem, New Configuration
Configuration itself was not new to me. The classic editions had already taught me that a product cannot rely on the developer manually setting up the environment.
Jellyfin changed what had to be configured.
On my development machine, I already knew:
- the Jellyfin server I was testing against;
- how the application should connect to it;
- which detected client belonged to which physical television;
- which target was currently selected;
- where supporting tools were installed;
- which folders contained runtime assets.
That knowledge was specific to the new architecture. A distributable Jellyfin edition needed a way to establish it on another machine.
The Jellyfin Setup Tool became the user-facing boundary for that job.
The user could provide the Jellyfin connection details, scan the available client sessions, identify a television, save configured targets, choose the active target, and reload the configuration later.
The Identify step was especially useful. A list of discovered clients is meaningful to a developer who has spent hours watching session data. It is much less useful to someone standing in a living room trying to answer a simple question:
"Which one of these entries is my TV?"
The product had to bridge that gap.
That led to a rule I still find useful:
Any knowledge required for normal operation that exists only in the developer's head is unfinished product work.
Runtime State Needs a Stable Home
The Jellyfin branch introduced state that had to remain discoverable after installation and restart: server configuration, target information, shared runtime data, and other machine-level settings used by the installed software.
Installed binaries and runtime state serve different purposes. The binaries belong in the installation area. Configuration and machine-level application data need a stable location that does not depend on:
- the source tree;
- the current working directory;
- a build folder;
- the account that compiled the program;
- a folder that happened to exist during testing.
The Jellyfin editions therefore kept persistent runtime information in shared application data outside the installation directory.
The exact file layout is not the interesting part. The important point is that the installed program could discover its own state without inheriting paths from my workstation.
This also created a cleaner boundary between shared responsibilities and edition-specific behavior. Voice Assistant and Local Voice could use common machine-level state where the responsibility was genuinely shared, while keeping their own runtime components where their behavior differed.
FFmpeg Was Easy Until It Had to Ship
During Jellyfin development, FFmpeg was simple.
It existed.
The Jellyfin Connector could use it when it needed to generate video assets for parts of the TV-facing experience. As long as the tool was present in the expected environment, there was little reason to think about it.
Distribution changed the problem completely.
A user's machine might not have FFmpeg at all. If it did, the version or location could be different. Relying on the system PATH would make the product depend on unrelated software choices made by the user.
The dependency needed a predictable lifecycle.
An early approach treated the FFmpeg binaries as files that could simply be bundled with the payload. That approach was replaced with a controlled dependency-bootstrap flow.
The installer could obtain the required package, verify its integrity, extract the required tools, place them in a deterministic shared runtime location, and clean up the temporary files afterward.
That small sentence hides several failure modes:
download fails
checksum does not match
archive extraction fails
required executable is missing
copy fails
cleanup fails
Those problems had nothing to do with whether the Connector itself could generate an asset correctly. They appeared because a tool that was simply present during development now had to become a dependable part of somebody else's installation.
FFmpeg stopped being merely "a tool the Connector uses." Its delivery and failure behavior became part of the release system.
Failure Paths Are Part of the Product
The dependency bootstrap also produced a useful installer bug.
The happy path worked.
The failure path had a problem.
In one error-handling branch, the order between cleanup and error reporting meant that cleanup could be skipped when the operation failed. The fix was straightforward once found: cleanup had to happen before the operation that propagated the error.
The more important change was in testing.
I started treating negative cases as release tests:
bad checksum
→ dependency is not installed
→ temporary files are cleaned
failed download
→ dependency is not installed
→ temporary files are cleaned
A partial installation must not look like a successful one.
Once the installer itself can download, validate, extract, and place runtime components, its own failure behavior becomes part of product reliability.
The useful question is:
What state does the machine end up in when the operation fails halfway through?
For a distributable dependency, that answer matters as much as the success path.
Correct Work Can Still Look Broken
Another FFmpeg issue was not a failure at all.
Preparing the dependency could take several minutes.
From the installer's point of view, work was happening normally. From the user's point of view, the window could appear frozen.
That is enough to create a support problem.
A user does not see internal progress unless the product makes it visible. Waiting without feedback is easy to interpret as a crash.
The installer was updated with explicit messages explaining that the dependency download and preparation could take time. The setup page also changed its status text while the work was running.
Nothing about the underlying FFmpeg process became faster.
The user simply gained enough information to understand the state of the installation.
That was a small change compared with the architecture, but it belonged in the same release process.
Documentation Became Part of the Payload
The final staging areas included the appropriate README, license information, third-party notices, and edition-specific documentation.
A file relevant to the Voice Assistant edition was deliberately excluded from Local Voice rather than copied into both packages for convenience.
That distinction matters when one product family contains editions with overlapping components but different runtime paths.
A clean release is not the folder with the largest number of files. It is the smallest complete payload that accurately represents the edition being installed.
Documentation and third-party material were therefore validated alongside the executables rather than added casually at the end.
Clean Install Now Had to Reach the Television
Clean-install testing was another familiar discipline whose meaning changed with Jellyfin.
For a local Windows playback product, the important runtime stays close to the machine being tested.
The Jellyfin editions extended the path beyond it.
For the Jellyfin editions of Smart Home Cinema – Voice Control, the clean-install pass had to exercise the complete operational chain:
- installation and the expected wizard flow;
- initialization of required product state;
- configuration through the Setup Tool;
- Jellyfin server connection;
- TV discovery;
- Identify;
- saving a configured TV;
- active-target persistence;
- reloading the configuration;
- launching the Connector;
- executing representative Jellyfin commands.
Local Voice added another layer:
- Local Voice Engine startup;
- microphone path;
- wake state;
- sleep state;
- status behavior;
- representative commands through the local speech path.
The definition of success had expanded.
A clean installation passed only when the installed product could make the full journey from configuration to visible behavior on the real Jellyfin client.
A completed installer was merely the beginning of that test.
This was one of the clearest differences between the two productization passes. The classic branch had taught me to validate the software outside my development environment. Jellyfin required that validation to cross a server/client boundary and end on a television.
Signing Was Not the Finish Line
The final Windows binaries and installers were code-signed.
That created another release rule:
Test the artifact that will actually be distributed.
The signed build was retested after signing.
It is easy to imagine the release sequence as:
build
test
sign
upload
In practice, the signed artifact is the one the user receives, so it deserves its own final verification.
The signed Voice Assistant and Local Voice installers passed their clean-install regression.
At that point, the Jellyfin branch had installable editions that were packaged, configurable through a user-facing tool, dependency-aware, documented, clean-install tested, and signed.
The release still had one more problem.
Then Antivirus Called It a Reverse Shell
The outer installers looked clean in VirusTotal.
One internal component did not.
The shared Jellyfin Setup Tool accumulated multiple heuristic detections, including labels associated with generic malware and reverse-shell behavior.
That was serious enough to stop the release.
The Setup Tool was a configuration application. Its real job was to communicate with the Jellyfin server configured by the user, discover client sessions, send an Identify action, and save the selected TVs and target state.
I did not want to dismiss the detections simply because I knew what the program was supposed to do.
The component was audited.
The review did not find the behavior suggested by those labels: no reverse-shell implementation, no arbitrary shell launcher, no persistence mechanism, and no dynamic payload behavior of the kind the detections implied.
The sample was also submitted to an antivirus vendor for analysis. Bitdefender later confirmed that the file was clean and corrected its classification.
That created another release decision.
I did not want to rewrite a working component purely to obtain a different hash. I also did not want to add unnecessary obfuscation just to make static analysis harder.
Either choice could have made the executable less explainable without improving its actual security.
The release remained blocked while the classification issue was being remediated.
That was an odd stage of the project. The Jellyfin editions were functional, packaged, signed, and validated through clean installation, yet they were still not ready for public distribution.
Productization Has to Follow the Architecture
The classic VLC/PotPlayer branch had already taught me that personal automation and product software are different disciplines.
Jellyfin did not overturn that lesson. It exposed another layer of it.
Some productization work carried over directly: explicit configuration, documentation, stable runtime state, clean-install testing, signed release artifacts.
Other work existed because the architecture had changed:
- authoritative Jellyfin release staging;
- server and target configuration;
- client discovery and identification;
- a runtime path that ended on a television;
- FFmpeg dependency bootstrap and integrity checking;
- dependency failure cleanup;
- post-signing end-to-end regression;
- antivirus investigation that became a release gate.
For Smart Home Cinema – Voice Control, this second productization pass was not a repeat of the first one. The underlying discipline was familiar, but the new architecture created a new set of assumptions that had to be made explicit.
The earlier branch taught me to remove assumptions that belonged only to my own PC.
Jellyfin showed me how quickly new ones appear when the product enters a different architecture.
The Connector already worked before this stage began. The engineering work that followed was about finding everything around it that still depended on my development environment and turning those dependencies into explicit product behavior.
The first branch taught me that the development machine cannot be part of the product specification.
Jellyfin taught me the follow-up lesson: when the architecture changes, productization has to chase the hidden assumptions all over again.