jfmo 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jfmo-3.0.0/PKG-INFO +247 -0
- jfmo-3.0.0/README.md +230 -0
- jfmo-3.0.0/pyproject.toml +99 -0
- jfmo-3.0.0/src/jfmo/__init__.py +124 -0
- jfmo-3.0.0/src/jfmo/cli.py +59 -0
- jfmo-3.0.0/src/jfmo/config.py +206 -0
- jfmo-3.0.0/src/jfmo/daemon.py +105 -0
- jfmo-3.0.0/src/jfmo/di.py +44 -0
- jfmo-3.0.0/src/jfmo/exceptions.py +10 -0
- jfmo-3.0.0/src/jfmo/formatter.py +59 -0
- jfmo-3.0.0/src/jfmo/metadata/__init__.py +1 -0
- jfmo-3.0.0/src/jfmo/metadata/tmdb.py +99 -0
- jfmo-3.0.0/src/jfmo/parser/__init__.py +5 -0
- jfmo-3.0.0/src/jfmo/parser/context.py +19 -0
- jfmo-3.0.0/src/jfmo/parser/parser.py +19 -0
- jfmo-3.0.0/src/jfmo/parser/protocol.py +7 -0
- jfmo-3.0.0/src/jfmo/parser/steps/__init__.py +27 -0
- jfmo-3.0.0/src/jfmo/parser/steps/codec.py +18 -0
- jfmo-3.0.0/src/jfmo/parser/steps/episode.py +28 -0
- jfmo-3.0.0/src/jfmo/parser/steps/extension.py +14 -0
- jfmo-3.0.0/src/jfmo/parser/steps/hdr.py +18 -0
- jfmo-3.0.0/src/jfmo/parser/steps/media_type.py +52 -0
- jfmo-3.0.0/src/jfmo/parser/steps/quality.py +60 -0
- jfmo-3.0.0/src/jfmo/parser/steps/release_group.py +15 -0
- jfmo-3.0.0/src/jfmo/parser/steps/season.py +47 -0
- jfmo-3.0.0/src/jfmo/parser/steps/service.py +17 -0
- jfmo-3.0.0/src/jfmo/parser/steps/source.py +18 -0
- jfmo-3.0.0/src/jfmo/parser/steps/title.py +24 -0
- jfmo-3.0.0/src/jfmo/parser/steps/year.py +29 -0
- jfmo-3.0.0/src/jfmo/parser/tokens.py +15 -0
- jfmo-3.0.0/src/jfmo/processors/__init__.py +4 -0
- jfmo-3.0.0/src/jfmo/processors/movie_processor.py +38 -0
- jfmo-3.0.0/src/jfmo/processors/result.py +15 -0
- jfmo-3.0.0/src/jfmo/processors/tv_processor.py +45 -0
- jfmo-3.0.0/src/jfmo/transliteration/__init__.py +5 -0
- jfmo-3.0.0/src/jfmo/transliteration/core.py +91 -0
- jfmo-3.0.0/src/jfmo/transliteration/models/__init__.py +0 -0
- jfmo-3.0.0/src/jfmo/transliteration/models/jfmo_english_model.pkl +0 -0
- jfmo-3.0.0/src/jfmo/transliteration/models/jfmo_russian_model.pkl +0 -0
- jfmo-3.0.0/src/jfmo/utils/__init__.py +9 -0
- jfmo-3.0.0/src/jfmo/utils/cli_output.py +50 -0
- jfmo-3.0.0/src/jfmo/utils/fs/__init__.py +4 -0
- jfmo-3.0.0/src/jfmo/utils/fs/file_ops.py +64 -0
- jfmo-3.0.0/src/jfmo/utils/fs/file_stability_tracker.py +60 -0
- jfmo-3.0.0/src/jfmo/utils/token_formatter.py +34 -0
jfmo-3.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: jfmo
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Jellyfin Format Media Organizer
|
|
5
|
+
Keywords: jellyfin,media,organizer,tmdb,transliteration
|
|
6
|
+
Author: StafLoker
|
|
7
|
+
Author-email: StafLoker <dev.stafloker@gmail.com>
|
|
8
|
+
License: GPL-3.0
|
|
9
|
+
Requires-Dist: loguru>=0.7.0
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Dist: requests>=2.32.5
|
|
12
|
+
Requires-Dist: transliterate>=1.10.2
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Project-URL: Homepage, https://github.com/StafLoker/jellyfin-format-media-organizer
|
|
15
|
+
Project-URL: Bug Tracker, https://github.com/StafLoker/jellyfin-format-media-organizer/issues
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
<div align="center">
|
|
19
|
+
<img width="150" height="150" src="logo.png" alt="Logo">
|
|
20
|
+
<h1><b>Jellyfin Format Media Organizer</b></h1>
|
|
21
|
+
<p><i>~ JFMO ~</i></p>
|
|
22
|
+
<p align="center">
|
|
23
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/releases">Releases</a> ·
|
|
24
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/blob/main/LICENSE">License</a>
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/releases"><img src="https://img.shields.io/github/release-pre/StafLoker/jellyfin-format-media-organizer.svg?style=flat" alt="latest version"/></a>
|
|
30
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/actions/workflows/ci.yml"><img src="https://github.com/StafLoker/jellyfin-format-media-organizer/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"/></a>
|
|
31
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/actions/workflows/release.yml"><img src="https://github.com/StafLoker/jellyfin-format-media-organizer/actions/workflows/release.yml/badge.svg" alt="Release"/></a>
|
|
32
|
+
<a href="https://pypi.org/project/jfmo/"><img src="https://img.shields.io/pypi/dm/jfmo?style=flat&label=PyPI%20downloads" alt="PyPI downloads"/></a>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<br>
|
|
36
|
+
|
|
37
|
+
Automatically organizes and renames media files according to [Jellyfin's naming conventions](https://jellyfin.org/docs/general/server/media/movies). Detects movies vs TV shows, fetches TMDB metadata, and handles transliteration of non-Latin filenames.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- Smart movie vs TV show detection
|
|
42
|
+
- TMDB integration for IDs and metadata
|
|
43
|
+
- Configurable naming via tokens
|
|
44
|
+
- **Russian transliteration detection and conversion**
|
|
45
|
+
- Daemon mode for continuous monitoring
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
**1. Create a system user and add it to the `media` group:**
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
sudo groupadd media
|
|
53
|
+
sudo useradd --system --no-create-home --shell /usr/sbin/nologin jfmo
|
|
54
|
+
sudo usermod -aG media jfmo
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Make sure your media directories are owned or readable by the `media` group:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
sudo chown -R :media /data/media
|
|
61
|
+
sudo chmod -R g+rw /data/media
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**2. Set up the config:**
|
|
65
|
+
|
|
66
|
+
Default config path: `/etc/jfmo/config.yaml`. See [`config.template.yaml`](config.template.yaml) for all options.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
sudo mkdir -p /etc/jfmo
|
|
70
|
+
sudo vim /etc/jfmo/config.yaml
|
|
71
|
+
sudo chown -R jfmo:jfmo /etc/jfmo
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Option 1 — pip / pipx
|
|
75
|
+
|
|
76
|
+
**3. Install the package:**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
sudo pipx install jfmo --global
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**4. Create the systemd unit `/etc/systemd/system/jfmo.service`:**
|
|
83
|
+
|
|
84
|
+
```ini
|
|
85
|
+
[Unit]
|
|
86
|
+
Description=Jellyfin Format Media Organizer
|
|
87
|
+
After=network.target
|
|
88
|
+
|
|
89
|
+
[Service]
|
|
90
|
+
Type=simple
|
|
91
|
+
User=jfmo
|
|
92
|
+
Group=media
|
|
93
|
+
ExecStart=/usr/local/bin/jfmo daemon
|
|
94
|
+
Restart=on-failure
|
|
95
|
+
RestartSec=10
|
|
96
|
+
|
|
97
|
+
[Install]
|
|
98
|
+
WantedBy=multi-user.target
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**5. Enable and start:**
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
sudo systemctl daemon-reload
|
|
105
|
+
sudo systemctl enable --now jfmo
|
|
106
|
+
sudo systemctl status jfmo
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Run once manually** (without stopping the daemon):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
sudo -u jfmo -g media jfmo run --apply
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Option 2 — Docker
|
|
116
|
+
|
|
117
|
+
See example of docker compose file in [`docker-compose.template.yaml`](docker-compose.template.yaml).
|
|
118
|
+
|
|
119
|
+
Set `user` in `docker-compose.yaml` to the `uid:gid` of `jfmo:media` (created above):
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
id jfmo # get uid
|
|
123
|
+
getent group media # get gid
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**1. Set up files:**
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
sudo mkdir -p /opt/jfmo
|
|
130
|
+
cd /opt/jfmo
|
|
131
|
+
sudo vim docker-compose.yaml
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Start as a background daemon (restarts automatically on reboot):
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
sudo docker compose up -d
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Run once manually (e.g. to process a backlog):
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Dry-run preview — no files moved
|
|
144
|
+
sudo docker compose run --rm jfmo run
|
|
145
|
+
|
|
146
|
+
# Apply changes
|
|
147
|
+
sudo docker compose run --rm jfmo run --apply
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Update
|
|
151
|
+
|
|
152
|
+
### pipx
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
sudo pipx upgrade jfmo --global
|
|
156
|
+
sudo systemctl restart jfmo
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Docker
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
sudo docker compose pull
|
|
163
|
+
sudo docker compose up -d
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Usage
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
jfmo run # dry-run preview (no files moved)
|
|
170
|
+
jfmo run --apply # apply changes
|
|
171
|
+
jfmo daemon # watch downloads directory continuously
|
|
172
|
+
jfmo --version
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Naming
|
|
176
|
+
|
|
177
|
+
### Available tokens
|
|
178
|
+
|
|
179
|
+
| Token | Description | Example |
|
|
180
|
+
| ----------------- | --------------------------- | --------------------------- |
|
|
181
|
+
| `{title}` | Media title | `Inception` |
|
|
182
|
+
| `{year}` | Release year | `2010` |
|
|
183
|
+
| `{tmdb_id}` | TMDB numeric ID | `27205` |
|
|
184
|
+
| `{quality}` | Resolution label | `[1080p]` |
|
|
185
|
+
| `{season}` | Season number, zero-padded | `01` |
|
|
186
|
+
| `{episode}` | Episode number, zero-padded | `04` |
|
|
187
|
+
| `{source}` | Release source | `WEB-DL`, `BluRay`, `BDRip` |
|
|
188
|
+
| `{codec}` | Video codec | `x265`, `HEVC`, `AV1` |
|
|
189
|
+
| `{hdr}` | HDR format | `HDR10`, `DV`, `DoVi` |
|
|
190
|
+
| `{service}` | Streaming service | `NF`, `AMZN`, `DSNP` |
|
|
191
|
+
| `{release_group}` | Release group name | `LostFilm`, `NOOBDL` |
|
|
192
|
+
|
|
193
|
+
Each pattern only accepts a specific subset of tokens:
|
|
194
|
+
|
|
195
|
+
| Pattern (`naming.`) | Allowed tokens |
|
|
196
|
+
| ------------------- | --------------------------------------------------------------------------------------------- |
|
|
197
|
+
| `movie.file` | `title`, `year`, `tmdb_id`, `quality`, `source`, `codec`, `hdr`, `service`, `release_group` |
|
|
198
|
+
| `tv.folder` | `title`, `year`, `tmdb_id` |
|
|
199
|
+
| `tv.season` | `season` |
|
|
200
|
+
| `tv.file` | `title`, `season`, `episode`, `quality`, `source`, `codec`, `hdr`, `service`, `release_group` |
|
|
201
|
+
|
|
202
|
+
### Example: before → after
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
downloads/
|
|
206
|
+
├── Severance.S02E02.1080p.mkv
|
|
207
|
+
├── The.Accountant.2.2024.2160p.mkv
|
|
208
|
+
├── Podslushano.v.Rybinske.S01E01.2160p.mkv ← Russian transliteration
|
|
209
|
+
└── La Casa de Papel 3 - LostFilm [1080p]/
|
|
210
|
+
|
|
211
|
+
films/
|
|
212
|
+
└── The Accountant 2 (2024) [tmdbid-717559] - 2160p.mkv
|
|
213
|
+
|
|
214
|
+
tv/
|
|
215
|
+
├── Severance (2022) [tmdbid-95396]/
|
|
216
|
+
│ └── Season 02/
|
|
217
|
+
│ └── Severance S02E02 - 1080p.mkv
|
|
218
|
+
├── Подслушано в Рыбинске (2024) [tmdbid-245083]/ ← converted to Cyrillic
|
|
219
|
+
│ └── Season 01/
|
|
220
|
+
│ └── Подслушано в Рыбинске S01E01 - 2160p.mkv
|
|
221
|
+
└── La Casa de Papel (2017) [tmdbid-71446]/
|
|
222
|
+
└── Season 03/
|
|
223
|
+
└── La Casa de Papel S03E01 - 1080p.mkv
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Transliteration Detection
|
|
227
|
+
|
|
228
|
+
Most media organizers (Radarr, Sonarr, etc.) cannot handle files where the title is written in **Latin-script transliteration of Russian** — e.g. `Podslushano.v.Rybinske.S01.mkv` looks like English but is actually «Подслушано в Рыбинске».
|
|
229
|
+
|
|
230
|
+
JFMO detects this automatically using a custom **character n-gram language model** trained to distinguish genuine English titles from Russian titles written in transliteration. When a transliterated title is detected, JFMO converts it back to Cyrillic before searching TMDB — resulting in a correct match instead of a failed lookup.
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
Podslushano.v.Rybinske.S01E01.mkv
|
|
234
|
+
detected: Russian transliteration
|
|
235
|
+
converted: Подслушано в Рыбинске
|
|
236
|
+
TMDB match: tmdbid-XXXXXX
|
|
237
|
+
→ Подслушано в Рыбинске (2024) [tmdbid-XXXXXX]/Season 01/...
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The model was trained on a custom dataset of ~2.5M titles (165k Russian + 2.4M English) built specifically for this project, achieving **93% accuracy** on a diverse test set of 334 cases.
|
|
241
|
+
|
|
242
|
+
- Dataset: [stafloker/media-transliterated](https://www.kaggle.com/datasets/stafloker/media-transliterated) (Kaggle)
|
|
243
|
+
- Inspired by: [Language Identification for Texts Written in Transliteration](https://ceur-ws.org/Vol-871/paper_2.pdf)
|
|
244
|
+
|
|
245
|
+
## Acknowledgments
|
|
246
|
+
|
|
247
|
+
- [Jellyfin](https://jellyfin.org/) · [TMDB](https://www.themoviedb.org/) · [transliterate](https://pypi.org/project/transliterate/)
|
jfmo-3.0.0/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img width="150" height="150" src="logo.png" alt="Logo">
|
|
3
|
+
<h1><b>Jellyfin Format Media Organizer</b></h1>
|
|
4
|
+
<p><i>~ JFMO ~</i></p>
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/releases">Releases</a> ·
|
|
7
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/blob/main/LICENSE">License</a>
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div align="center">
|
|
12
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/releases"><img src="https://img.shields.io/github/release-pre/StafLoker/jellyfin-format-media-organizer.svg?style=flat" alt="latest version"/></a>
|
|
13
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/actions/workflows/ci.yml"><img src="https://github.com/StafLoker/jellyfin-format-media-organizer/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"/></a>
|
|
14
|
+
<a href="https://github.com/StafLoker/jellyfin-format-media-organizer/actions/workflows/release.yml"><img src="https://github.com/StafLoker/jellyfin-format-media-organizer/actions/workflows/release.yml/badge.svg" alt="Release"/></a>
|
|
15
|
+
<a href="https://pypi.org/project/jfmo/"><img src="https://img.shields.io/pypi/dm/jfmo?style=flat&label=PyPI%20downloads" alt="PyPI downloads"/></a>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<br>
|
|
19
|
+
|
|
20
|
+
Automatically organizes and renames media files according to [Jellyfin's naming conventions](https://jellyfin.org/docs/general/server/media/movies). Detects movies vs TV shows, fetches TMDB metadata, and handles transliteration of non-Latin filenames.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- Smart movie vs TV show detection
|
|
25
|
+
- TMDB integration for IDs and metadata
|
|
26
|
+
- Configurable naming via tokens
|
|
27
|
+
- **Russian transliteration detection and conversion**
|
|
28
|
+
- Daemon mode for continuous monitoring
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
**1. Create a system user and add it to the `media` group:**
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
sudo groupadd media
|
|
36
|
+
sudo useradd --system --no-create-home --shell /usr/sbin/nologin jfmo
|
|
37
|
+
sudo usermod -aG media jfmo
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Make sure your media directories are owned or readable by the `media` group:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
sudo chown -R :media /data/media
|
|
44
|
+
sudo chmod -R g+rw /data/media
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**2. Set up the config:**
|
|
48
|
+
|
|
49
|
+
Default config path: `/etc/jfmo/config.yaml`. See [`config.template.yaml`](config.template.yaml) for all options.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
sudo mkdir -p /etc/jfmo
|
|
53
|
+
sudo vim /etc/jfmo/config.yaml
|
|
54
|
+
sudo chown -R jfmo:jfmo /etc/jfmo
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Option 1 — pip / pipx
|
|
58
|
+
|
|
59
|
+
**3. Install the package:**
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sudo pipx install jfmo --global
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**4. Create the systemd unit `/etc/systemd/system/jfmo.service`:**
|
|
66
|
+
|
|
67
|
+
```ini
|
|
68
|
+
[Unit]
|
|
69
|
+
Description=Jellyfin Format Media Organizer
|
|
70
|
+
After=network.target
|
|
71
|
+
|
|
72
|
+
[Service]
|
|
73
|
+
Type=simple
|
|
74
|
+
User=jfmo
|
|
75
|
+
Group=media
|
|
76
|
+
ExecStart=/usr/local/bin/jfmo daemon
|
|
77
|
+
Restart=on-failure
|
|
78
|
+
RestartSec=10
|
|
79
|
+
|
|
80
|
+
[Install]
|
|
81
|
+
WantedBy=multi-user.target
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**5. Enable and start:**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
sudo systemctl daemon-reload
|
|
88
|
+
sudo systemctl enable --now jfmo
|
|
89
|
+
sudo systemctl status jfmo
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Run once manually** (without stopping the daemon):
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
sudo -u jfmo -g media jfmo run --apply
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Option 2 — Docker
|
|
99
|
+
|
|
100
|
+
See example of docker compose file in [`docker-compose.template.yaml`](docker-compose.template.yaml).
|
|
101
|
+
|
|
102
|
+
Set `user` in `docker-compose.yaml` to the `uid:gid` of `jfmo:media` (created above):
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
id jfmo # get uid
|
|
106
|
+
getent group media # get gid
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**1. Set up files:**
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
sudo mkdir -p /opt/jfmo
|
|
113
|
+
cd /opt/jfmo
|
|
114
|
+
sudo vim docker-compose.yaml
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Start as a background daemon (restarts automatically on reboot):
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
sudo docker compose up -d
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Run once manually (e.g. to process a backlog):
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Dry-run preview — no files moved
|
|
127
|
+
sudo docker compose run --rm jfmo run
|
|
128
|
+
|
|
129
|
+
# Apply changes
|
|
130
|
+
sudo docker compose run --rm jfmo run --apply
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Update
|
|
134
|
+
|
|
135
|
+
### pipx
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
sudo pipx upgrade jfmo --global
|
|
139
|
+
sudo systemctl restart jfmo
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Docker
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
sudo docker compose pull
|
|
146
|
+
sudo docker compose up -d
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Usage
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
jfmo run # dry-run preview (no files moved)
|
|
153
|
+
jfmo run --apply # apply changes
|
|
154
|
+
jfmo daemon # watch downloads directory continuously
|
|
155
|
+
jfmo --version
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Naming
|
|
159
|
+
|
|
160
|
+
### Available tokens
|
|
161
|
+
|
|
162
|
+
| Token | Description | Example |
|
|
163
|
+
| ----------------- | --------------------------- | --------------------------- |
|
|
164
|
+
| `{title}` | Media title | `Inception` |
|
|
165
|
+
| `{year}` | Release year | `2010` |
|
|
166
|
+
| `{tmdb_id}` | TMDB numeric ID | `27205` |
|
|
167
|
+
| `{quality}` | Resolution label | `[1080p]` |
|
|
168
|
+
| `{season}` | Season number, zero-padded | `01` |
|
|
169
|
+
| `{episode}` | Episode number, zero-padded | `04` |
|
|
170
|
+
| `{source}` | Release source | `WEB-DL`, `BluRay`, `BDRip` |
|
|
171
|
+
| `{codec}` | Video codec | `x265`, `HEVC`, `AV1` |
|
|
172
|
+
| `{hdr}` | HDR format | `HDR10`, `DV`, `DoVi` |
|
|
173
|
+
| `{service}` | Streaming service | `NF`, `AMZN`, `DSNP` |
|
|
174
|
+
| `{release_group}` | Release group name | `LostFilm`, `NOOBDL` |
|
|
175
|
+
|
|
176
|
+
Each pattern only accepts a specific subset of tokens:
|
|
177
|
+
|
|
178
|
+
| Pattern (`naming.`) | Allowed tokens |
|
|
179
|
+
| ------------------- | --------------------------------------------------------------------------------------------- |
|
|
180
|
+
| `movie.file` | `title`, `year`, `tmdb_id`, `quality`, `source`, `codec`, `hdr`, `service`, `release_group` |
|
|
181
|
+
| `tv.folder` | `title`, `year`, `tmdb_id` |
|
|
182
|
+
| `tv.season` | `season` |
|
|
183
|
+
| `tv.file` | `title`, `season`, `episode`, `quality`, `source`, `codec`, `hdr`, `service`, `release_group` |
|
|
184
|
+
|
|
185
|
+
### Example: before → after
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
downloads/
|
|
189
|
+
├── Severance.S02E02.1080p.mkv
|
|
190
|
+
├── The.Accountant.2.2024.2160p.mkv
|
|
191
|
+
├── Podslushano.v.Rybinske.S01E01.2160p.mkv ← Russian transliteration
|
|
192
|
+
└── La Casa de Papel 3 - LostFilm [1080p]/
|
|
193
|
+
|
|
194
|
+
films/
|
|
195
|
+
└── The Accountant 2 (2024) [tmdbid-717559] - 2160p.mkv
|
|
196
|
+
|
|
197
|
+
tv/
|
|
198
|
+
├── Severance (2022) [tmdbid-95396]/
|
|
199
|
+
│ └── Season 02/
|
|
200
|
+
│ └── Severance S02E02 - 1080p.mkv
|
|
201
|
+
├── Подслушано в Рыбинске (2024) [tmdbid-245083]/ ← converted to Cyrillic
|
|
202
|
+
│ └── Season 01/
|
|
203
|
+
│ └── Подслушано в Рыбинске S01E01 - 2160p.mkv
|
|
204
|
+
└── La Casa de Papel (2017) [tmdbid-71446]/
|
|
205
|
+
└── Season 03/
|
|
206
|
+
└── La Casa de Papel S03E01 - 1080p.mkv
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Transliteration Detection
|
|
210
|
+
|
|
211
|
+
Most media organizers (Radarr, Sonarr, etc.) cannot handle files where the title is written in **Latin-script transliteration of Russian** — e.g. `Podslushano.v.Rybinske.S01.mkv` looks like English but is actually «Подслушано в Рыбинске».
|
|
212
|
+
|
|
213
|
+
JFMO detects this automatically using a custom **character n-gram language model** trained to distinguish genuine English titles from Russian titles written in transliteration. When a transliterated title is detected, JFMO converts it back to Cyrillic before searching TMDB — resulting in a correct match instead of a failed lookup.
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
Podslushano.v.Rybinske.S01E01.mkv
|
|
217
|
+
detected: Russian transliteration
|
|
218
|
+
converted: Подслушано в Рыбинске
|
|
219
|
+
TMDB match: tmdbid-XXXXXX
|
|
220
|
+
→ Подслушано в Рыбинске (2024) [tmdbid-XXXXXX]/Season 01/...
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The model was trained on a custom dataset of ~2.5M titles (165k Russian + 2.4M English) built specifically for this project, achieving **93% accuracy** on a diverse test set of 334 cases.
|
|
224
|
+
|
|
225
|
+
- Dataset: [stafloker/media-transliterated](https://www.kaggle.com/datasets/stafloker/media-transliterated) (Kaggle)
|
|
226
|
+
- Inspired by: [Language Identification for Texts Written in Transliteration](https://ceur-ws.org/Vol-871/paper_2.pdf)
|
|
227
|
+
|
|
228
|
+
## Acknowledgments
|
|
229
|
+
|
|
230
|
+
- [Jellyfin](https://jellyfin.org/) · [TMDB](https://www.themoviedb.org/) · [transliterate](https://pypi.org/project/transliterate/)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jfmo"
|
|
3
|
+
version = "3.0.0"
|
|
4
|
+
description = "Jellyfin Format Media Organizer"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "GPL-3.0" }
|
|
8
|
+
authors = [{ name = "StafLoker", email = "dev.stafloker@gmail.com" }]
|
|
9
|
+
keywords = ["jellyfin", "media", "organizer", "tmdb", "transliteration"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"loguru>=0.7.0",
|
|
12
|
+
"pyyaml>=6.0.3",
|
|
13
|
+
"requests>=2.32.5",
|
|
14
|
+
"transliterate>=1.10.2",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/StafLoker/jellyfin-format-media-organizer"
|
|
19
|
+
"Bug Tracker" = "https://github.com/StafLoker/jellyfin-format-media-organizer/issues"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
jfmo = "jfmo:main"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Dependencies ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=9.0.2",
|
|
34
|
+
"ruff>=0.15.4",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# ── Pytest ────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
addopts = "-ra -q"
|
|
42
|
+
|
|
43
|
+
# ── Ruff (linter & formatter) ─────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 120
|
|
47
|
+
indent-width = 4
|
|
48
|
+
target-version = "py312"
|
|
49
|
+
exclude = [
|
|
50
|
+
".git",
|
|
51
|
+
".venv",
|
|
52
|
+
"__pycache__",
|
|
53
|
+
"build",
|
|
54
|
+
"dist",
|
|
55
|
+
"*.egg-info",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
# Enable pycodestyle (E/W), pyflakes (F), isort (I), and other common rules
|
|
60
|
+
select = [
|
|
61
|
+
"E", # pycodestyle errors
|
|
62
|
+
"W", # pycodestyle warnings
|
|
63
|
+
"F", # pyflakes
|
|
64
|
+
"I", # isort
|
|
65
|
+
"N", # pep8-naming
|
|
66
|
+
"UP", # pyupgrade
|
|
67
|
+
"B", # flake8-bugbear
|
|
68
|
+
"C4", # flake8-comprehensions
|
|
69
|
+
"SIM", # flake8-simplify
|
|
70
|
+
"RET", # flake8-return
|
|
71
|
+
"ARG", # flake8-unused-arguments
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# Ignore specific rules that might be too strict
|
|
75
|
+
ignore = [
|
|
76
|
+
"E501", # Line too long (handled by formatter)
|
|
77
|
+
"B008", # Do not perform function call in argument defaults
|
|
78
|
+
"ARG002", # Unused method argument
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
fixable = ["ALL"]
|
|
82
|
+
|
|
83
|
+
[tool.ruff.format]
|
|
84
|
+
# Use double quotes for strings
|
|
85
|
+
quote-style = "double"
|
|
86
|
+
|
|
87
|
+
# Indent with spaces
|
|
88
|
+
indent-style = "space"
|
|
89
|
+
|
|
90
|
+
# Respect magic trailing comma
|
|
91
|
+
skip-magic-trailing-comma = false
|
|
92
|
+
|
|
93
|
+
# Automatically detect the appropriate line ending
|
|
94
|
+
line-ending = "auto"
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint.isort]
|
|
97
|
+
# Use a single line for imports from the same package
|
|
98
|
+
combine-as-imports = true
|
|
99
|
+
force-wrap-aliases = true
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from .cli import CLI
|
|
8
|
+
from .config import config
|
|
9
|
+
from .daemon import FileWatcher
|
|
10
|
+
from .di import Container
|
|
11
|
+
from .exceptions import DirectoryNotFoundError, TransliterationModelError
|
|
12
|
+
from .utils.cli_output import print_dry_run_banner, print_entry_header, print_header, print_result, print_summary
|
|
13
|
+
from .utils.fs.file_ops import is_video_file
|
|
14
|
+
|
|
15
|
+
EXIT_SUCCESS = 0
|
|
16
|
+
EXIT_CONFIG_ERROR = 1
|
|
17
|
+
EXIT_DIRECTORY_ERROR = 2
|
|
18
|
+
EXIT_SYSTEM_ERROR = 5
|
|
19
|
+
EXIT_MODEL_ERROR = 6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run(apply: bool) -> None:
|
|
23
|
+
config.DRY_RUN = not apply
|
|
24
|
+
container = Container()
|
|
25
|
+
|
|
26
|
+
show_output = not config.DAEMON_MODE
|
|
27
|
+
|
|
28
|
+
video_entries = [
|
|
29
|
+
name
|
|
30
|
+
for name in os.listdir(config.DOWNLOADS_DIR)
|
|
31
|
+
if os.path.isdir(os.path.join(config.DOWNLOADS_DIR, name))
|
|
32
|
+
or (os.path.isfile(os.path.join(config.DOWNLOADS_DIR, name)) and is_video_file(name))
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
if show_output and config.DRY_RUN:
|
|
36
|
+
print_dry_run_banner()
|
|
37
|
+
if show_output:
|
|
38
|
+
print_header(len(video_entries))
|
|
39
|
+
|
|
40
|
+
all_results = []
|
|
41
|
+
skipped_count = 0
|
|
42
|
+
|
|
43
|
+
for name in video_entries:
|
|
44
|
+
path = os.path.join(config.DOWNLOADS_DIR, name)
|
|
45
|
+
|
|
46
|
+
if show_output:
|
|
47
|
+
print_entry_header(name, is_dir=os.path.isdir(path))
|
|
48
|
+
|
|
49
|
+
if os.path.isdir(path):
|
|
50
|
+
results = container.formatter.format_directory(path)
|
|
51
|
+
all_results.extend(results)
|
|
52
|
+
if show_output:
|
|
53
|
+
for r in results:
|
|
54
|
+
print_result(r)
|
|
55
|
+
else:
|
|
56
|
+
result = container.formatter.format_file(path)
|
|
57
|
+
if result is None:
|
|
58
|
+
skipped_count += 1
|
|
59
|
+
else:
|
|
60
|
+
all_results.append(result)
|
|
61
|
+
if show_output:
|
|
62
|
+
print_result(result)
|
|
63
|
+
|
|
64
|
+
if show_output:
|
|
65
|
+
print_summary(all_results, skipped_count, config.DRY_RUN)
|
|
66
|
+
if config.DRY_RUN:
|
|
67
|
+
print_dry_run_banner()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_daemon() -> None:
|
|
71
|
+
container = Container()
|
|
72
|
+
watcher = FileWatcher(config.DOWNLOADS_DIR, config.DAEMON_INTERVAL_SEC, container.formatter)
|
|
73
|
+
|
|
74
|
+
def _stop(signum, _frame):
|
|
75
|
+
logger.info(f"Signal {signum}. Stopping...")
|
|
76
|
+
watcher.stop()
|
|
77
|
+
sys.exit(0)
|
|
78
|
+
|
|
79
|
+
signal.signal(signal.SIGINT, _stop)
|
|
80
|
+
signal.signal(signal.SIGTERM, _stop)
|
|
81
|
+
|
|
82
|
+
logger.info("Daemon starting...")
|
|
83
|
+
logger.info(config)
|
|
84
|
+
watcher.start()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main():
|
|
88
|
+
cli = CLI()
|
|
89
|
+
command = cli.read_command()
|
|
90
|
+
|
|
91
|
+
if command is None:
|
|
92
|
+
cli.print_help()
|
|
93
|
+
sys.exit(EXIT_SUCCESS)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
config.DAEMON_MODE = command == "daemon"
|
|
97
|
+
config.load(cli.read_config_path())
|
|
98
|
+
|
|
99
|
+
if command == "daemon":
|
|
100
|
+
_run_daemon()
|
|
101
|
+
else:
|
|
102
|
+
_run(cli.read_run_apply())
|
|
103
|
+
|
|
104
|
+
except FileNotFoundError as e:
|
|
105
|
+
logger.error(str(e))
|
|
106
|
+
sys.exit(EXIT_CONFIG_ERROR)
|
|
107
|
+
|
|
108
|
+
except ValueError as e:
|
|
109
|
+
logger.error(str(e))
|
|
110
|
+
sys.exit(EXIT_CONFIG_ERROR)
|
|
111
|
+
|
|
112
|
+
except DirectoryNotFoundError as e:
|
|
113
|
+
logger.error(str(e))
|
|
114
|
+
sys.exit(EXIT_DIRECTORY_ERROR)
|
|
115
|
+
|
|
116
|
+
except TransliterationModelError as e:
|
|
117
|
+
logger.error(str(e))
|
|
118
|
+
sys.exit(EXIT_MODEL_ERROR)
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"Unexpected error: {e}")
|
|
122
|
+
sys.exit(EXIT_SYSTEM_ERROR)
|
|
123
|
+
|
|
124
|
+
sys.exit(EXIT_SUCCESS)
|