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.
Files changed (45) hide show
  1. jfmo-3.0.0/PKG-INFO +247 -0
  2. jfmo-3.0.0/README.md +230 -0
  3. jfmo-3.0.0/pyproject.toml +99 -0
  4. jfmo-3.0.0/src/jfmo/__init__.py +124 -0
  5. jfmo-3.0.0/src/jfmo/cli.py +59 -0
  6. jfmo-3.0.0/src/jfmo/config.py +206 -0
  7. jfmo-3.0.0/src/jfmo/daemon.py +105 -0
  8. jfmo-3.0.0/src/jfmo/di.py +44 -0
  9. jfmo-3.0.0/src/jfmo/exceptions.py +10 -0
  10. jfmo-3.0.0/src/jfmo/formatter.py +59 -0
  11. jfmo-3.0.0/src/jfmo/metadata/__init__.py +1 -0
  12. jfmo-3.0.0/src/jfmo/metadata/tmdb.py +99 -0
  13. jfmo-3.0.0/src/jfmo/parser/__init__.py +5 -0
  14. jfmo-3.0.0/src/jfmo/parser/context.py +19 -0
  15. jfmo-3.0.0/src/jfmo/parser/parser.py +19 -0
  16. jfmo-3.0.0/src/jfmo/parser/protocol.py +7 -0
  17. jfmo-3.0.0/src/jfmo/parser/steps/__init__.py +27 -0
  18. jfmo-3.0.0/src/jfmo/parser/steps/codec.py +18 -0
  19. jfmo-3.0.0/src/jfmo/parser/steps/episode.py +28 -0
  20. jfmo-3.0.0/src/jfmo/parser/steps/extension.py +14 -0
  21. jfmo-3.0.0/src/jfmo/parser/steps/hdr.py +18 -0
  22. jfmo-3.0.0/src/jfmo/parser/steps/media_type.py +52 -0
  23. jfmo-3.0.0/src/jfmo/parser/steps/quality.py +60 -0
  24. jfmo-3.0.0/src/jfmo/parser/steps/release_group.py +15 -0
  25. jfmo-3.0.0/src/jfmo/parser/steps/season.py +47 -0
  26. jfmo-3.0.0/src/jfmo/parser/steps/service.py +17 -0
  27. jfmo-3.0.0/src/jfmo/parser/steps/source.py +18 -0
  28. jfmo-3.0.0/src/jfmo/parser/steps/title.py +24 -0
  29. jfmo-3.0.0/src/jfmo/parser/steps/year.py +29 -0
  30. jfmo-3.0.0/src/jfmo/parser/tokens.py +15 -0
  31. jfmo-3.0.0/src/jfmo/processors/__init__.py +4 -0
  32. jfmo-3.0.0/src/jfmo/processors/movie_processor.py +38 -0
  33. jfmo-3.0.0/src/jfmo/processors/result.py +15 -0
  34. jfmo-3.0.0/src/jfmo/processors/tv_processor.py +45 -0
  35. jfmo-3.0.0/src/jfmo/transliteration/__init__.py +5 -0
  36. jfmo-3.0.0/src/jfmo/transliteration/core.py +91 -0
  37. jfmo-3.0.0/src/jfmo/transliteration/models/__init__.py +0 -0
  38. jfmo-3.0.0/src/jfmo/transliteration/models/jfmo_english_model.pkl +0 -0
  39. jfmo-3.0.0/src/jfmo/transliteration/models/jfmo_russian_model.pkl +0 -0
  40. jfmo-3.0.0/src/jfmo/utils/__init__.py +9 -0
  41. jfmo-3.0.0/src/jfmo/utils/cli_output.py +50 -0
  42. jfmo-3.0.0/src/jfmo/utils/fs/__init__.py +4 -0
  43. jfmo-3.0.0/src/jfmo/utils/fs/file_ops.py +64 -0
  44. jfmo-3.0.0/src/jfmo/utils/fs/file_stability_tracker.py +60 -0
  45. 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)