Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,62 @@ jobs:
packages: write
steps: *build-image

release-notes:
name: Update GitHub release notes
needs: docker
if: github.server_url == 'https://github.com' && vars.REGISTRY == '' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Append release commits
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const tag = context.ref.replace('refs/tags/', '');
const repo = context.repo;
const { data: release } = await github.rest.repos.getReleaseByTag({ ...repo, tag });
const releases = await github.paginate(github.rest.repos.listReleases, {
...repo,
per_page: 100,
});
const published = releases.filter(r => !r.draft);
const baseTag = published[published.findIndex(r => r.id === release.id) + 1]?.tag_name;
const commits = baseTag
? await github.paginate(github.rest.repos.compareCommitsWithBasehead, {
...repo,
basehead: `${baseTag}...${tag}`,
per_page: 100,
})
: await github.paginate(github.rest.repos.listCommits, {
...repo,
sha: tag,
per_page: 100,
});
const changelog = commits
.filter(c => c.parents.length <= 1)
.map(c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}`)
.join('\n');

if (!changelog) {
core.setFailed('No commits found for the release notes.');
return;
}

const heading = baseTag ? `## Commits since ${baseTag}` : '## Commits';
const existingBody = (release.body || '').trim();
const body = existingBody
? `${existingBody}\n\n---\n\n${heading}\n\n${changelog}`
: `${heading}\n\n${changelog}`;

await github.rest.repos.updateRelease({
...repo,
release_id: release.id,
body,
});

dockerhub-sync-readme:
name: Sync Docker Hub README
needs: test
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ media processing tasks, such as changing video codecs, containers, audio tracks,
individual files or let Millwright handle new files automatically.

> [!WARNING]
> Millwright is still in alpha phase and may have bugs or incomplete features.
> Millwright is in beta testing phase and may have bugs or incomplete features.
>
> I personally tested the app on my own library and it worked as expected at least for nvidia, qsv and software encoders.
> Unfortunately, I cannot guarantee that it will work perfectly for all setups or encoders without actual testing on
> those setups. **AMD** encoders may also work, but I haven't tested them as i dont have the necessary hardware.

### If you like my work, you might also like my other projects:

Expand Down Expand Up @@ -62,7 +66,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/arabcoders/millwright/dev/in

See the [installation guide](docs/installation.md) for requirements, storage, GPU access, and remote nodes.

After starting Millwright, open `http://<server-ip>:8000`, create your account, and [add your first library](docs/usage.md#add-a-library).
After starting Millwright, open `http://<server-ip>:8000`, create your account, claim the pending node on **Nodes**, and [add your first library](docs/usage.md#add-a-library).

## Security

Expand Down
40 changes: 37 additions & 3 deletions common/src/common/actions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

from pathlib import PurePath, PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING, Annotated, Literal

from pydantic import BaseModel, Field, HttpUrl, field_validator
from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator

from common.media.language import normalize_language
from common.metadata import MetadataValues
Expand Down Expand Up @@ -55,16 +56,40 @@ class Notify(PluginAction):
on: list[Literal["job.succeeded", "job.failed", "job.cancelled"]] = Field(min_length=1)


class PathMapping(BaseModel):
source: str = Field(min_length=1, description="Path used by Millwright.")
destination: str = Field(min_length=1, description="Path used by the application.")

@model_validator(mode="after")
def check_paths(self) -> PathMapping:
source = PurePath(self.source)
destination = (
PureWindowsPath(self.destination)
if PureWindowsPath(self.destination).is_absolute()
else PurePosixPath(self.destination)
)
if not source.is_absolute() or not destination.is_absolute():
raise ValueError("Use absolute paths in mappings.")
if ".." in source.parts or ".." in destination.parts:
raise ValueError("Mapping paths can't contain '..'.")
return self


class ExternalNotificationConfig(BaseModel):
application: Literal["radarr", "sonarr"]
application: Literal["radarr", "sonarr", "plex", "jellyfin", "emby"]
url: HttpUrl = Field(description="Application URL, including any URL base.")
api_key: str = Field(
min_length=1, repr=False, description="Application API key.", json_schema_extra={"ui": "password"}
min_length=1, repr=False, description="Application API key or token.", json_schema_extra={"ui": "password"}
)
unmonitor_after_refresh: bool = Field(
default=False,
description="Disable future automatic upgrades for the movie or associated episodes after refresh succeeds.",
)
path_mappings: list[PathMapping] = Field(
default=[],
description="Add mappings if an application uses different paths for your files.",
json_schema_extra={"ui": "path_mappings"},
)

@field_validator("url")
@classmethod
Expand All @@ -73,6 +98,15 @@ def check_url(cls, value: HttpUrl) -> HttpUrl:
raise ValueError("Use an application URL without credentials, a query, or a fragment.")
return value

@model_validator(mode="after")
def check_options(self) -> ExternalNotificationConfig:
if self.application not in {"radarr", "sonarr"} and self.unmonitor_after_refresh:
raise ValueError("Unmonitor after refresh is only supported for Radarr and Sonarr.")
sources = [PurePath(mapping.source) for mapping in self.path_mappings]
if len(sources) != len(set(sources)):
raise ValueError("Each Millwright path can appear only once.")
return self


class NotifyExternal(ExternalNotificationConfig, PluginAction):
kind: Literal["notify_external"] = "notify_external"
Expand Down
31 changes: 18 additions & 13 deletions common/src/common/plan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,24 +57,28 @@ class StreamPolicy(BaseModel):

class StreamPolicyInputs(BaseModel):
audio_keep_languages: list[str] = Field(
default=[], description="Comma-separated ISO-639-2 audio languages (eng, jpn). Empty keeps all"
default=[], description="Keep audio in these languages, such as eng or jpn. Leave empty to keep all languages."
)
audio_drop_descriptive: bool = Field(default=False, description="Remove commentary and descriptive audio tracks.")
audio_keep_forced: bool = Field(
default=True, description="Keep forced audio tracks even if their language isn't selected."
)
audio_drop_descriptive: bool = Field(default=False, description="Drop commentary/description audio tracks")
audio_keep_forced: bool = Field(default=True, description="Keep forced audio tracks regardless of language")
audio_keep_all_if_empty: bool = Field(
default=False, description="Keep all audio if the language policy would remove every track"
default=False, description="Keep all audio tracks if the other audio filters would remove them all."
)
audio_max_per_language: int | None = Field(
default=None, ge=1, description="Keep at most this many audio tracks per language. Blank keeps all"
default=None, ge=1, description="Limit audio tracks per language. Leave empty for no limit."
)
subtitle_keep_languages: list[str] = Field(
default=[],
description="Comma-separated ISO-639-2 subtitle languages. Empty leaves subtitles untouched",
description="Keep subtitles in these languages, such as eng or jpn. Leave empty to keep all languages.",
)
subtitle_strip_all: bool = Field(default=False, description="Remove subtitles, keeping forced tracks if enabled.")
subtitle_keep_forced: bool = Field(
default=True, description="Keep forced subtitles even if their language isn't selected."
)
subtitle_strip_all: bool = Field(default=False, description="Drop all subtitles except retained forced tracks")
subtitle_keep_forced: bool = Field(default=True, description="Keep forced subtitles regardless of language")
drop_data: bool = Field(default=False, description="Drop all data streams")
drop_cover_art: bool = Field(default=False, description="Drop embedded picture video streams")
drop_data: bool = Field(default=False, description="Remove data streams.")
drop_cover_art: bool = Field(default=False, description="Remove embedded cover art.")

def stream_policy(self) -> StreamPolicy | None:
audio = None
Expand All @@ -101,10 +105,11 @@ def stream_policy(self) -> StreamPolicy | None:


class AudioPlanInputs(StreamPolicyInputs):
audio_codec: str | None = Field(default=None, description="Codec for re-encoding matching audio tracks")
audio_bitrate: str | None = Field(default=None, description="Target bitrate for re-encoded audio tracks")
audio_codec: str | None = Field(default=None, description="Choose the output audio codec.")
audio_bitrate: str | None = Field(default=None, description="Set the output audio bitrate, such as 192k.")
audio_when_codecs: list[str] = Field(
default=[], description="Re-encode audio with these source codecs. Empty matches all"
default=[],
description="Convert audio tracks with these source codecs. Leave empty to convert all selected tracks.",
)

@field_validator("audio_codec", "audio_bitrate", mode="before")
Expand Down
5 changes: 4 additions & 1 deletion common/src/common/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

TextField = Annotated[str, Field(json_schema_extra={"ui": "text"})]

FieldType = Literal["int", "float", "bool", "str", "text", "password", "enum", "strings"]
FieldType = Literal["int", "float", "bool", "str", "text", "password", "enum", "strings", "path_mappings"]


def input_fields(model: type[BaseModel]) -> list[dict[str, Any]]:
Expand Down Expand Up @@ -56,6 +56,9 @@ def _field_type(info: FieldInfo) -> FieldType:
if get_origin(annotation) is Literal:
return "enum"
if get_origin(annotation) is list:
extra = info.json_schema_extra
if isinstance(extra, dict) and extra.get("ui") == "path_mappings":
return "path_mappings"
return "strings"
if annotation is bool:
return "bool"
Expand Down
4 changes: 2 additions & 2 deletions common/src/common/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterator
from collections.abc import Awaitable, Callable, Generator
from pathlib import Path

_TERM_GRACE_S = 10.0
Expand All @@ -24,7 +24,7 @@
@contextmanager
def process_policy(
*, create: Callable[..., Awaitable[asyncio.subprocess.Process]], stop: Callable[..., Awaitable[int]]
) -> Iterator[None]:
) -> Generator[None]:
token = _POLICY.set((create, stop))
try:
yield
Expand Down
14 changes: 10 additions & 4 deletions common/src/common/protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
ActiveAttempt,
AttemptMissing,
Cancel,
Enrolled,
EnrollmentSaved,
CredentialsSaved,
Error,
Finished,
HealthResult,
Expand All @@ -21,7 +20,11 @@
NodeLogs,
OfferedJob,
Progress,
Registered,
RegistrationPending,
SetLimits,
Unregister,
Unregistered,
Welcome,
WorkspaceChallenge,
WorkspaceResponse,
Expand All @@ -35,8 +38,7 @@
"ActiveAttempt",
"AttemptMissing",
"Cancel",
"Enrolled",
"EnrollmentSaved",
"CredentialsSaved",
"Error",
"Finished",
"HealthResult",
Expand All @@ -49,7 +51,11 @@
"NodeLogs",
"OfferedJob",
"Progress",
"Registered",
"RegistrationPending",
"SetLimits",
"Unregister",
"Unregistered",
"Welcome",
"WorkspaceChallenge",
"WorkspaceResponse",
Expand Down
38 changes: 29 additions & 9 deletions common/src/common/protocol/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class Hello(_MessageBase):
type: Literal["hello"] = "hello"

node_id: str | None = Field(default=None, min_length=1)
token: str = Field(min_length=1)
token: str = ""
capabilities: NodeCapabilities = Field(default_factory=NodeCapabilities)
active_attempts: list[ActiveAttempt]

Expand All @@ -109,19 +109,33 @@ class WorkspaceResponse(_MessageBase):
_validate_path = field_validator("response_path")(_workspace_path)


class Enrolled(_MessageBase):
type: Literal["enrolled"] = "enrolled"
class RegistrationPending(_MessageBase):
type: Literal["registration_pending"] = "registration_pending"

request_id: str = Field(min_length=1)


class Registered(_MessageBase):
type: Literal["registered"] = "registered"

node_id: str = Field(min_length=1)
token: str = Field(min_length=1)


class EnrollmentSaved(_MessageBase):
type: Literal["enrollment_saved"] = "enrollment_saved"
class CredentialsSaved(_MessageBase):
type: Literal["credentials_saved"] = "credentials_saved"

node_id: str = Field(min_length=1)


class Unregister(_MessageBase):
type: Literal["unregister"] = "unregister"


class Unregistered(_MessageBase):
type: Literal["unregistered"] = "unregistered"


class GPUUsage(_StrictModel):
name: str = Field(min_length=1)
percent: float | None = Field(default=None, ge=0, le=100)
Expand Down Expand Up @@ -340,8 +354,11 @@ class Error(_MessageBase):
Hello
| WorkspaceChallenge
| WorkspaceResponse
| Enrolled
| EnrollmentSaved
| RegistrationPending
| Registered
| CredentialsSaved
| Unregister
| Unregistered
| Heartbeat
| Progress
| Finished
Expand All @@ -361,8 +378,11 @@ class Error(_MessageBase):
Hello,
WorkspaceChallenge,
WorkspaceResponse,
Enrolled,
EnrollmentSaved,
RegistrationPending,
Registered,
CredentialsSaved,
Unregister,
Unregistered,
Heartbeat,
Progress,
Finished,
Expand Down
4 changes: 2 additions & 2 deletions common/src/common/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from common.fs.acquire import directory_fd

if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Generator

DEFAULT_MIN_FREE_GB = 10.0
DEFAULT_HARD_SIZE_CEILING = 1.25
Expand Down Expand Up @@ -41,7 +41,7 @@ def open_workspace_lock(path: Path, *, exclusive: bool = False) -> int:


@contextmanager
def workspace_lock(path: Path, *, exclusive: bool = False) -> Iterator[int]:
def workspace_lock(path: Path, *, exclusive: bool = False) -> Generator[int]:
fd = open_workspace_lock(path, exclusive=exclusive)
try:
# Cleanup can take the exclusive lock only after all writers release theirs.
Expand Down
Loading
Loading