Welcome to the fifth chapter of the seerr tutorial!
In the previous chapter, External Service Integrations (The "Connectors"), we learned how to build "Diplomats" that can talk to external services like TMDB (for data) and Radarr/Sonarr (for downloads).
Now we have the tools, but we need a manager to use them. We can't just let every user send commands directly to Radarr. What if they request a movie that is already downloaded? What if they request 100 movies in one minute?
We need a central logic handler. We call this the Media Request Workflow.
Imagine staying at a luxury hotel. If you want a specific meal, you don't run into the kitchen and start cooking, nor do you call the grocery store yourself. You call the Concierge.
The Concierge performs a complex workflow behind the scenes:
In seerr, the Media Request Workflow acts as this Concierge. It stands between the user clicking "Request" and the system actually adding the movie to the download queue.
Let's follow the journey of a user requesting the movie "Inception".
In many applications, the logic is stored in the Controller (the traffic cop). In seerr, we move this logic into the Entity itself. This is called a "Fat Model." The MediaRequest class isn't just a database shape; it contains the smarts to validate itself.
We use layers of defense:
The workflow is designed so that if any check fails (e.g., quota full), the entire process stops, and an error is thrown. The request is only saved if everything is perfect.
The journey begins in the API Controller. Because our logic is inside the "Fat Model," the controller code is surprisingly simple.
Open server/routes/request.ts.
The controller receives the POST request and simply hands it off to the MediaRequest class.
// server/routes/request.ts
requestRoutes.post('/', async (req, res, next) => {
try {
// Pass the request data (body) and the current user to the logic
const request = await MediaRequest.request(req.body, req.user);
// If successful, return the new request
return res.status(201).json(request);
} catch (error) {
// If logic fails (quota, permission), send error to frontend
next({ status: 500, message: error.message });
}
});
Explanation: The controller acts like a receptionist. It doesn't know the rules; it just hands the paperwork to the manager (MediaRequest.request).
This is where the magic happens. We will look inside the MediaRequest.request static method found in server/entity/MediaRequest.ts.
Let's break down the code in server/entity/MediaRequest.ts.
First, the "Concierge" checks the user's ID card.
// server/entity/MediaRequest.ts inside static request()
// Check if user has the specific permission to request movies
if (!requestUser.hasPermission(Permission.REQUEST_MOVIE)) {
// If not, throw a specific error
throw new RequestPermissionError(
'You do not have permission to make movie requests.'
);
}
Explanation: We use the hasPermission method from the User entity (covered in Data Models & ORM). If false, the code stops immediately.
Next, we check if the user has reached their limit.
// server/entity/MediaRequest.ts
// Calculate usage from the database
const quotas = await requestUser.getQuota();
// If the quota is restricted/full...
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
throw new QuotaRestrictedError('Movie Quota exceeded.');
}
Explanation: This prevents users from spamming the system and filling up the hard drives.
The user sent an ID (e.g., 550), but we need to verify what that is. We use our "Diplomat" from Chapter 4.
// server/entity/MediaRequest.ts
const tmdb = new TheMovieDb(); // Initialize the connector
// Ask TMDB for the details
const tmdbMedia = await tmdb.getMovie({
movieId: requestBody.mediaId
});
Explanation: We trust TMDB to tell us the correct title and release date, ensuring our database data is clean.
We must ensure we don't download the same movie twice.
// server/entity/MediaRequest.ts
const existing = await requestRepository.findOne({
where: {
media: { tmdbId: tmdbMedia.id }
}
});
if (existing) {
throw new DuplicateMediaRequestError('Request already exists.');
}
Explanation: We query the database to see if a request with this specific TMDB ID already exists.
Finally, if all checks pass, we prepare the request to be saved. We also decide immediately if it should be PENDING (wait for admin) or APPROVED (download now).
// server/entity/MediaRequest.ts
const newRequest = new MediaRequest({
media: media, // The movie details
requestedBy: requestUser,
// If user is "Trusted", auto-approve. Otherwise, set to Pending.
status: user.hasPermission(Permission.AUTO_APPROVE)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING
});
// Save to database
await requestRepository.save(newRequest);
Explanation: This is a crucial feature. Trusted users (like admins) skip the approval queue. Regular users must wait for an admin to click "Approve."
In this chapter, we built the "Concierge" service for seerr. We learned that:
Now that the request is successfully saved in the database, the system needs to actually talk to Radarr/Sonarr to track the download status. How does seerr know when the movie finishes downloading so it can tell the user "It's ready"?
Next Chapter: Library Scanners & Synchronization
Generated by Code IQ