Compare commits
61 Commits
12509cd4e2
...
v0.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac6e3020b2 | ||
|
|
93d19629dc | ||
|
|
db43501ce2 | ||
|
|
87568989fe | ||
|
|
20ab08626b | ||
|
|
2e2c94f539 | ||
|
|
12be6e985a | ||
|
|
12310942ae | ||
|
|
f913cb4fe3 | ||
|
|
0a153280e3 | ||
|
|
6ca0cd54b0 | ||
|
|
14c956b6fa | ||
|
|
502a822bb4 | ||
|
|
6cc21b5f36 | ||
|
|
0034f8ca97 | ||
|
|
eedcbaed0a | ||
|
|
653ce7b417 | ||
|
|
b80c055826 | ||
|
|
c5fc6ac13d | ||
|
|
fea8ea4b70 | ||
|
|
1bead05d19 | ||
|
|
9fe2a842e9 | ||
|
|
849d03d054 | ||
|
|
3a87bbbba6 | ||
|
|
ab5e8e53e1 | ||
|
|
0ab2408444 | ||
|
|
bc1e0889e7 | ||
|
|
6dfbe1022a | ||
|
|
d3d2de8a0d | ||
|
|
0728ece4b8 | ||
|
|
02e375fbf2 | ||
|
|
14e6ce8458 | ||
|
|
d314b6024d | ||
|
|
d921629947 | ||
|
|
65490e2a7f | ||
|
|
6c5b518e4d | ||
|
|
e3c18f22d4 | ||
|
|
57185c7f10 | ||
|
|
1ff9ecd4b6 | ||
|
|
037388886e | ||
|
|
e614ca5d75 | ||
|
|
c0b3977ea6 | ||
|
|
d9639561ce | ||
|
|
cbf43e5d6c | ||
|
|
d6e885517d | ||
|
|
2593c95b5c | ||
|
|
8a8c43ecdf | ||
|
|
6170ac641c | ||
|
|
497c0e500b | ||
|
|
008c643272 | ||
|
|
c302b30e63 | ||
|
|
7926407534 | ||
|
|
0894ac2fab | ||
|
|
353759b983 | ||
|
|
454f5f0656 | ||
|
|
0e51d6337f | ||
|
|
a24b6dedaa | ||
|
|
8361fc536b | ||
|
|
4d4272e5e8 | ||
|
|
559869ca68 | ||
|
|
0e4fae538b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,6 +10,8 @@ tools/ansible/inventory/group_vars/all.yml
|
||||
ffx_test_report.log
|
||||
bin/conversiontest.py
|
||||
|
||||
tests/assets/
|
||||
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
@@ -20,4 +22,6 @@ venv/
|
||||
|
||||
*.mkv
|
||||
*.webm
|
||||
*.mp4
|
||||
ffmpeg2pass-0.log
|
||||
*.sup
|
||||
52
README.md
52
README.md
@@ -99,6 +99,58 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
||||
|
||||
## Version History
|
||||
|
||||
### 0.4.3
|
||||
|
||||
- styled ASS subtitle sources with embedded font attachments are now detected explicitly, keep MKV output, preserve current source font attachments, and reject incompatible sidecar subtitle import
|
||||
- attachment descriptors are now treated as source-runtime data instead of pattern schema data, so pattern persistence skips them and source-vs-pattern validation ignores them
|
||||
- inspect differences no longer report planned changes for attachment filename/count drift while still showing attachment streams in the stream table
|
||||
|
||||
### 0.4.2
|
||||
|
||||
- pattern details now show an inline `Show: <quality>` hint next to the quality field when the pattern itself has no stored quality but the selected show does
|
||||
- inspect stream tables now show attachment format labels like `TTF` in the codec column and keep attachment language cells blank instead of showing an undefined language
|
||||
- ffmpeg damaged-MP3 diagnostics now recognize additional corruption lines such as `invalid new backstep`, keeping them grouped under the `warn-corrupt-mpeg-audio` review summary
|
||||
|
||||
### 0.4.1
|
||||
|
||||
- `convert` now supports `--copy-video` and `--copy-audio` to keep the selected stream type in copy mode without applying the corresponding reencode flags, filters, or formatting options
|
||||
- ffmpeg conversions now monitor diagnostics while the process is running, retry unset AVI packet timestamps once with `-fflags +genpts`, and stop early when a file should be skipped instead of waiting for the full job to finish
|
||||
- end-of-run convert summaries now list only ffmpeg findings that still require review, including named remedy identifiers such as `warn-corrupt-mpeg-audio`
|
||||
- `upgrade` now finishes by reporting the installed FFX version together with the active bundle branch
|
||||
|
||||
### 0.3.1
|
||||
|
||||
- debug mode screen titles now append the active Textual screen class name, making screen-specific troubleshooting easier during inspect and edit flows
|
||||
- `--cut` again works as a combined flag/option: omitted disables cutting, bare `--cut` applies the default `60,180`, and explicit duration or `START,DURATION` values stay supported
|
||||
- H.265 unmux commands no longer force an invalid `-f h265` output format, keeping ffmpeg copy extraction aligned with the required Annex B bitstream filter
|
||||
- H.264 encoding now falls back from `libx264` to `libopenh264` with a warning when needed, and the test fixtures use the same encoder fallback so the suite remains portable across ffmpeg builds
|
||||
|
||||
### 0.3.0
|
||||
|
||||
- inspect and edit screens now refresh nested track and pattern changes more reliably, with inspect-mode tables aligned to the target pattern view shown in the differences pane
|
||||
- metadata editing got a follow-up polish pass with clearer ffmpeg notifications, a shared in-screen log pane, safer apply/reload handling, and expanded cleanup and normalization coverage
|
||||
- track and asset probing recognize additional codecs, and the modern test suite now covers more metadata-editor, change-set, screen-state, and asset-probe behavior
|
||||
- Textual now requires version `8.0` or newer to match the UI APIs used by the current screens
|
||||
|
||||
### 0.2.6
|
||||
|
||||
- DB-free `ffx edit` workflow for in-place metadata editing via temporary-file rewrite
|
||||
- inspect and edit workflows split into dedicated Textual screens with shared media-workflow support
|
||||
- Textual tables and row actions now separate raw data from rendered labels to avoid markup leaking into stored metadata
|
||||
- responsive screen layout pass, `Esc` back handling, sortable show/inspect tables, and improved edit-screen notifications/toggles
|
||||
- application-wide UTF-8 i18n catalogs with language precedence from CLI over config over system over German default
|
||||
- metadata normalization extended for localized subtitle titles, ISO language cleanup, and smarter track editor language/title helpers
|
||||
|
||||
### 0.2.5
|
||||
|
||||
- show-level quality and notes fields
|
||||
- pattern-over-show-over-default season-shift resolution with dynamic DB migration loading
|
||||
- migration prompt now reports the upgrade path and creates an in-place DB backup before applying schema changes
|
||||
- `upgrade --branch <name>` now fetches remote-only branches before switching
|
||||
- `unmux` now applies season shifting to subtitle output filenames
|
||||
- convert now keeps DB-defined target subtitle dispositions authoritative over sidecar filename disposition flags when a pattern definition exists
|
||||
- focused modern tests added around migrations, unmux, upgrade, and subtitle-disposition import precedence
|
||||
|
||||
### 0.2.4
|
||||
|
||||
- lightweight CLI commands now stay import-light via lazy runtime loading
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"language": {{ language_json }},
|
||||
"databasePath": {{ database_path_json }},
|
||||
"logDirectory": {{ log_directory_json }},
|
||||
"subtitlesDirectory": {{ subtitles_directory_json }},
|
||||
|
||||
361
assets/i18n/de.json
Normal file
361
assets/i18n/de.json
Normal file
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "Abchasisch",
|
||||
"AFAR": "Afar",
|
||||
"AFRIKAANS": "Afrikaans",
|
||||
"AKAN": "Akan",
|
||||
"ALBANIAN": "Albanisch",
|
||||
"AMHARIC": "Amharisch",
|
||||
"ARABIC": "Arabisch",
|
||||
"ARAGONESE": "Aragonesisch",
|
||||
"ARMENIAN": "Armenisch",
|
||||
"ASSAMESE": "Assamesisch",
|
||||
"AVARIC": "Awarisch",
|
||||
"AVESTAN": "Avestisch",
|
||||
"AYMARA": "Aymara",
|
||||
"AZERBAIJANI": "Aserbaidschanisch",
|
||||
"BAMBARA": "Bambara",
|
||||
"BASHKIR": "Baschkirisch",
|
||||
"BASQUE": "Baskisch",
|
||||
"BELARUSIAN": "Weißrussisch",
|
||||
"BENGALI": "Bengalisch",
|
||||
"BISLAMA": "Bislama",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "Bosnisch",
|
||||
"BRETON": "Bretonisch",
|
||||
"BULGARIAN": "Bulgarisch",
|
||||
"BURMESE": "Burmesisch",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "Chamorro",
|
||||
"CHECHEN": "Tschetschenisch",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "Chinesisch",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "Tschuwaschisch",
|
||||
"CORNISH": "Kornisch",
|
||||
"CORSICAN": "Korsisch",
|
||||
"CREE": "Cree",
|
||||
"CROATIAN": "Kroatisch",
|
||||
"CZECH": "Tschechisch",
|
||||
"DANISH": "Dänisch",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "Dzongkha",
|
||||
"ENGLISH": "Englisch",
|
||||
"ESPERANTO": "Esperanto",
|
||||
"ESTONIAN": "Estnisch",
|
||||
"EWE": "Ewe-Sprache",
|
||||
"FAROESE": "Färöisch",
|
||||
"FIJIAN": "Fidschianisch",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "Finnisch",
|
||||
"FRENCH": "Französisch",
|
||||
"FULAH": "Ful",
|
||||
"GALICIAN": "Galizisch",
|
||||
"GANDA": "Ganda",
|
||||
"GEORGIAN": "Georgisch",
|
||||
"GERMAN": "Deutsch",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "Guaraní",
|
||||
"GUJARATI": "Gujarati",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "Haussa",
|
||||
"HEBREW": "Hebräisch",
|
||||
"HERERO": "Herero",
|
||||
"HINDI": "Hindi",
|
||||
"HIRI_MOTU": "Hiri-Motu",
|
||||
"HUNGARIAN": "Ungarisch",
|
||||
"ICELANDIC": "Isländisch",
|
||||
"IDO": "Ido",
|
||||
"IGBO": "Ibo",
|
||||
"INDONESIAN": "Indonesisch",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "Inuktitut",
|
||||
"INUPIAQ": "Inupiaq",
|
||||
"IRISH": "Irisch",
|
||||
"ITALIAN": "Italienisch",
|
||||
"JAPANESE": "Japanisch",
|
||||
"JAVANESE": "Javanisch",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "Kannada",
|
||||
"KANURI": "Kanuri",
|
||||
"KASHMIRI": "Kaschmirisch",
|
||||
"KAZAKH": "Kasachisch",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "Kinyarwanda",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "Komi",
|
||||
"KONGO": "Kongo",
|
||||
"KOREAN": "Koreanisch",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "Kurdisch",
|
||||
"LAO": "Laotisch",
|
||||
"LATIN": "Lateinisch",
|
||||
"LATVIAN": "Lettisch",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "Lingala",
|
||||
"LITHUANIAN": "Litauisch",
|
||||
"LUBA_KATANGA": "Luba-Katanga",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "Makedonisch",
|
||||
"MALAGASY": "Malagasi",
|
||||
"MALAY": "Malaiisch",
|
||||
"MALAYALAM": "Malayalam",
|
||||
"MALTESE": "Maltesisch",
|
||||
"MANX": "Manx",
|
||||
"MAORI": "Maori",
|
||||
"MARATHI": "Marathi",
|
||||
"MARSHALLESE": "Marschallesisch",
|
||||
"MONGOLIAN": "Mongolisch",
|
||||
"NAURU": "Nauru",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "Ndonga",
|
||||
"NEPALI": "Nepali",
|
||||
"NORTHERN_SAMI": "Nord-Samisch",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "Norwegisch",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "Ojibwa",
|
||||
"ORIYA": "Oriya",
|
||||
"OROMO": "Oromo",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "Pali",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "Persisch",
|
||||
"POLISH": "Polnisch",
|
||||
"PORTUGUESE": "Portugiesisch",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "Quechua",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "Bündnerromanisch",
|
||||
"RUNDI": "Kirundi",
|
||||
"RUSSIAN": "Russisch",
|
||||
"SAMOAN": "Samoanisch",
|
||||
"SANGO": "Sango",
|
||||
"SANSKRIT": "Sanskrit",
|
||||
"SARDINIAN": "Sardisch",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "Serbisch",
|
||||
"SHONA": "Schona",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "Sindhi",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "Slowakisch",
|
||||
"SLOVENIAN": "Slowenisch",
|
||||
"SOMALI": "Somali",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "Sundanesisch",
|
||||
"SWAHILI": "Suaheli; Swaheli",
|
||||
"SWATI": "Swazi",
|
||||
"SWEDISH": "Schwedisch",
|
||||
"TAGALOG": "Tagalog",
|
||||
"TAHITIAN": "Tahitisch",
|
||||
"TAJIK": "Tadschikisch",
|
||||
"TAMIL": "Tamilisch",
|
||||
"TATAR": "Tatarisch",
|
||||
"TELUGU": "Telugu",
|
||||
"THAI": "Thai",
|
||||
"TIBETAN": "Tibetisch",
|
||||
"TIGRINYA": "Tigrinja",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "Tsonga",
|
||||
"TSWANA": "Tswana",
|
||||
"TURKISH": "Türkisch",
|
||||
"TURKMEN": "Turkmenisch",
|
||||
"TWI": "Twi",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "Ukrainisch",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "Urdu",
|
||||
"UZBEK": "Usbekisch",
|
||||
"VENDA": "Venda",
|
||||
"VIETNAMESE": "Vietnamesisch",
|
||||
"VOLAPUK": "Volapük",
|
||||
"WALLOON": "Wallonisch",
|
||||
"WELSH": "Walisisch",
|
||||
"WESTERN_FRISIAN": "Westfriesisch",
|
||||
"WOLOF": "Wolof",
|
||||
"XHOSA": "Xhosa",
|
||||
"YIDDISH": "Jiddisch",
|
||||
"YORUBA": "Joruba",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "Zulu"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<Neue Serie>",
|
||||
"Add": "Hinzufügen",
|
||||
"Add Pattern": "Muster hinzufügen",
|
||||
"Apply": "Anwenden",
|
||||
"Apply failed: {error}": "Anwenden fehlgeschlagen: {error}",
|
||||
"Are you sure to delete the following filename pattern?": "Möchtest du das folgende Dateinamensmuster wirklich löschen?",
|
||||
"Are you sure to delete the following shifted season?": "Möchtest du die folgende verschobene Staffel wirklich löschen?",
|
||||
"Are you sure to delete the following show?": "Möchtest du die folgende Serie wirklich löschen?",
|
||||
"Are you sure to delete the following {track_type} track?": "Möchtest du den folgenden {track_type}-Stream wirklich löschen?",
|
||||
"Are you sure to delete this tag?": "Möchtest du dieses Tag wirklich löschen?",
|
||||
"Audio Layout": "Audiolayout",
|
||||
"Back": "Zurück",
|
||||
"Cancel": "Abbrechen",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "Es kann kein weiterer Stream mit gesetztem Dispositions-Flag 'default' oder 'forced' hinzugefügt werden",
|
||||
"Changes applied and file reloaded.": "Änderungen angewendet und Datei neu geladen.",
|
||||
"Cleanup": "Bereinigen",
|
||||
"Cleanup disabled.": "Bereinigung deaktiviert.",
|
||||
"Cleanup enabled.": "Bereinigung aktiviert.",
|
||||
"Codec": "Codec",
|
||||
"Continuing edit session.": "Bearbeitung wird fortgesetzt.",
|
||||
"Default": "Standard",
|
||||
"Delete": "Löschen",
|
||||
"Delete Show": "Serie löschen",
|
||||
"Deleted media tag {tag!r}.": "Medien-Tag {tag!r} gelöscht.",
|
||||
"Differences": "Unterschiede",
|
||||
"Differences (file->db/output)": "Unterschiede (Datei->DB/Ausgabe)",
|
||||
"Discard": "Verwerfen",
|
||||
"Discard pending metadata changes and quit?": "Ausstehende Metadatenänderungen verwerfen und beenden?",
|
||||
"Discard pending metadata changes and reload the file state?": "Ausstehende Metadatenänderungen verwerfen und Dateistand neu laden?",
|
||||
"Down": "Runter",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "Trockenlauf: würde über temporäre Datei {target_path} neu schreiben",
|
||||
"Edit": "Bearbeiten",
|
||||
"Edit Pattern": "Muster bearbeiten",
|
||||
"Edit Show": "Serie bearbeiten",
|
||||
"Edit filename pattern": "Dateinamensmuster bearbeiten",
|
||||
"Edit shifted season": "Verschobene Staffel bearbeiten",
|
||||
"Edit stream": "Stream bearbeiten",
|
||||
"Episode Offset": "Episodenoffset",
|
||||
"Episode offset": "Episodenoffset",
|
||||
"File": "Datei",
|
||||
"File patterns": "Datei-Namensmuster",
|
||||
"First Episode": "Erste Episode",
|
||||
"First episode": "Erste Episode",
|
||||
"Forced": "Erzwungen",
|
||||
"Help": "Hilfe",
|
||||
"Help Screen": "Hilfe-Bildschirm",
|
||||
"ID": "ID",
|
||||
"Identify": "Identifizieren",
|
||||
"Index": "Index",
|
||||
"Index / Subindex": "Index / Unterindex",
|
||||
"Index Episode Digits": "Ep. Index Stellen",
|
||||
"Index Season Digits": "Sta. Index Stellen",
|
||||
"Indicator Edisode Digits": "Ep. Indikator Stellen",
|
||||
"Indicator Season Digits": "Sta. Indikator Stellen",
|
||||
"Keep Editing": "Weiter bearbeiten",
|
||||
"Keeping pending changes.": "Ausstehende Änderungen bleiben erhalten.",
|
||||
"Key": "Schlüssel",
|
||||
"Language": "Sprache",
|
||||
"Last Episode": "Letzte Episode",
|
||||
"Last episode": "Letzte Episode",
|
||||
"Layout": "Layout",
|
||||
"Media Tags": "Medien-Tags",
|
||||
"More than one default audio stream detected and no prompt set": "Mehr als ein Standard-Audiostream erkannt und keine Abfrage aktiviert",
|
||||
"More than one default audio stream detected! Please select stream": "Mehr als ein Standard-Audiostream erkannt! Bitte Stream auswählen",
|
||||
"More than one default subtitle stream detected and no prompt set": "Mehr als ein Standard-Untertitelstream erkannt und keine Abfrage aktiviert",
|
||||
"More than one default subtitle stream detected! Please select stream": "Mehr als ein Standard-Untertitelstream erkannt! Bitte Stream auswählen",
|
||||
"More than one default video stream detected and no prompt set": "Mehr als ein Standard-Videostream erkannt und keine Abfrage aktiviert",
|
||||
"More than one default video stream detected! Please select stream": "Mehr als ein Standard-Videostream erkannt! Bitte Stream auswählen",
|
||||
"More than one forced audio stream detected and no prompt set": "Mehr als ein erzwungener Audiostream erkannt und keine Abfrage aktiviert",
|
||||
"More than one forced audio stream detected! Please select stream": "Mehr als ein erzwungener Audiostream erkannt! Bitte Stream auswählen",
|
||||
"More than one forced subtitle stream detected and no prompt set": "Mehr als ein erzwungener Untertitelstream erkannt und keine Abfrage aktiviert",
|
||||
"More than one forced subtitle stream detected! Please select stream": "Mehr als ein erzwungener Untertitelstream erkannt! Bitte Stream auswählen",
|
||||
"More than one forced video stream detected and no prompt set": "Mehr als ein erzwungener Videostream erkannt und keine Abfrage aktiviert",
|
||||
"More than one forced video stream detected! Please select stream": "Mehr als ein erzwungener Videostream erkannt! Bitte Stream auswählen",
|
||||
"Name": "Name",
|
||||
"New Pattern": "Neues Muster",
|
||||
"New Show": "Neue Serie",
|
||||
"New filename pattern": "Neues Dateinamensmuster",
|
||||
"New shifted season": "Neue verschobene Staffel",
|
||||
"New stream": "Neuer Stream",
|
||||
"No": "Nein",
|
||||
"No changes to apply.": "Keine Änderungen zum Anwenden.",
|
||||
"No changes to revert.": "Keine Änderungen zum Zurücksetzen.",
|
||||
"Normalization disabled.": "Normalisierung deaktiviert.",
|
||||
"Normalization enabled.": "Normalisierung aktiviert.",
|
||||
"Normalize": "Normalisieren",
|
||||
"Notes": "Notizen",
|
||||
"Pattern": "Muster",
|
||||
"Planned Changes (file->edited output)": "Geplante Änderungen (Datei->bearbeitete Ausgabe)",
|
||||
"Quality": "Qualität",
|
||||
"Quit": "Beenden",
|
||||
"Remove Pattern": "Muster entfernen",
|
||||
"Revert": "Zurücksetzen",
|
||||
"Reverted pending changes.": "Ausstehende Änderungen verworfen.",
|
||||
"Save": "Speichern",
|
||||
"Season Offset": "Staffeloffset",
|
||||
"Select a stream first.": "Bitte zuerst einen Stream auswählen.",
|
||||
"Set Default": "Als Standard setzen",
|
||||
"Set Forced": "Als erzwungen setzen",
|
||||
"Settings Screen": "Einstellungsbildschirm",
|
||||
"Numbering Mapping": "Abbildung Nummerierung",
|
||||
"Show": "Serie",
|
||||
"Shows": "Serien",
|
||||
"Source Season": "Quellstaffel",
|
||||
"SrcIndex": "QuellIndex",
|
||||
"Status": "Status",
|
||||
"Stay": "Bleiben",
|
||||
"Stream dispositions": "Stream-Dispositionen",
|
||||
"Stream tags": "Stream-Tags",
|
||||
"Streams": "Streams",
|
||||
"SubIndex": "Unterindex",
|
||||
"Substitute": "Ersetzen",
|
||||
"Substitute pattern": "Muster ersetzen",
|
||||
"Title": "Titel",
|
||||
"Type": "Typ",
|
||||
"Unable to update selected stream.": "Ausgewählten Stream konnte nicht aktualisiert werden.",
|
||||
"Up": "Hoch",
|
||||
"Update Pattern": "Muster aktualisieren",
|
||||
"Updated media tag {tag!r}.": "Medien-Tag {tag!r} aktualisiert.",
|
||||
"Updated stream #{index} ({track_type}).": "Stream #{index} ({track_type}) aktualisiert.",
|
||||
"Value": "Wert",
|
||||
"Year": "Jahr",
|
||||
"Yes": "Ja",
|
||||
"add media tag: key='{key}' value='{value}'": "Medien-Tag hinzufügen: Schlüssel='{key}' Wert='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "{track_type}-Stream hinzufügen: Index={index} Sprache={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "Anhang",
|
||||
"audio": "Audio",
|
||||
"captions": "Untertitel",
|
||||
"change media tag: key='{key}' value='{value}'": "Medien-Tag ändern: Schlüssel='{key}' Wert='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "Stream #{index} ({track_type}:{sub_index}) Disposition hinzufügen={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "Stream #{index} ({track_type}:{sub_index}) Schlüssel hinzufügen={key} Wert={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "Stream #{index} ({track_type}:{sub_index}) Schlüssel ändern={key} Wert={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "Stream #{index} ({track_type}:{sub_index}) Disposition entfernen={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "Stream #{index} ({track_type}:{sub_index}) Schlüssel entfernen={key} Wert={value}",
|
||||
"clean_effects": "Nur Effekte",
|
||||
"comment": "Kommentar",
|
||||
"default": "Standard",
|
||||
"dependent": "abhängig",
|
||||
"descriptions": "Beschreibungen",
|
||||
"dub": "Synchronisiert",
|
||||
"for pattern": "für Muster",
|
||||
"forced": "erzwungen",
|
||||
"from": "von",
|
||||
"from pattern": "aus Muster",
|
||||
"from show": "aus Serie",
|
||||
"hearing_impaired": "hörgeschädigt",
|
||||
"karaoke": "Karaoke",
|
||||
"lyrics": "Liedtext",
|
||||
"metadata": "Metadaten",
|
||||
"non_diegetic": "nicht-diegetisch",
|
||||
"original": "Original",
|
||||
"pattern #{id}": "Muster #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "Medien-Tag entfernen: Schlüssel='{key}' Wert='{value}'",
|
||||
"remove stream #{index}": "Stream #{index} entfernen",
|
||||
"show #{id}": "Serie #{id}",
|
||||
"stereo": "Stereo",
|
||||
"still_image": "Standbild",
|
||||
"sub index": "Unterindex",
|
||||
"subtitle": "Untertitel",
|
||||
"timed_thumbnails": "zeitgesteuerte Vorschaubilder",
|
||||
"undefined": "undefiniert",
|
||||
"unknown": "unbekannt",
|
||||
"video": "Video",
|
||||
"visual_impaired": "sehgeschädigt"
|
||||
}
|
||||
}
|
||||
360
assets/i18n/en.json
Normal file
360
assets/i18n/en.json
Normal file
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "Abkhazian",
|
||||
"AFAR": "Afar",
|
||||
"AFRIKAANS": "Afrikaans",
|
||||
"AKAN": "Akan",
|
||||
"ALBANIAN": "Albanian",
|
||||
"AMHARIC": "Amharic",
|
||||
"ARABIC": "Arabic",
|
||||
"ARAGONESE": "Aragonese",
|
||||
"ARMENIAN": "Armenian",
|
||||
"ASSAMESE": "Assamese",
|
||||
"AVARIC": "Avaric",
|
||||
"AVESTAN": "Avestan",
|
||||
"AYMARA": "Aymara",
|
||||
"AZERBAIJANI": "Azerbaijani",
|
||||
"BAMBARA": "Bambara",
|
||||
"BASHKIR": "Bashkir",
|
||||
"BASQUE": "Basque",
|
||||
"BELARUSIAN": "Belarusian",
|
||||
"BENGALI": "Bengali",
|
||||
"BISLAMA": "Bislama",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "Bosnian",
|
||||
"BRETON": "Breton",
|
||||
"BULGARIAN": "Bulgarian",
|
||||
"BURMESE": "Burmese",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "Chamorro",
|
||||
"CHECHEN": "Chechen",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "Chinese",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "Chuvash",
|
||||
"CORNISH": "Cornish",
|
||||
"CORSICAN": "Corsican",
|
||||
"CREE": "Cree",
|
||||
"CROATIAN": "Croatian",
|
||||
"CZECH": "Czech",
|
||||
"DANISH": "Danish",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "Dzongkha",
|
||||
"ENGLISH": "English",
|
||||
"ESPERANTO": "Esperanto",
|
||||
"ESTONIAN": "Estonian",
|
||||
"EWE": "Ewe",
|
||||
"FAROESE": "Faroese",
|
||||
"FIJIAN": "Fijian",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "Finnish",
|
||||
"FRENCH": "French",
|
||||
"FULAH": "Fulah",
|
||||
"GALICIAN": "Galician",
|
||||
"GANDA": "Ganda",
|
||||
"GEORGIAN": "Georgian",
|
||||
"GERMAN": "German",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "Guarani",
|
||||
"GUJARATI": "Gujarati",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "Hausa",
|
||||
"HEBREW": "Hebrew",
|
||||
"HERERO": "Herero",
|
||||
"HINDI": "Hindi",
|
||||
"HIRI_MOTU": "Hiri Motu",
|
||||
"HUNGARIAN": "Hungarian",
|
||||
"ICELANDIC": "Icelandic",
|
||||
"IDO": "Ido",
|
||||
"IGBO": "Igbo",
|
||||
"INDONESIAN": "Indonesian",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "Inuktitut",
|
||||
"INUPIAQ": "Inupiaq",
|
||||
"IRISH": "Irish",
|
||||
"ITALIAN": "Italian",
|
||||
"JAPANESE": "Japanese",
|
||||
"JAVANESE": "Javanese",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "Kannada",
|
||||
"KANURI": "Kanuri",
|
||||
"KASHMIRI": "Kashmiri",
|
||||
"KAZAKH": "Kazakh",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "Kinyarwanda",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "Komi",
|
||||
"KONGO": "Kongo",
|
||||
"KOREAN": "Korean",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "Kurdish",
|
||||
"LAO": "Lao",
|
||||
"LATIN": "Latin",
|
||||
"LATVIAN": "Latvian",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "Lingala",
|
||||
"LITHUANIAN": "Lithuanian",
|
||||
"LUBA_KATANGA": "Luba-Katanga",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "Macedonian",
|
||||
"MALAGASY": "Malagasy",
|
||||
"MALAY": "Malay",
|
||||
"MALAYALAM": "Malayalam",
|
||||
"MALTESE": "Maltese",
|
||||
"MANX": "Manx",
|
||||
"MAORI": "Maori",
|
||||
"MARATHI": "Marathi",
|
||||
"MARSHALLESE": "Marshallese",
|
||||
"MONGOLIAN": "Mongolian",
|
||||
"NAURU": "Nauru",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "Ndonga",
|
||||
"NEPALI": "Nepali",
|
||||
"NORTHERN_SAMI": "Northern Sami",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "Norwegian",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "Ojibwa",
|
||||
"ORIYA": "Oriya",
|
||||
"OROMO": "Oromo",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "Pali",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "Persian",
|
||||
"POLISH": "Polish",
|
||||
"PORTUGUESE": "Portuguese",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "Quechua",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "Romansh",
|
||||
"RUNDI": "Rundi",
|
||||
"RUSSIAN": "Russian",
|
||||
"SAMOAN": "Samoan",
|
||||
"SANGO": "Sango",
|
||||
"SANSKRIT": "Sanskrit",
|
||||
"SARDINIAN": "Sardinian",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "Serbian",
|
||||
"SHONA": "Shona",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "Sindhi",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "Slovak",
|
||||
"SLOVENIAN": "Slovenian",
|
||||
"SOMALI": "Somali",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "Sundanese",
|
||||
"SWAHILI": "Swahili",
|
||||
"SWATI": "Swati",
|
||||
"SWEDISH": "Swedish",
|
||||
"TAGALOG": "Tagalog",
|
||||
"TAHITIAN": "Tahitian",
|
||||
"TAJIK": "Tajik",
|
||||
"TAMIL": "Tamil",
|
||||
"TATAR": "Tatar",
|
||||
"TELUGU": "Telugu",
|
||||
"THAI": "Thai",
|
||||
"TIBETAN": "Tibetan",
|
||||
"TIGRINYA": "Tigrinya",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "Tsonga",
|
||||
"TSWANA": "Tswana",
|
||||
"TURKISH": "Turkish",
|
||||
"TURKMEN": "Turkmen",
|
||||
"TWI": "Twi",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "Ukrainian",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "Urdu",
|
||||
"UZBEK": "Uzbek",
|
||||
"VENDA": "Venda",
|
||||
"VIETNAMESE": "Vietnamese",
|
||||
"VOLAPUK": "Volapük",
|
||||
"WALLOON": "Walloon",
|
||||
"WELSH": "Welsh",
|
||||
"WESTERN_FRISIAN": "Western Frisian",
|
||||
"WOLOF": "Wolof",
|
||||
"XHOSA": "Xhosa",
|
||||
"YIDDISH": "Yiddish",
|
||||
"YORUBA": "Yoruba",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "Zulu"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<New show>",
|
||||
"Add": "Add",
|
||||
"Add Pattern": "Add Pattern",
|
||||
"Apply": "Apply",
|
||||
"Apply failed: {error}": "Apply failed: {error}",
|
||||
"Are you sure to delete the following filename pattern?": "Are you sure to delete the following filename pattern?",
|
||||
"Are you sure to delete the following shifted season?": "Are you sure to delete the following shifted season?",
|
||||
"Are you sure to delete the following show?": "Are you sure to delete the following show?",
|
||||
"Are you sure to delete the following {track_type} track?": "Are you sure to delete the following {track_type} track?",
|
||||
"Are you sure to delete this tag?": "Are you sure to delete this tag?",
|
||||
"Audio Layout": "Audio Layout",
|
||||
"Back": "Back",
|
||||
"Cancel": "Cancel",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "Cannot add another stream with disposition flag 'default' or 'forced' set",
|
||||
"Changes applied and file reloaded.": "Changes applied and file reloaded.",
|
||||
"Cleanup": "Cleanup",
|
||||
"Cleanup disabled.": "Cleanup disabled.",
|
||||
"Cleanup enabled.": "Cleanup enabled.",
|
||||
"Codec": "Codec",
|
||||
"Continuing edit session.": "Continuing edit session.",
|
||||
"Default": "Default",
|
||||
"Delete": "Delete",
|
||||
"Delete Show": "Delete Show",
|
||||
"Deleted media tag {tag!r}.": "Deleted media tag {tag!r}.",
|
||||
"Differences": "Differences",
|
||||
"Differences (file->db/output)": "Differences (file->db/output)",
|
||||
"Discard": "Discard",
|
||||
"Discard pending metadata changes and quit?": "Discard pending metadata changes and quit?",
|
||||
"Discard pending metadata changes and reload the file state?": "Discard pending metadata changes and reload the file state?",
|
||||
"Down": "Down",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "Dry-run: would rewrite via temporary file {target_path}",
|
||||
"Edit": "Edit",
|
||||
"Edit Pattern": "Edit Pattern",
|
||||
"Edit Show": "Edit Show",
|
||||
"Edit filename pattern": "Edit filename pattern",
|
||||
"Edit shifted season": "Edit shifted season",
|
||||
"Edit stream": "Edit stream",
|
||||
"Episode Offset": "Episode Offset",
|
||||
"Episode offset": "Episode offset",
|
||||
"File": "File",
|
||||
"File patterns": "File patterns",
|
||||
"First Episode": "First Episode",
|
||||
"First episode": "First episode",
|
||||
"Forced": "Forced",
|
||||
"Help": "Help",
|
||||
"Help Screen": "Help Screen",
|
||||
"ID": "ID",
|
||||
"Identify": "Identify",
|
||||
"Index": "Index",
|
||||
"Index / Subindex": "Index / Subindex",
|
||||
"Index Episode Digits": "Index Episode Digits",
|
||||
"Index Season Digits": "Index Season Digits",
|
||||
"Indicator Edisode Digits": "Indicator Edisode Digits",
|
||||
"Indicator Season Digits": "Indicator Season Digits",
|
||||
"Keep Editing": "Keep Editing",
|
||||
"Keeping pending changes.": "Keeping pending changes.",
|
||||
"Key": "Key",
|
||||
"Language": "Language",
|
||||
"Last Episode": "Last Episode",
|
||||
"Last episode": "Last episode",
|
||||
"Layout": "Layout",
|
||||
"Media Tags": "Media Tags",
|
||||
"More than one default audio stream detected and no prompt set": "More than one default audio stream detected and no prompt set",
|
||||
"More than one default audio stream detected! Please select stream": "More than one default audio stream detected! Please select stream",
|
||||
"More than one default subtitle stream detected and no prompt set": "More than one default subtitle stream detected and no prompt set",
|
||||
"More than one default subtitle stream detected! Please select stream": "More than one default subtitle stream detected! Please select stream",
|
||||
"More than one default video stream detected and no prompt set": "More than one default video stream detected and no prompt set",
|
||||
"More than one default video stream detected! Please select stream": "More than one default video stream detected! Please select stream",
|
||||
"More than one forced audio stream detected and no prompt set": "More than one forced audio stream detected and no prompt set",
|
||||
"More than one forced audio stream detected! Please select stream": "More than one forced audio stream detected! Please select stream",
|
||||
"More than one forced subtitle stream detected and no prompt set": "More than one forced subtitle stream detected and no prompt set",
|
||||
"More than one forced subtitle stream detected! Please select stream": "More than one forced subtitle stream detected! Please select stream",
|
||||
"More than one forced video stream detected and no prompt set": "More than one forced video stream detected and no prompt set",
|
||||
"More than one forced video stream detected! Please select stream": "More than one forced video stream detected! Please select stream",
|
||||
"Name": "Name",
|
||||
"New Pattern": "New Pattern",
|
||||
"New Show": "New Show",
|
||||
"New filename pattern": "New filename pattern",
|
||||
"New shifted season": "New shifted season",
|
||||
"New stream": "New stream",
|
||||
"No": "No",
|
||||
"No changes to apply.": "No changes to apply.",
|
||||
"No changes to revert.": "No changes to revert.",
|
||||
"Normalization disabled.": "Normalization disabled.",
|
||||
"Normalization enabled.": "Normalization enabled.",
|
||||
"Normalize": "Normalize",
|
||||
"Notes": "Notes",
|
||||
"Pattern": "Pattern",
|
||||
"Planned Changes (file->edited output)": "Planned Changes (file->edited output)",
|
||||
"Quality": "Quality",
|
||||
"Quit": "Quit",
|
||||
"Remove Pattern": "Remove Pattern",
|
||||
"Revert": "Revert",
|
||||
"Reverted pending changes.": "Reverted pending changes.",
|
||||
"Save": "Save",
|
||||
"Season Offset": "Season Offset",
|
||||
"Select a stream first.": "Select a stream first.",
|
||||
"Set Default": "Set Default",
|
||||
"Set Forced": "Set Forced",
|
||||
"Settings Screen": "Settings Screen",
|
||||
"Numbering Mapping": "Numbering Mapping",
|
||||
"Show": "Show",
|
||||
"Shows": "Shows",
|
||||
"SrcIndex": "SrcIndex",
|
||||
"Status": "Status",
|
||||
"Stay": "Stay",
|
||||
"Stream dispositions": "Stream dispositions",
|
||||
"Stream tags": "Stream tags",
|
||||
"Streams": "Streams",
|
||||
"SubIndex": "SubIndex",
|
||||
"Substitute": "Substitute",
|
||||
"Substitute pattern": "Substitute pattern",
|
||||
"Title": "Title",
|
||||
"Type": "Type",
|
||||
"Unable to update selected stream.": "Unable to update selected stream.",
|
||||
"Up": "Up",
|
||||
"Update Pattern": "Update Pattern",
|
||||
"Updated media tag {tag!r}.": "Updated media tag {tag!r}.",
|
||||
"Updated stream #{index} ({track_type}).": "Updated stream #{index} ({track_type}).",
|
||||
"Value": "Value",
|
||||
"Year": "Year",
|
||||
"Yes": "Yes",
|
||||
"add media tag: key='{key}' value='{value}'": "add media tag: key='{key}' value='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "add {track_type} track: index={index} lang={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "attachment",
|
||||
"audio": "audio",
|
||||
"captions": "captions",
|
||||
"change media tag: key='{key}' value='{value}'": "change media tag: key='{key}' value='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}",
|
||||
"clean_effects": "clean_effects",
|
||||
"comment": "comment",
|
||||
"default": "default",
|
||||
"dependent": "dependent",
|
||||
"descriptions": "descriptions",
|
||||
"dub": "dub",
|
||||
"for pattern": "for pattern",
|
||||
"forced": "forced",
|
||||
"from": "from",
|
||||
"from pattern": "from pattern",
|
||||
"from show": "from show",
|
||||
"hearing_impaired": "hearing_impaired",
|
||||
"karaoke": "karaoke",
|
||||
"lyrics": "lyrics",
|
||||
"metadata": "metadata",
|
||||
"non_diegetic": "non_diegetic",
|
||||
"original": "original",
|
||||
"pattern #{id}": "pattern #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "remove media tag: key='{key}' value='{value}'",
|
||||
"remove stream #{index}": "remove stream #{index}",
|
||||
"show #{id}": "show #{id}",
|
||||
"stereo": "stereo",
|
||||
"still_image": "still_image",
|
||||
"sub index": "sub index",
|
||||
"subtitle": "subtitle",
|
||||
"timed_thumbnails": "timed_thumbnails",
|
||||
"undefined": "undefined",
|
||||
"unknown": "unknown",
|
||||
"video": "video",
|
||||
"visual_impaired": "visual_impaired"
|
||||
}
|
||||
}
|
||||
361
assets/i18n/eo.json
Normal file
361
assets/i18n/eo.json
Normal file
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "Abĥaza",
|
||||
"AFAR": "Afara",
|
||||
"AFRIKAANS": "Afrikansa",
|
||||
"AKAN": "Akana",
|
||||
"ALBANIAN": "Albana",
|
||||
"AMHARIC": "Amhara",
|
||||
"ARABIC": "Araba",
|
||||
"ARAGONESE": "Aragona",
|
||||
"ARMENIAN": "Armena",
|
||||
"ASSAMESE": "Asama",
|
||||
"AVARIC": "Avara",
|
||||
"AVESTAN": "Avesta",
|
||||
"AYMARA": "Ajmara",
|
||||
"AZERBAIJANI": "Azerbajĝana",
|
||||
"BAMBARA": "Bambara",
|
||||
"BASHKIR": "Baŝkira",
|
||||
"BASQUE": "Eŭska",
|
||||
"BELARUSIAN": "Belorusa",
|
||||
"BENGALI": "Bengala",
|
||||
"BISLAMA": "Bislamo",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "Bosna",
|
||||
"BRETON": "Bretona",
|
||||
"BULGARIAN": "Bulgara",
|
||||
"BURMESE": "Birma",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "Ĉamora",
|
||||
"CHECHEN": "Ĉeĉena",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "Ĉina",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "Ĉuvaŝa",
|
||||
"CORNISH": "Kornvala",
|
||||
"CORSICAN": "Korsika",
|
||||
"CREE": "Kria",
|
||||
"CROATIAN": "Kroata",
|
||||
"CZECH": "Ĉeĥa",
|
||||
"DANISH": "Dana",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "Dzonka",
|
||||
"ENGLISH": "Angla",
|
||||
"ESPERANTO": "Esperanto",
|
||||
"ESTONIAN": "Estona",
|
||||
"EWE": "Evea",
|
||||
"FAROESE": "Feroa",
|
||||
"FIJIAN": "Fiĝia",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "Finna",
|
||||
"FRENCH": "Franca",
|
||||
"FULAH": "Fula",
|
||||
"GALICIAN": "Galega",
|
||||
"GANDA": "Ganda",
|
||||
"GEORGIAN": "Kartvela",
|
||||
"GERMAN": "Germana",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "Gvarania",
|
||||
"GUJARATI": "Guĝarata",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "Haŭsa",
|
||||
"HEBREW": "Hebrea",
|
||||
"HERERO": "Herera",
|
||||
"HINDI": "Hindia",
|
||||
"HIRI_MOTU": "Hirimotua",
|
||||
"HUNGARIAN": "Hungara",
|
||||
"ICELANDIC": "Islanda",
|
||||
"IDO": "Ido",
|
||||
"IGBO": "Igba",
|
||||
"INDONESIAN": "Indonezia",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "Inuktituta",
|
||||
"INUPIAQ": "Inupiaka",
|
||||
"IRISH": "Irlanda",
|
||||
"ITALIAN": "Itala",
|
||||
"JAPANESE": "Japana",
|
||||
"JAVANESE": "Java",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "Kanara",
|
||||
"KANURI": "Kanura",
|
||||
"KASHMIRI": "Kaŝmira",
|
||||
"KAZAKH": "Kazaĥa",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "Ruanda",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "Komia",
|
||||
"KONGO": "Konga",
|
||||
"KOREAN": "Korea",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "Kurda",
|
||||
"LAO": "Laosa",
|
||||
"LATIN": "Latina",
|
||||
"LATVIAN": "Latva",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "Lingala",
|
||||
"LITHUANIAN": "Litova",
|
||||
"LUBA_KATANGA": "Luba-katanga",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "Makedona",
|
||||
"MALAGASY": "Malagasa",
|
||||
"MALAY": "Malaja",
|
||||
"MALAYALAM": "Malajala",
|
||||
"MALTESE": "Malta",
|
||||
"MANX": "Manksa",
|
||||
"MAORI": "Maoria",
|
||||
"MARATHI": "Marata",
|
||||
"MARSHALLESE": "Marŝala",
|
||||
"MONGOLIAN": "Mongola",
|
||||
"NAURU": "Naura",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "Ndonga",
|
||||
"NEPALI": "Nepala",
|
||||
"NORTHERN_SAMI": "Norda samea",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "Norvega",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "Oĝibva",
|
||||
"ORIYA": "Orija",
|
||||
"OROMO": "Oroma",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "Palia",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "Persa",
|
||||
"POLISH": "Pola",
|
||||
"PORTUGUESE": "Portugala",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "Keĉua",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "Romanĉa",
|
||||
"RUNDI": "Burunda",
|
||||
"RUSSIAN": "Rusa",
|
||||
"SAMOAN": "Samoa",
|
||||
"SANGO": "Sangoa",
|
||||
"SANSKRIT": "Sanskrito",
|
||||
"SARDINIAN": "Sarda",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "Serba",
|
||||
"SHONA": "Ŝona",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "Sinda",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "Slovaka",
|
||||
"SLOVENIAN": "Slovena",
|
||||
"SOMALI": "Somalia",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "Sunda",
|
||||
"SWAHILI": "Svahila",
|
||||
"SWATI": "Svazia",
|
||||
"SWEDISH": "Sveda",
|
||||
"TAGALOG": "Tagaloga",
|
||||
"TAHITIAN": "Tahitia",
|
||||
"TAJIK": "Taĝika",
|
||||
"TAMIL": "Tamila",
|
||||
"TATAR": "Tatara",
|
||||
"TELUGU": "Telugua",
|
||||
"THAI": "Taja",
|
||||
"TIBETAN": "Tibeta",
|
||||
"TIGRINYA": "Tigraja",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "Conga",
|
||||
"TSWANA": "Cvana",
|
||||
"TURKISH": "Turka",
|
||||
"TURKMEN": "Turkmena",
|
||||
"TWI": "Tvia",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "Ukraina",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "Urdua",
|
||||
"UZBEK": "Uzbeka",
|
||||
"VENDA": "Vendaa",
|
||||
"VIETNAMESE": "Vjetnama",
|
||||
"VOLAPUK": "Volapuko",
|
||||
"WALLOON": "Valona",
|
||||
"WELSH": "Kimra",
|
||||
"WESTERN_FRISIAN": "Okcidenta frisa",
|
||||
"WOLOF": "Volofa",
|
||||
"XHOSA": "Kosa",
|
||||
"YIDDISH": "Jida",
|
||||
"YORUBA": "Joruba",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "Zulua"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<Nova serio>",
|
||||
"Add": "Aldoni",
|
||||
"Add Pattern": "Aldoni ŝablonon",
|
||||
"Apply": "Apliki",
|
||||
"Apply failed: {error}": "Apliko malsukcesis: {error}",
|
||||
"Are you sure to delete the following filename pattern?": "Ĉu vi certe volas forigi la jenan dosiernoman ŝablonon?",
|
||||
"Are you sure to delete the following shifted season?": "Ĉu vi certe volas forigi la jenan ŝovitan sezonon?",
|
||||
"Are you sure to delete the following show?": "Ĉu vi certe volas forigi la jenan serion?",
|
||||
"Are you sure to delete the following {track_type} track?": "Ĉu vi certe volas forigi la jenan {track_type}-trakon?",
|
||||
"Are you sure to delete this tag?": "Ĉu vi certe volas forigi ĉi tiun etikedon?",
|
||||
"Audio Layout": "Aŭda aranĝo",
|
||||
"Back": "Reen",
|
||||
"Cancel": "Nuligi",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "Ne eblas aldoni alian fluon kun la dispozicia flago 'default' aŭ 'forced' aktiva",
|
||||
"Changes applied and file reloaded.": "Ŝanĝoj aplikitaj kaj dosiero reŝargita.",
|
||||
"Cleanup": "Purigado",
|
||||
"Cleanup disabled.": "Purigado malŝaltita.",
|
||||
"Cleanup enabled.": "Purigado ŝaltita.",
|
||||
"Codec": "Kodeko",
|
||||
"Continuing edit session.": "Daŭrigante la redaktan seancon.",
|
||||
"Default": "Defaŭlta",
|
||||
"Delete": "Forigi",
|
||||
"Delete Show": "Forigi serion",
|
||||
"Deleted media tag {tag!r}.": "Forigis la aŭdvidan etikedon {tag!r}.",
|
||||
"Differences": "Diferencoj",
|
||||
"Differences (file->db/output)": "Diferencoj (dosiero->DB/eligo)",
|
||||
"Discard": "Forĵeti",
|
||||
"Discard pending metadata changes and quit?": "Ĉu forĵeti atendatajn metadatumajn ŝanĝojn kaj eliri?",
|
||||
"Discard pending metadata changes and reload the file state?": "Ĉu forĵeti atendatajn metadatumajn ŝanĝojn kaj reŝargi la dosieran staton?",
|
||||
"Down": "Malsupren",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "Seka provo: reskribus per provizora dosiero {target_path}",
|
||||
"Edit": "Redakti",
|
||||
"Edit Pattern": "Redakti ŝablonon",
|
||||
"Edit Show": "Redakti serion",
|
||||
"Edit filename pattern": "Redakti dosiernoman ŝablonon",
|
||||
"Edit shifted season": "Redakti ŝovitan sezonon",
|
||||
"Edit stream": "Redakti fluon",
|
||||
"Episode Offset": "Epizoda deŝovo",
|
||||
"Episode offset": "Epizoda deŝovo",
|
||||
"File": "Dosiero",
|
||||
"File patterns": "Dosieraj ŝablonoj",
|
||||
"First Episode": "Unua epizodo",
|
||||
"First episode": "Unua epizodo",
|
||||
"Forced": "Devigita",
|
||||
"Help": "Helpo",
|
||||
"Help Screen": "Helpa ekrano",
|
||||
"ID": "ID",
|
||||
"Identify": "Identigi",
|
||||
"Index": "Indekso",
|
||||
"Index / Subindex": "Indekso / Subindekso",
|
||||
"Index Episode Digits": "Ciferoj de epizoda indekso",
|
||||
"Index Season Digits": "Ciferoj de sezona indekso",
|
||||
"Indicator Edisode Digits": "Ciferoj de epizoda indikilo",
|
||||
"Indicator Season Digits": "Ciferoj de sezona indikilo",
|
||||
"Keep Editing": "Daŭrigi redaktadon",
|
||||
"Keeping pending changes.": "Konservas atendatajn ŝanĝojn.",
|
||||
"Key": "Ŝlosilo",
|
||||
"Language": "Lingvo",
|
||||
"Last Episode": "Lasta epizodo",
|
||||
"Last episode": "Lasta epizodo",
|
||||
"Layout": "Aranĝo",
|
||||
"Media Tags": "Aŭdvidaj etikedoj",
|
||||
"More than one default audio stream detected and no prompt set": "Pli ol unu defaŭlta sonfluo detektita kaj neniu instigo agordita",
|
||||
"More than one default audio stream detected! Please select stream": "Pli ol unu defaŭlta sonfluo detektita! Bonvolu elekti fluon",
|
||||
"More than one default subtitle stream detected and no prompt set": "Pli ol unu defaŭlta subtitola fluo detektita kaj neniu instigo agordita",
|
||||
"More than one default subtitle stream detected! Please select stream": "Pli ol unu defaŭlta subtitola fluo detektita! Bonvolu elekti fluon",
|
||||
"More than one default video stream detected and no prompt set": "Pli ol unu defaŭlta videofluo detektita kaj neniu instigo agordita",
|
||||
"More than one default video stream detected! Please select stream": "Pli ol unu defaŭlta videofluo detektita! Bonvolu elekti fluon",
|
||||
"More than one forced audio stream detected and no prompt set": "Pli ol unu devigita sonfluo detektita kaj neniu instigo agordita",
|
||||
"More than one forced audio stream detected! Please select stream": "Pli ol unu devigita sonfluo detektita! Bonvolu elekti fluon",
|
||||
"More than one forced subtitle stream detected and no prompt set": "Pli ol unu devigita subtitola fluo detektita kaj neniu instigo agordita",
|
||||
"More than one forced subtitle stream detected! Please select stream": "Pli ol unu devigita subtitola fluo detektita! Bonvolu elekti fluon",
|
||||
"More than one forced video stream detected and no prompt set": "Pli ol unu devigita videofluo detektita kaj neniu instigo agordita",
|
||||
"More than one forced video stream detected! Please select stream": "Pli ol unu devigita videofluo detektita! Bonvolu elekti fluon",
|
||||
"Name": "Nomo",
|
||||
"New Pattern": "Nova ŝablono",
|
||||
"New Show": "Nova serio",
|
||||
"New filename pattern": "Nova dosiernoma ŝablono",
|
||||
"New shifted season": "Nova ŝovita sezono",
|
||||
"New stream": "Nova fluo",
|
||||
"No": "Ne",
|
||||
"No changes to apply.": "Neniuj ŝanĝoj por apliki.",
|
||||
"No changes to revert.": "Neniuj ŝanĝoj por malfari.",
|
||||
"Normalization disabled.": "Normaligo malŝaltita.",
|
||||
"Normalization enabled.": "Normaligo ŝaltita.",
|
||||
"Normalize": "Normaligi",
|
||||
"Notes": "Notoj",
|
||||
"Pattern": "Ŝablono",
|
||||
"Planned Changes (file->edited output)": "Planitaj ŝanĝoj (dosiero->redaktita eligo)",
|
||||
"Quality": "Kvalito",
|
||||
"Quit": "Eliri",
|
||||
"Remove Pattern": "Forigi ŝablonon",
|
||||
"Revert": "Malfari",
|
||||
"Reverted pending changes.": "Malfaris atendatajn ŝanĝojn.",
|
||||
"Save": "Konservi",
|
||||
"Season Offset": "Sezona deŝovo",
|
||||
"Select a stream first.": "Bonvolu unue elekti fluon.",
|
||||
"Set Default": "Agordi kiel defaŭltan",
|
||||
"Set Forced": "Agordi kiel devigitan",
|
||||
"Settings Screen": "Agorda ekrano",
|
||||
"Numbering Mapping": "Ŝovitaj sezonoj",
|
||||
"Show": "Serio",
|
||||
"Shows": "Serioj",
|
||||
"Source Season": "Fonta sezono",
|
||||
"SrcIndex": "Fontindekso",
|
||||
"Status": "Stato",
|
||||
"Stay": "Resti",
|
||||
"Stream dispositions": "Fluaj dispozicioj",
|
||||
"Stream tags": "Fluaj etikedoj",
|
||||
"Streams": "Fluoj",
|
||||
"SubIndex": "Subindekso",
|
||||
"Substitute": "Anstataŭigi",
|
||||
"Substitute pattern": "Anstataŭigi ŝablonon",
|
||||
"Title": "Titolo",
|
||||
"Type": "Tipo",
|
||||
"Unable to update selected stream.": "Ne eblis ĝisdatigi la elektitan fluon.",
|
||||
"Up": "Supren",
|
||||
"Update Pattern": "Ĝisdatigi ŝablonon",
|
||||
"Updated media tag {tag!r}.": "Ĝisdatigis la aŭdvidan etikedon {tag!r}.",
|
||||
"Updated stream #{index} ({track_type}).": "Ĝisdatigis fluon #{index} ({track_type}).",
|
||||
"Value": "Valoro",
|
||||
"Year": "Jaro",
|
||||
"Yes": "Jes",
|
||||
"add media tag: key='{key}' value='{value}'": "aldoni aŭdvidan etikedon: ŝlosilo='{key}' valoro='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "aldoni {track_type}-trakon: indekso={index} lingvo={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "aldonaĵo",
|
||||
"audio": "sono",
|
||||
"captions": "subtekstoj",
|
||||
"change media tag: key='{key}' value='{value}'": "ŝanĝi aŭdvidan etikedon: ŝlosilo='{key}' valoro='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "ŝanĝi fluon #{index} ({track_type}:{sub_index}) aldoni dispozicion={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "ŝanĝi fluon #{index} ({track_type}:{sub_index}) aldoni ŝlosilon={key} valoron={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "ŝanĝi fluon #{index} ({track_type}:{sub_index}) ŝanĝi ŝlosilon={key} valoron={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "ŝanĝi fluon #{index} ({track_type}:{sub_index}) forigi dispozicion={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "ŝanĝi fluon #{index} ({track_type}:{sub_index}) forigi ŝlosilon={key} valoron={value}",
|
||||
"clean_effects": "nur efektoj",
|
||||
"comment": "komento",
|
||||
"default": "defaŭlta",
|
||||
"dependent": "dependa",
|
||||
"descriptions": "priskriboj",
|
||||
"dub": "dublado",
|
||||
"for pattern": "por ŝablono",
|
||||
"forced": "devigita",
|
||||
"from": "de",
|
||||
"from pattern": "de ŝablono",
|
||||
"from show": "el serio",
|
||||
"hearing_impaired": "aŭdmalhelpita",
|
||||
"karaoke": "karaokeo",
|
||||
"lyrics": "kantoteksto",
|
||||
"metadata": "metadatenoj",
|
||||
"non_diegetic": "nediĝeta",
|
||||
"original": "originala",
|
||||
"pattern #{id}": "ŝablono #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "forigi aŭdvidan etikedon: ŝlosilo='{key}' valoro='{value}'",
|
||||
"remove stream #{index}": "forigi fluon #{index}",
|
||||
"show #{id}": "serio #{id}",
|
||||
"stereo": "stereo",
|
||||
"still_image": "senmova bildo",
|
||||
"sub index": "subindekso",
|
||||
"subtitle": "subtitolo",
|
||||
"timed_thumbnails": "tempigitaj bildetoj",
|
||||
"undefined": "nedifinita",
|
||||
"unknown": "nekonata",
|
||||
"video": "video",
|
||||
"visual_impaired": "vidmalhelpita"
|
||||
}
|
||||
}
|
||||
361
assets/i18n/es.json
Normal file
361
assets/i18n/es.json
Normal file
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "Abjaziano",
|
||||
"AFAR": "Afar",
|
||||
"AFRIKAANS": "Afrikaans",
|
||||
"AKAN": "Akan",
|
||||
"ALBANIAN": "Albanés",
|
||||
"AMHARIC": "Ámárico",
|
||||
"ARABIC": "Árábe",
|
||||
"ARAGONESE": "Aragonés",
|
||||
"ARMENIAN": "Armenio",
|
||||
"ASSAMESE": "Assamais",
|
||||
"AVARIC": "Avaric",
|
||||
"AVESTAN": "Avestan",
|
||||
"AYMARA": "Aymará",
|
||||
"AZERBAIJANI": "Azerbayano",
|
||||
"BAMBARA": "Bambara",
|
||||
"BASHKIR": "Bashkir",
|
||||
"BASQUE": "Vasco",
|
||||
"BELARUSIAN": "Bieloruso",
|
||||
"BENGALI": "Bengalí",
|
||||
"BISLAMA": "Bislama",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "Bosnio",
|
||||
"BRETON": "Bretón",
|
||||
"BULGARIAN": "Búlgaro",
|
||||
"BURMESE": "Birmano",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "Chamorro",
|
||||
"CHECHEN": "Checheno",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "Chino",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "Chuvash",
|
||||
"CORNISH": "Córnico",
|
||||
"CORSICAN": "Corso",
|
||||
"CREE": "Cree",
|
||||
"CROATIAN": "Croata",
|
||||
"CZECH": "Checo",
|
||||
"DANISH": "Danés",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "Butaní",
|
||||
"ENGLISH": "Inglés",
|
||||
"ESPERANTO": "Esperanto",
|
||||
"ESTONIAN": "Estonio",
|
||||
"EWE": "Ewe",
|
||||
"FAROESE": "Feroés",
|
||||
"FIJIAN": "Fidji",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "Finés",
|
||||
"FRENCH": "Francés",
|
||||
"FULAH": "Fulah",
|
||||
"GALICIAN": "Gallego",
|
||||
"GANDA": "Ganda",
|
||||
"GEORGIAN": "Georgiano",
|
||||
"GERMAN": "Alemán",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "Guaraní",
|
||||
"GUJARATI": "guyaratí",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "Haussa",
|
||||
"HEBREW": "Hebreo",
|
||||
"HERERO": "Herero",
|
||||
"HINDI": "Hindi",
|
||||
"HIRI_MOTU": "Hiri Motu",
|
||||
"HUNGARIAN": "Húngaro",
|
||||
"ICELANDIC": "Islandés",
|
||||
"IDO": "Ido",
|
||||
"IGBO": "Igbo",
|
||||
"INDONESIAN": "Indonesio",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "Inuktitut",
|
||||
"INUPIAQ": "Inupiak",
|
||||
"IRISH": "Irlandés",
|
||||
"ITALIAN": "Italiano",
|
||||
"JAPANESE": "Japonés",
|
||||
"JAVANESE": "Javanés",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "Canarés",
|
||||
"KANURI": "Kanuri",
|
||||
"KASHMIRI": "Kashmir",
|
||||
"KAZAKH": "Kazako",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "Kinyarwanda",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "Komi",
|
||||
"KONGO": "Kongo",
|
||||
"KOREAN": "Coreano",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "Kurdo",
|
||||
"LAO": "laosiano",
|
||||
"LATIN": "Latín",
|
||||
"LATVIAN": "Letón",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "Lingala",
|
||||
"LITHUANIAN": "Lituano",
|
||||
"LUBA_KATANGA": "Luba-Katanga",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "Macedonio",
|
||||
"MALAGASY": "Malgache",
|
||||
"MALAY": "Malayo",
|
||||
"MALAYALAM": "malabar",
|
||||
"MALTESE": "Maltés",
|
||||
"MANX": "Manx [Gaélico de Manx]",
|
||||
"MAORI": "Maorí",
|
||||
"MARATHI": "Marath",
|
||||
"MARSHALLESE": "Marshall",
|
||||
"MONGOLIAN": "Mongol",
|
||||
"NAURU": "Nauru",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "Ndonga",
|
||||
"NEPALI": "Nepalés",
|
||||
"NORTHERN_SAMI": "Sami del Norte",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "Noruego",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "Ojibwa",
|
||||
"ORIYA": "Oriya",
|
||||
"OROMO": "Oromo (Afan)",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "Pali",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "Persa",
|
||||
"POLISH": "Polaco",
|
||||
"PORTUGUESE": "Portugués",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "Quechua",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "Romaní",
|
||||
"RUNDI": "Kiroundi",
|
||||
"RUSSIAN": "Ruso",
|
||||
"SAMOAN": "Samoano",
|
||||
"SANGO": "Sango",
|
||||
"SANSKRIT": "Sánscrito",
|
||||
"SARDINIAN": "Sardo",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "Serbio",
|
||||
"SHONA": "Shona",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "Sindhi",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "Eslovaco",
|
||||
"SLOVENIAN": "Esloveno",
|
||||
"SOMALI": "Somalí",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "Sondanés",
|
||||
"SWAHILI": "Swahili",
|
||||
"SWATI": "Siswati",
|
||||
"SWEDISH": "Sueco",
|
||||
"TAGALOG": "Tagalo",
|
||||
"TAHITIAN": "Tahitiano",
|
||||
"TAJIK": "Tajiko",
|
||||
"TAMIL": "Tamil",
|
||||
"TATAR": "Tataro",
|
||||
"TELUGU": "Telugu",
|
||||
"THAI": "Tailandés",
|
||||
"TIBETAN": "Tibetano",
|
||||
"TIGRINYA": "Tigrinya",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "Tsonga",
|
||||
"TSWANA": "Setchwana",
|
||||
"TURKISH": "Turco",
|
||||
"TURKMEN": "Turkmeno",
|
||||
"TWI": "Tchi",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "Ukranio",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "Urdu",
|
||||
"UZBEK": "Uzbeko",
|
||||
"VENDA": "Venda",
|
||||
"VIETNAMESE": "Vietnamita",
|
||||
"VOLAPUK": "Volapük",
|
||||
"WALLOON": "valón",
|
||||
"WELSH": "Galés",
|
||||
"WESTERN_FRISIAN": "Frisón occidental",
|
||||
"WOLOF": "Wolof",
|
||||
"XHOSA": "Xhosa",
|
||||
"YIDDISH": "Yidish",
|
||||
"YORUBA": "Yoruba",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "Zulu"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<Nueva serie>",
|
||||
"Add": "Añadir",
|
||||
"Add Pattern": "Añadir patrón",
|
||||
"Apply": "Aplicar",
|
||||
"Apply failed: {error}": "Error al aplicar: {error}",
|
||||
"Are you sure to delete the following filename pattern?": "¿Seguro que quieres eliminar el siguiente patrón de nombre de archivo?",
|
||||
"Are you sure to delete the following shifted season?": "¿Seguro que quieres eliminar la siguiente temporada desplazada?",
|
||||
"Are you sure to delete the following show?": "¿Seguro que quieres eliminar la siguiente serie?",
|
||||
"Are you sure to delete the following {track_type} track?": "¿Seguro que quieres eliminar la pista {track_type} siguiente?",
|
||||
"Are you sure to delete this tag?": "¿Seguro que quieres eliminar esta etiqueta?",
|
||||
"Audio Layout": "Disposición de audio",
|
||||
"Back": "Volver",
|
||||
"Cancel": "Cancelar",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "No se puede añadir otro flujo con la marca de disposición 'default' o 'forced' activada",
|
||||
"Changes applied and file reloaded.": "Cambios aplicados y archivo recargado.",
|
||||
"Cleanup": "Limpieza",
|
||||
"Cleanup disabled.": "Limpieza desactivada.",
|
||||
"Cleanup enabled.": "Limpieza activada.",
|
||||
"Codec": "Códec",
|
||||
"Continuing edit session.": "Continuando la sesión de edición.",
|
||||
"Default": "Predeterminado",
|
||||
"Delete": "Eliminar",
|
||||
"Delete Show": "Eliminar serie",
|
||||
"Deleted media tag {tag!r}.": "Etiqueta de medios {tag!r} eliminada.",
|
||||
"Differences": "Diferencias",
|
||||
"Differences (file->db/output)": "Diferencias (archivo->BD/salida)",
|
||||
"Discard": "Descartar",
|
||||
"Discard pending metadata changes and quit?": "¿Descartar los cambios pendientes de metadatos y salir?",
|
||||
"Discard pending metadata changes and reload the file state?": "¿Descartar los cambios pendientes de metadatos y recargar el estado del archivo?",
|
||||
"Down": "Abajo",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "Simulación: reescribiría mediante el archivo temporal {target_path}",
|
||||
"Edit": "Editar",
|
||||
"Edit Pattern": "Editar patrón",
|
||||
"Edit Show": "Editar serie",
|
||||
"Edit filename pattern": "Editar patrón de nombre de archivo",
|
||||
"Edit shifted season": "Editar temporada desplazada",
|
||||
"Edit stream": "Editar flujo",
|
||||
"Episode Offset": "Desplazamiento de episodio",
|
||||
"Episode offset": "Desplazamiento de episodio",
|
||||
"File": "Archivo",
|
||||
"File patterns": "Patrones de archivo",
|
||||
"First Episode": "Primer episodio",
|
||||
"First episode": "Primer episodio",
|
||||
"Forced": "Forzado",
|
||||
"Help": "Ayuda",
|
||||
"Help Screen": "Pantalla de ayuda",
|
||||
"ID": "ID",
|
||||
"Identify": "Identificar",
|
||||
"Index": "Índice",
|
||||
"Index / Subindex": "Índice / Subíndice",
|
||||
"Index Episode Digits": "Dígitos del índice de episodio",
|
||||
"Index Season Digits": "Dígitos del índice de temporada",
|
||||
"Indicator Edisode Digits": "Dígitos del indicador de episodio",
|
||||
"Indicator Season Digits": "Dígitos del indicador de temporada",
|
||||
"Keep Editing": "Seguir editando",
|
||||
"Keeping pending changes.": "Se conservan los cambios pendientes.",
|
||||
"Key": "Clave",
|
||||
"Language": "Idioma",
|
||||
"Last Episode": "Último episodio",
|
||||
"Last episode": "Último episodio",
|
||||
"Layout": "Diseño",
|
||||
"Media Tags": "Etiquetas de medios",
|
||||
"More than one default audio stream detected and no prompt set": "Se detectó más de un flujo de audio predeterminado y no hay aviso configurado",
|
||||
"More than one default audio stream detected! Please select stream": "Se detectó más de un flujo de audio predeterminado. Selecciona el flujo",
|
||||
"More than one default subtitle stream detected and no prompt set": "Se detectó más de un flujo de subtítulos predeterminado y no hay aviso configurado",
|
||||
"More than one default subtitle stream detected! Please select stream": "Se detectó más de un flujo de subtítulos predeterminado. Selecciona el flujo",
|
||||
"More than one default video stream detected and no prompt set": "Se detectó más de un flujo de vídeo predeterminado y no hay aviso configurado",
|
||||
"More than one default video stream detected! Please select stream": "Se detectó más de un flujo de vídeo predeterminado. Selecciona el flujo",
|
||||
"More than one forced audio stream detected and no prompt set": "Se detectó más de un flujo de audio forzado y no hay aviso configurado",
|
||||
"More than one forced audio stream detected! Please select stream": "Se detectó más de un flujo de audio forzado. Selecciona el flujo",
|
||||
"More than one forced subtitle stream detected and no prompt set": "Se detectó más de un flujo de subtítulos forzados y no hay aviso configurado",
|
||||
"More than one forced subtitle stream detected! Please select stream": "Se detectó más de un flujo de subtítulos forzados. Selecciona el flujo",
|
||||
"More than one forced video stream detected and no prompt set": "Se detectó más de un flujo de vídeo forzado y no hay aviso configurado",
|
||||
"More than one forced video stream detected! Please select stream": "Se detectó más de un flujo de vídeo forzado. Selecciona el flujo",
|
||||
"Name": "Nombre",
|
||||
"New Pattern": "Nuevo patrón",
|
||||
"New Show": "Nueva serie",
|
||||
"New filename pattern": "Nuevo patrón de nombre de archivo",
|
||||
"New shifted season": "Nueva temporada desplazada",
|
||||
"New stream": "Nuevo flujo",
|
||||
"No": "No",
|
||||
"No changes to apply.": "No hay cambios para aplicar.",
|
||||
"No changes to revert.": "No hay cambios para revertir.",
|
||||
"Normalization disabled.": "Normalización desactivada.",
|
||||
"Normalization enabled.": "Normalización activada.",
|
||||
"Normalize": "Normalizar",
|
||||
"Notes": "Notas",
|
||||
"Pattern": "Patrón",
|
||||
"Planned Changes (file->edited output)": "Cambios planificados (archivo->salida editada)",
|
||||
"Quality": "Calidad",
|
||||
"Quit": "Salir",
|
||||
"Remove Pattern": "Eliminar patrón",
|
||||
"Revert": "Revertir",
|
||||
"Reverted pending changes.": "Se revirtieron los cambios pendientes.",
|
||||
"Save": "Guardar",
|
||||
"Season Offset": "Desplazamiento de temporada",
|
||||
"Select a stream first.": "Selecciona primero un flujo.",
|
||||
"Set Default": "Establecer como predeterminado",
|
||||
"Set Forced": "Establecer como forzado",
|
||||
"Settings Screen": "Pantalla de ajustes",
|
||||
"Numbering Mapping": "Temporadas desplazadas",
|
||||
"Show": "Serie",
|
||||
"Shows": "Series",
|
||||
"Source Season": "Temporada de origen",
|
||||
"SrcIndex": "Índice origen",
|
||||
"Status": "Estado",
|
||||
"Stay": "Permanecer",
|
||||
"Stream dispositions": "Disposiciones del flujo",
|
||||
"Stream tags": "Etiquetas del flujo",
|
||||
"Streams": "Flujos",
|
||||
"SubIndex": "Subíndice",
|
||||
"Substitute": "Sustituir",
|
||||
"Substitute pattern": "Sustituir patrón",
|
||||
"Title": "Título",
|
||||
"Type": "Tipo",
|
||||
"Unable to update selected stream.": "No se pudo actualizar el flujo seleccionado.",
|
||||
"Up": "Arriba",
|
||||
"Update Pattern": "Actualizar patrón",
|
||||
"Updated media tag {tag!r}.": "Etiqueta de medios {tag!r} actualizada.",
|
||||
"Updated stream #{index} ({track_type}).": "Flujo #{index} ({track_type}) actualizado.",
|
||||
"Value": "Valor",
|
||||
"Year": "Año",
|
||||
"Yes": "Sí",
|
||||
"add media tag: key='{key}' value='{value}'": "añadir etiqueta de medios: clave='{key}' valor='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "añadir pista {track_type}: índice={index} idioma={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "adjunto",
|
||||
"audio": "audio",
|
||||
"captions": "subtítulos",
|
||||
"change media tag: key='{key}' value='{value}'": "cambiar etiqueta de medios: clave='{key}' valor='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "cambiar flujo #{index} ({track_type}:{sub_index}) añadir disposición={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "cambiar flujo #{index} ({track_type}:{sub_index}) añadir clave={key} valor={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "cambiar flujo #{index} ({track_type}:{sub_index}) cambiar clave={key} valor={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "cambiar flujo #{index} ({track_type}:{sub_index}) quitar disposición={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "cambiar flujo #{index} ({track_type}:{sub_index}) quitar clave={key} valor={value}",
|
||||
"clean_effects": "solo efectos",
|
||||
"comment": "comentario",
|
||||
"default": "predeterminado",
|
||||
"dependent": "dependiente",
|
||||
"descriptions": "descripciones",
|
||||
"dub": "doblaje",
|
||||
"for pattern": "para el patrón",
|
||||
"forced": "forzado",
|
||||
"from": "de",
|
||||
"from pattern": "del patrón",
|
||||
"from show": "de la serie",
|
||||
"hearing_impaired": "personas con discapacidad auditiva",
|
||||
"karaoke": "karaoke",
|
||||
"lyrics": "letra",
|
||||
"metadata": "metadatos",
|
||||
"non_diegetic": "no diegético",
|
||||
"original": "original",
|
||||
"pattern #{id}": "patrón #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "eliminar etiqueta de medios: clave='{key}' valor='{value}'",
|
||||
"remove stream #{index}": "eliminar flujo #{index}",
|
||||
"show #{id}": "serie #{id}",
|
||||
"stereo": "estéreo",
|
||||
"still_image": "imagen fija",
|
||||
"sub index": "subíndice",
|
||||
"subtitle": "subtítulo",
|
||||
"timed_thumbnails": "miniaturas temporizadas",
|
||||
"undefined": "indefinido",
|
||||
"unknown": "desconocido",
|
||||
"video": "vídeo",
|
||||
"visual_impaired": "personas con discapacidad visual"
|
||||
}
|
||||
}
|
||||
361
assets/i18n/fr.json
Normal file
361
assets/i18n/fr.json
Normal file
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "Abkhaze",
|
||||
"AFAR": "Afar",
|
||||
"AFRIKAANS": "Afrikaans",
|
||||
"AKAN": "Akan",
|
||||
"ALBANIAN": "Albanais",
|
||||
"AMHARIC": "Amharique",
|
||||
"ARABIC": "Arabe",
|
||||
"ARAGONESE": "Aragonais",
|
||||
"ARMENIAN": "Arménien",
|
||||
"ASSAMESE": "Assamais",
|
||||
"AVARIC": "Avar",
|
||||
"AVESTAN": "Avestique",
|
||||
"AYMARA": "Aymara",
|
||||
"AZERBAIJANI": "Azéri",
|
||||
"BAMBARA": "Bambara",
|
||||
"BASHKIR": "Bachkir",
|
||||
"BASQUE": "Basque",
|
||||
"BELARUSIAN": "Biélorusse",
|
||||
"BENGALI": "Bengali",
|
||||
"BISLAMA": "Bichelamar",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "Bosniaque",
|
||||
"BRETON": "Breton",
|
||||
"BULGARIAN": "Bulgare",
|
||||
"BURMESE": "Birman",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "Chamorro",
|
||||
"CHECHEN": "Tchétchène",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "Chinois",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "Tchouvache",
|
||||
"CORNISH": "Cornique",
|
||||
"CORSICAN": "Corse",
|
||||
"CREE": "Cri",
|
||||
"CROATIAN": "Croate",
|
||||
"CZECH": "Tchèque",
|
||||
"DANISH": "Danois",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "Dzongkha",
|
||||
"ENGLISH": "Anglais",
|
||||
"ESPERANTO": "Espéranto",
|
||||
"ESTONIAN": "Estonien",
|
||||
"EWE": "Éwé",
|
||||
"FAROESE": "Féroïen",
|
||||
"FIJIAN": "Fidjien",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "Finnois",
|
||||
"FRENCH": "Français",
|
||||
"FULAH": "Peul",
|
||||
"GALICIAN": "Galicien",
|
||||
"GANDA": "Ganda",
|
||||
"GEORGIAN": "Géorgien",
|
||||
"GERMAN": "Allemand",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "Guarani",
|
||||
"GUJARATI": "Goudjarâtî (Gujrâtî)",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "Haoussa",
|
||||
"HEBREW": "Hébreu",
|
||||
"HERERO": "Herero",
|
||||
"HINDI": "Hindi",
|
||||
"HIRI_MOTU": "Hiri Motu",
|
||||
"HUNGARIAN": "Hongrois",
|
||||
"ICELANDIC": "Islandais",
|
||||
"IDO": "Ido",
|
||||
"IGBO": "Igbo",
|
||||
"INDONESIAN": "Indonésien",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "Inuktitut",
|
||||
"INUPIAQ": "Inupiaq",
|
||||
"IRISH": "Irlandais",
|
||||
"ITALIAN": "Italien",
|
||||
"JAPANESE": "Japonais",
|
||||
"JAVANESE": "Javanais",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "Kannara (Canara)",
|
||||
"KANURI": "Kanouri",
|
||||
"KASHMIRI": "Kashmiri",
|
||||
"KAZAKH": "Kazakh",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "Kinyarwanda",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "Komi",
|
||||
"KONGO": "Kongo",
|
||||
"KOREAN": "Coréen",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "Kurde",
|
||||
"LAO": "Laotien",
|
||||
"LATIN": "Latin",
|
||||
"LATVIAN": "Letton",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "Lingala",
|
||||
"LITHUANIAN": "Lituanien",
|
||||
"LUBA_KATANGA": "Luba-katanga",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "Macédonien",
|
||||
"MALAGASY": "Malgache",
|
||||
"MALAY": "Malais",
|
||||
"MALAYALAM": "Malayalam",
|
||||
"MALTESE": "Maltais",
|
||||
"MANX": "Mannois",
|
||||
"MAORI": "Maori",
|
||||
"MARATHI": "Marathe",
|
||||
"MARSHALLESE": "Marshallais",
|
||||
"MONGOLIAN": "Mongol",
|
||||
"NAURU": "Nauru",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "Ndonga",
|
||||
"NEPALI": "Népalais",
|
||||
"NORTHERN_SAMI": "Same du Nord",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "Norvégien",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "Ojibwa",
|
||||
"ORIYA": "Oriya",
|
||||
"OROMO": "Oromo",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "Pali",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "Persan",
|
||||
"POLISH": "Polonais",
|
||||
"PORTUGUESE": "Portugais",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "Quechua",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "Romanche",
|
||||
"RUNDI": "Rundi",
|
||||
"RUSSIAN": "Russe",
|
||||
"SAMOAN": "Samoan",
|
||||
"SANGO": "Sango",
|
||||
"SANSKRIT": "Sanskrit",
|
||||
"SARDINIAN": "Sarde",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "Serbe",
|
||||
"SHONA": "Shona",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "Sindhi",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "Slovaque",
|
||||
"SLOVENIAN": "Slovène",
|
||||
"SOMALI": "Somali",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "Sundanais",
|
||||
"SWAHILI": "Swahili",
|
||||
"SWATI": "Swati",
|
||||
"SWEDISH": "Suédois",
|
||||
"TAGALOG": "Tagalog",
|
||||
"TAHITIAN": "Tahitien",
|
||||
"TAJIK": "Tadjik",
|
||||
"TAMIL": "Tamoul",
|
||||
"TATAR": "Tatar",
|
||||
"TELUGU": "Télougou",
|
||||
"THAI": "Thaï",
|
||||
"TIBETAN": "Tibétain",
|
||||
"TIGRINYA": "Tigrigna",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "Tsonga",
|
||||
"TSWANA": "Tswana",
|
||||
"TURKISH": "Turc",
|
||||
"TURKMEN": "Turkmène",
|
||||
"TWI": "Twi",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "Ukrainien",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "Ourdou",
|
||||
"UZBEK": "Ouszbek",
|
||||
"VENDA": "Venda",
|
||||
"VIETNAMESE": "Vietnamien",
|
||||
"VOLAPUK": "Volapük",
|
||||
"WALLOON": "Wallon",
|
||||
"WELSH": "Gallois",
|
||||
"WESTERN_FRISIAN": "Frison occidental",
|
||||
"WOLOF": "Wolof",
|
||||
"XHOSA": "Xhosa",
|
||||
"YIDDISH": "Yiddish",
|
||||
"YORUBA": "Yoruba",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "Zoulou"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<Nouvelle série>",
|
||||
"Add": "Ajouter",
|
||||
"Add Pattern": "Ajouter un modèle",
|
||||
"Apply": "Appliquer",
|
||||
"Apply failed: {error}": "Échec de l'application : {error}",
|
||||
"Are you sure to delete the following filename pattern?": "Voulez-vous vraiment supprimer le modèle de nom de fichier suivant ?",
|
||||
"Are you sure to delete the following shifted season?": "Voulez-vous vraiment supprimer la saison décalée suivante ?",
|
||||
"Are you sure to delete the following show?": "Voulez-vous vraiment supprimer la série suivante ?",
|
||||
"Are you sure to delete the following {track_type} track?": "Voulez-vous vraiment supprimer la piste {track_type} suivante ?",
|
||||
"Are you sure to delete this tag?": "Voulez-vous vraiment supprimer cette balise ?",
|
||||
"Audio Layout": "Disposition audio",
|
||||
"Back": "Retour",
|
||||
"Cancel": "Annuler",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "Impossible d'ajouter un autre flux avec l'indicateur de disposition 'default' ou 'forced'",
|
||||
"Changes applied and file reloaded.": "Modifications appliquées et fichier rechargé.",
|
||||
"Cleanup": "Nettoyage",
|
||||
"Cleanup disabled.": "Nettoyage désactivé.",
|
||||
"Cleanup enabled.": "Nettoyage activé.",
|
||||
"Codec": "Codec",
|
||||
"Continuing edit session.": "Poursuite de la session d'édition.",
|
||||
"Default": "Par défaut",
|
||||
"Delete": "Supprimer",
|
||||
"Delete Show": "Supprimer la série",
|
||||
"Deleted media tag {tag!r}.": "Balise média {tag!r} supprimée.",
|
||||
"Differences": "Différences",
|
||||
"Differences (file->db/output)": "Différences (fichier->BD/sortie)",
|
||||
"Discard": "Ignorer",
|
||||
"Discard pending metadata changes and quit?": "Ignorer les modifications de métadonnées en attente et quitter ?",
|
||||
"Discard pending metadata changes and reload the file state?": "Ignorer les modifications de métadonnées en attente et recharger l'état du fichier ?",
|
||||
"Down": "Descendre",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "Simulation : réécrirait via le fichier temporaire {target_path}",
|
||||
"Edit": "Modifier",
|
||||
"Edit Pattern": "Modifier le modèle",
|
||||
"Edit Show": "Modifier la série",
|
||||
"Edit filename pattern": "Modifier le modèle de nom de fichier",
|
||||
"Edit shifted season": "Modifier la saison décalée",
|
||||
"Edit stream": "Modifier le flux",
|
||||
"Episode Offset": "Décalage d'épisode",
|
||||
"Episode offset": "Décalage d'épisode",
|
||||
"File": "Fichier",
|
||||
"File patterns": "Modèles de fichiers",
|
||||
"First Episode": "Premier épisode",
|
||||
"First episode": "Premier épisode",
|
||||
"Forced": "Forcé",
|
||||
"Help": "Aide",
|
||||
"Help Screen": "Écran d'aide",
|
||||
"ID": "ID",
|
||||
"Identify": "Identifier",
|
||||
"Index": "Index",
|
||||
"Index / Subindex": "Index / Sous-index",
|
||||
"Index Episode Digits": "Chiffres d'épisode d'index",
|
||||
"Index Season Digits": "Chiffres de saison d'index",
|
||||
"Indicator Edisode Digits": "Chiffres d'épisode de l'indicateur",
|
||||
"Indicator Season Digits": "Chiffres de saison de l'indicateur",
|
||||
"Keep Editing": "Continuer l'édition",
|
||||
"Keeping pending changes.": "Les modifications en attente sont conservées.",
|
||||
"Key": "Clé",
|
||||
"Language": "Langue",
|
||||
"Last Episode": "Dernier épisode",
|
||||
"Last episode": "Dernier épisode",
|
||||
"Layout": "Disposition",
|
||||
"Media Tags": "Balises média",
|
||||
"More than one default audio stream detected and no prompt set": "Plus d'un flux audio par défaut détecté et aucune invite définie",
|
||||
"More than one default audio stream detected! Please select stream": "Plus d'un flux audio par défaut détecté ! Veuillez sélectionner un flux",
|
||||
"More than one default subtitle stream detected and no prompt set": "Plus d'un flux de sous-titres par défaut détecté et aucune invite définie",
|
||||
"More than one default subtitle stream detected! Please select stream": "Plus d'un flux de sous-titres par défaut détecté ! Veuillez sélectionner un flux",
|
||||
"More than one default video stream detected and no prompt set": "Plus d'un flux vidéo par défaut détecté et aucune invite définie",
|
||||
"More than one default video stream detected! Please select stream": "Plus d'un flux vidéo par défaut détecté ! Veuillez sélectionner un flux",
|
||||
"More than one forced audio stream detected and no prompt set": "Plus d'un flux audio forcé détecté et aucune invite définie",
|
||||
"More than one forced audio stream detected! Please select stream": "Plus d'un flux audio forcé détecté ! Veuillez sélectionner un flux",
|
||||
"More than one forced subtitle stream detected and no prompt set": "Plus d'un flux de sous-titres forcé détecté et aucune invite définie",
|
||||
"More than one forced subtitle stream detected! Please select stream": "Plus d'un flux de sous-titres forcé détecté ! Veuillez sélectionner un flux",
|
||||
"More than one forced video stream detected and no prompt set": "Plus d'un flux vidéo forcé détecté et aucune invite définie",
|
||||
"More than one forced video stream detected! Please select stream": "Plus d'un flux vidéo forcé détecté ! Veuillez sélectionner un flux",
|
||||
"Name": "Nom",
|
||||
"New Pattern": "Nouveau modèle",
|
||||
"New Show": "Nouvelle série",
|
||||
"New filename pattern": "Nouveau modèle de nom de fichier",
|
||||
"New shifted season": "Nouvelle saison décalée",
|
||||
"New stream": "Nouveau flux",
|
||||
"No": "Non",
|
||||
"No changes to apply.": "Aucune modification à appliquer.",
|
||||
"No changes to revert.": "Aucune modification à annuler.",
|
||||
"Normalization disabled.": "Normalisation désactivée.",
|
||||
"Normalization enabled.": "Normalisation activée.",
|
||||
"Normalize": "Normaliser",
|
||||
"Notes": "Notes",
|
||||
"Pattern": "Modèle",
|
||||
"Planned Changes (file->edited output)": "Modifications prévues (fichier->sortie modifiée)",
|
||||
"Quality": "Qualité",
|
||||
"Quit": "Quitter",
|
||||
"Remove Pattern": "Supprimer le modèle",
|
||||
"Revert": "Annuler les modifications",
|
||||
"Reverted pending changes.": "Modifications en attente annulées.",
|
||||
"Save": "Enregistrer",
|
||||
"Season Offset": "Décalage de saison",
|
||||
"Select a stream first.": "Veuillez d'abord sélectionner un flux.",
|
||||
"Set Default": "Définir par défaut",
|
||||
"Set Forced": "Définir comme forcé",
|
||||
"Settings Screen": "Écran des paramètres",
|
||||
"Numbering Mapping": "Saisons décalées",
|
||||
"Show": "Série",
|
||||
"Shows": "Séries",
|
||||
"Source Season": "Saison source",
|
||||
"SrcIndex": "Index source",
|
||||
"Status": "Statut",
|
||||
"Stay": "Rester",
|
||||
"Stream dispositions": "Dispositions des flux",
|
||||
"Stream tags": "Balises du flux",
|
||||
"Streams": "Flux",
|
||||
"SubIndex": "Sous-index",
|
||||
"Substitute": "Remplacer",
|
||||
"Substitute pattern": "Remplacer le modèle",
|
||||
"Title": "Titre",
|
||||
"Type": "Type",
|
||||
"Unable to update selected stream.": "Impossible de mettre à jour le flux sélectionné.",
|
||||
"Up": "Monter",
|
||||
"Update Pattern": "Mettre à jour le modèle",
|
||||
"Updated media tag {tag!r}.": "Balise média {tag!r} mise à jour.",
|
||||
"Updated stream #{index} ({track_type}).": "Flux #{index} ({track_type}) mis à jour.",
|
||||
"Value": "Valeur",
|
||||
"Year": "Année",
|
||||
"Yes": "Oui",
|
||||
"add media tag: key='{key}' value='{value}'": "ajouter une balise média : clé='{key}' valeur='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "ajouter une piste {track_type} : index={index} langue={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "pièce jointe",
|
||||
"audio": "audio",
|
||||
"captions": "sous-titres",
|
||||
"change media tag: key='{key}' value='{value}'": "modifier une balise média : clé='{key}' valeur='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "modifier le flux #{index} ({track_type}:{sub_index}) ajouter disposition={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "modifier le flux #{index} ({track_type}:{sub_index}) ajouter clé={key} valeur={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "modifier le flux #{index} ({track_type}:{sub_index}) changer clé={key} valeur={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "modifier le flux #{index} ({track_type}:{sub_index}) supprimer disposition={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "modifier le flux #{index} ({track_type}:{sub_index}) supprimer clé={key} valeur={value}",
|
||||
"clean_effects": "effets seuls",
|
||||
"comment": "commentaire",
|
||||
"default": "par défaut",
|
||||
"dependent": "dépendant",
|
||||
"descriptions": "descriptions",
|
||||
"dub": "doublage",
|
||||
"for pattern": "pour le modèle",
|
||||
"forced": "forcé",
|
||||
"from": "de",
|
||||
"from pattern": "depuis le modèle",
|
||||
"from show": "depuis la série",
|
||||
"hearing_impaired": "malentendants",
|
||||
"karaoke": "karaoké",
|
||||
"lyrics": "paroles",
|
||||
"metadata": "métadonnées",
|
||||
"non_diegetic": "non diégétique",
|
||||
"original": "original",
|
||||
"pattern #{id}": "modèle #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "supprimer une balise média : clé='{key}' valeur='{value}'",
|
||||
"remove stream #{index}": "supprimer le flux #{index}",
|
||||
"show #{id}": "série #{id}",
|
||||
"stereo": "stéréo",
|
||||
"still_image": "image fixe",
|
||||
"sub index": "sous-index",
|
||||
"subtitle": "sous-titre",
|
||||
"timed_thumbnails": "miniatures horodatées",
|
||||
"undefined": "indéfini",
|
||||
"unknown": "inconnu",
|
||||
"video": "vidéo",
|
||||
"visual_impaired": "malvoyants"
|
||||
}
|
||||
}
|
||||
361
assets/i18n/ja.json
Normal file
361
assets/i18n/ja.json
Normal file
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "アブハジア語",
|
||||
"AFAR": "アファル語",
|
||||
"AFRIKAANS": "アフリカーンス語",
|
||||
"AKAN": "アカン語",
|
||||
"ALBANIAN": "アルバニア語",
|
||||
"AMHARIC": "アムハラ語",
|
||||
"ARABIC": "アラビア語",
|
||||
"ARAGONESE": "アラゴン語",
|
||||
"ARMENIAN": "アルメニア語",
|
||||
"ASSAMESE": "アッサム語",
|
||||
"AVARIC": "アヴァル語",
|
||||
"AVESTAN": "アヴェスタ語",
|
||||
"AYMARA": "アイマラ語",
|
||||
"AZERBAIJANI": "アゼルバイジャン語",
|
||||
"BAMBARA": "バンバラ語",
|
||||
"BASHKIR": "バシキール語",
|
||||
"BASQUE": "バスク語",
|
||||
"BELARUSIAN": "白ロシア語",
|
||||
"BENGALI": "ベンガル語",
|
||||
"BISLAMA": "ビスラマ語",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "ボスニア語",
|
||||
"BRETON": "ブルトン語",
|
||||
"BULGARIAN": "ブルガリア語",
|
||||
"BURMESE": "ビルマ語",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "チャモロ語",
|
||||
"CHECHEN": "チェチェン語",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "中国語",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "チュヴァシュ語",
|
||||
"CORNISH": "コーンウォール語",
|
||||
"CORSICAN": "コルシカ語",
|
||||
"CREE": "クリー語",
|
||||
"CROATIAN": "クロアチア語",
|
||||
"CZECH": "チェコ語",
|
||||
"DANISH": "デンマーク語",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "ゾンカ語",
|
||||
"ENGLISH": "英語",
|
||||
"ESPERANTO": "エスペラント語",
|
||||
"ESTONIAN": "エストニア語",
|
||||
"EWE": "エウェ語",
|
||||
"FAROESE": "フェロー語",
|
||||
"FIJIAN": "フィジー語",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "フィン語",
|
||||
"FRENCH": "フランス語",
|
||||
"FULAH": "フラ語",
|
||||
"GALICIAN": "ガリシア語",
|
||||
"GANDA": "ガンダ語",
|
||||
"GEORGIAN": "グルジア語",
|
||||
"GERMAN": "ドイツ語",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "グアラニー",
|
||||
"GUJARATI": "グジャラーティー語",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "ハウサ語",
|
||||
"HEBREW": "ヘブライ語",
|
||||
"HERERO": "ヘレロ語",
|
||||
"HINDI": "ヒンディー語",
|
||||
"HIRI_MOTU": "ヒリモトゥ語",
|
||||
"HUNGARIAN": "ハンガリー語",
|
||||
"ICELANDIC": "アイスランド語",
|
||||
"IDO": "イド語",
|
||||
"IGBO": "イボ語",
|
||||
"INDONESIAN": "インドネシア語",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "イヌクウティトット語",
|
||||
"INUPIAQ": "イヌピアック語",
|
||||
"IRISH": "アイルランド語",
|
||||
"ITALIAN": "イタリア語",
|
||||
"JAPANESE": "日本語",
|
||||
"JAVANESE": "ジャワ語",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "カンナダ語",
|
||||
"KANURI": "カヌリ語",
|
||||
"KASHMIRI": "カシミーリー語",
|
||||
"KAZAKH": "カザーフ語",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "キンヤルワンダ語",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "コミ語",
|
||||
"KONGO": "コンゴ語",
|
||||
"KOREAN": "朝鮮語",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "クルド語",
|
||||
"LAO": "ラオ語",
|
||||
"LATIN": "ラテン語",
|
||||
"LATVIAN": "ラトビア語",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "リンガラ語",
|
||||
"LITHUANIAN": "リトアニア語",
|
||||
"LUBA_KATANGA": "ルバ語",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "マケドニア語",
|
||||
"MALAGASY": "マラガシ語",
|
||||
"MALAY": "マライ語",
|
||||
"MALAYALAM": "マラヤーラム語",
|
||||
"MALTESE": "マルタ語",
|
||||
"MANX": "マン島語",
|
||||
"MAORI": "マオリ語",
|
||||
"MARATHI": "マラーティー語",
|
||||
"MARSHALLESE": "マーシャル語",
|
||||
"MONGOLIAN": "蒙古語",
|
||||
"NAURU": "ナウル語",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "ンドンガ語",
|
||||
"NEPALI": "ネパール語",
|
||||
"NORTHERN_SAMI": "北サーミ語",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "ノルウェー語",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "オジブワ語",
|
||||
"ORIYA": "オリヤー語",
|
||||
"OROMO": "オロモ語",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "パーリ語",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "ペルシア語",
|
||||
"POLISH": "ポーランド語",
|
||||
"PORTUGUESE": "ポルトガル語",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "キチュワ語",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "ロマンシュ語",
|
||||
"RUNDI": "ルンディ語",
|
||||
"RUSSIAN": "ロシア語",
|
||||
"SAMOAN": "サモア語",
|
||||
"SANGO": "サンゴ語",
|
||||
"SANSKRIT": "梵語",
|
||||
"SARDINIAN": "サルデーニャ語",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "セルビア語",
|
||||
"SHONA": "ショナ語",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "シンディー語",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "スロヴァキア語",
|
||||
"SLOVENIAN": "スロヴェニア語",
|
||||
"SOMALI": "ソマリ語",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "スンダ語",
|
||||
"SWAHILI": "スワヒリ語",
|
||||
"SWATI": "シスワティ語",
|
||||
"SWEDISH": "スウェーデン語",
|
||||
"TAGALOG": "タガログ語",
|
||||
"TAHITIAN": "タヒチ語",
|
||||
"TAJIK": "タジク語",
|
||||
"TAMIL": "タミル語",
|
||||
"TATAR": "タタール語",
|
||||
"TELUGU": "テルグ語",
|
||||
"THAI": "タイ語",
|
||||
"TIBETAN": "チベット語",
|
||||
"TIGRINYA": "ティグリニア語",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "ツォンガ語",
|
||||
"TSWANA": "ツワナ語",
|
||||
"TURKISH": "トルコ語",
|
||||
"TURKMEN": "トゥルクメン語",
|
||||
"TWI": "トウィ語",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "ウクライナ語",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "ウルドゥー語",
|
||||
"UZBEK": "ウズベク語",
|
||||
"VENDA": "ベンダ語",
|
||||
"VIETNAMESE": "ベトナム語",
|
||||
"VOLAPUK": "ボラピューク語",
|
||||
"WALLOON": "ワロン語",
|
||||
"WELSH": "ウェールズ語",
|
||||
"WESTERN_FRISIAN": "西フリジア語",
|
||||
"WOLOF": "ウォロフ語",
|
||||
"XHOSA": "ホサ語",
|
||||
"YIDDISH": "イディッシュ語",
|
||||
"YORUBA": "ヨルバ語",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "ズールー語"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<新しい番組>",
|
||||
"Add": "追加",
|
||||
"Add Pattern": "パターンを追加",
|
||||
"Apply": "適用",
|
||||
"Apply failed: {error}": "適用に失敗しました: {error}",
|
||||
"Are you sure to delete the following filename pattern?": "次のファイル名パターンを削除してもよろしいですか?",
|
||||
"Are you sure to delete the following shifted season?": "次のシーズンシフト設定を削除してもよろしいですか?",
|
||||
"Are you sure to delete the following show?": "次の番組を削除してもよろしいですか?",
|
||||
"Are you sure to delete the following {track_type} track?": "次の{track_type}ストリームを削除してもよろしいですか?",
|
||||
"Are you sure to delete this tag?": "このタグを削除してもよろしいですか?",
|
||||
"Audio Layout": "音声レイアウト",
|
||||
"Back": "戻る",
|
||||
"Cancel": "キャンセル",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "default または forced の disposition が設定されたストリームはこれ以上追加できません",
|
||||
"Changes applied and file reloaded.": "変更を適用し、ファイルを再読み込みしました。",
|
||||
"Cleanup": "クリーンアップ",
|
||||
"Cleanup disabled.": "クリーンアップを無効にしました。",
|
||||
"Cleanup enabled.": "クリーンアップを有効にしました。",
|
||||
"Codec": "コーデック",
|
||||
"Continuing edit session.": "編集セッションを続行します。",
|
||||
"Default": "デフォルト",
|
||||
"Delete": "削除",
|
||||
"Delete Show": "番組を削除",
|
||||
"Deleted media tag {tag!r}.": "メディアタグ {tag!r} を削除しました。",
|
||||
"Differences": "差分",
|
||||
"Differences (file->db/output)": "差分 (ファイル->DB/出力)",
|
||||
"Discard": "破棄",
|
||||
"Discard pending metadata changes and quit?": "保留中のメタデータ変更を破棄して終了しますか?",
|
||||
"Discard pending metadata changes and reload the file state?": "保留中のメタデータ変更を破棄してファイル状態を再読み込みしますか?",
|
||||
"Down": "下へ",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "ドライラン: 一時ファイル {target_path} 経由で再書き込みします",
|
||||
"Edit": "編集",
|
||||
"Edit Pattern": "パターンを編集",
|
||||
"Edit Show": "番組を編集",
|
||||
"Edit filename pattern": "ファイル名パターンを編集",
|
||||
"Edit shifted season": "シフト済みシーズンを編集",
|
||||
"Edit stream": "ストリームを編集",
|
||||
"Episode Offset": "エピソードオフセット",
|
||||
"Episode offset": "エピソードオフセット",
|
||||
"File": "ファイル",
|
||||
"File patterns": "ファイルパターン",
|
||||
"First Episode": "最初のエピソード",
|
||||
"First episode": "最初のエピソード",
|
||||
"Forced": "強制",
|
||||
"Help": "ヘルプ",
|
||||
"Help Screen": "ヘルプ画面",
|
||||
"ID": "ID",
|
||||
"Identify": "識別",
|
||||
"Index": "インデックス",
|
||||
"Index / Subindex": "インデックス / サブインデックス",
|
||||
"Index Episode Digits": "インデックスのエピソード桁数",
|
||||
"Index Season Digits": "インデックスのシーズン桁数",
|
||||
"Indicator Edisode Digits": "インジケーターのエピソード桁数",
|
||||
"Indicator Season Digits": "インジケーターのシーズン桁数",
|
||||
"Keep Editing": "編集を続ける",
|
||||
"Keeping pending changes.": "保留中の変更を保持します。",
|
||||
"Key": "キー",
|
||||
"Language": "言語",
|
||||
"Last Episode": "最後のエピソード",
|
||||
"Last episode": "最後のエピソード",
|
||||
"Layout": "レイアウト",
|
||||
"Media Tags": "メディアタグ",
|
||||
"More than one default audio stream detected and no prompt set": "デフォルト音声ストリームが複数検出され、プロンプトも設定されていません",
|
||||
"More than one default audio stream detected! Please select stream": "デフォルト音声ストリームが複数検出されました。ストリームを選択してください",
|
||||
"More than one default subtitle stream detected and no prompt set": "デフォルト字幕ストリームが複数検出され、プロンプトも設定されていません",
|
||||
"More than one default subtitle stream detected! Please select stream": "デフォルト字幕ストリームが複数検出されました。ストリームを選択してください",
|
||||
"More than one default video stream detected and no prompt set": "デフォルト映像ストリームが複数検出され、プロンプトも設定されていません",
|
||||
"More than one default video stream detected! Please select stream": "デフォルト映像ストリームが複数検出されました。ストリームを選択してください",
|
||||
"More than one forced audio stream detected and no prompt set": "強制音声ストリームが複数検出され、プロンプトも設定されていません",
|
||||
"More than one forced audio stream detected! Please select stream": "強制音声ストリームが複数検出されました。ストリームを選択してください",
|
||||
"More than one forced subtitle stream detected and no prompt set": "強制字幕ストリームが複数検出され、プロンプトも設定されていません",
|
||||
"More than one forced subtitle stream detected! Please select stream": "強制字幕ストリームが複数検出されました。ストリームを選択してください",
|
||||
"More than one forced video stream detected and no prompt set": "強制映像ストリームが複数検出され、プロンプトも設定されていません",
|
||||
"More than one forced video stream detected! Please select stream": "強制映像ストリームが複数検出されました。ストリームを選択してください",
|
||||
"Name": "名前",
|
||||
"New Pattern": "新しいパターン",
|
||||
"New Show": "新しい番組",
|
||||
"New filename pattern": "新しいファイル名パターン",
|
||||
"New shifted season": "新しいシーズンシフト",
|
||||
"New stream": "新しいストリーム",
|
||||
"No": "いいえ",
|
||||
"No changes to apply.": "適用する変更はありません。",
|
||||
"No changes to revert.": "元に戻す変更はありません。",
|
||||
"Normalization disabled.": "正規化を無効にしました。",
|
||||
"Normalization enabled.": "正規化を有効にしました。",
|
||||
"Normalize": "正規化",
|
||||
"Notes": "メモ",
|
||||
"Pattern": "パターン",
|
||||
"Planned Changes (file->edited output)": "予定された変更 (ファイル->編集後出力)",
|
||||
"Quality": "品質",
|
||||
"Quit": "終了",
|
||||
"Remove Pattern": "パターンを削除",
|
||||
"Revert": "元に戻す",
|
||||
"Reverted pending changes.": "保留中の変更を元に戻しました。",
|
||||
"Save": "保存",
|
||||
"Season Offset": "シーズンオフセット",
|
||||
"Select a stream first.": "まずストリームを選択してください。",
|
||||
"Set Default": "デフォルトに設定",
|
||||
"Set Forced": "強制に設定",
|
||||
"Settings Screen": "設定画面",
|
||||
"Numbering Mapping": "シフト済みシーズン",
|
||||
"Show": "番組",
|
||||
"Shows": "番組一覧",
|
||||
"Source Season": "元シーズン",
|
||||
"SrcIndex": "元インデックス",
|
||||
"Status": "状態",
|
||||
"Stay": "このまま",
|
||||
"Stream dispositions": "ストリーム disposition",
|
||||
"Stream tags": "ストリームタグ",
|
||||
"Streams": "ストリーム",
|
||||
"SubIndex": "サブインデックス",
|
||||
"Substitute": "置換",
|
||||
"Substitute pattern": "パターンを置換",
|
||||
"Title": "タイトル",
|
||||
"Type": "タイプ",
|
||||
"Unable to update selected stream.": "選択したストリームを更新できませんでした。",
|
||||
"Up": "上へ",
|
||||
"Update Pattern": "パターンを更新",
|
||||
"Updated media tag {tag!r}.": "メディアタグ {tag!r} を更新しました。",
|
||||
"Updated stream #{index} ({track_type}).": "ストリーム #{index} ({track_type}) を更新しました。",
|
||||
"Value": "値",
|
||||
"Year": "年",
|
||||
"Yes": "はい",
|
||||
"add media tag: key='{key}' value='{value}'": "メディアタグを追加: key='{key}' value='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "{track_type}ストリームを追加: index={index} lang={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "添付",
|
||||
"audio": "音声",
|
||||
"captions": "キャプション",
|
||||
"change media tag: key='{key}' value='{value}'": "メディアタグを変更: key='{key}' value='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "ストリーム #{index} ({track_type}:{sub_index}) disposition を追加={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "ストリーム #{index} ({track_type}:{sub_index}) key を追加={key} value={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "ストリーム #{index} ({track_type}:{sub_index}) key を変更={key} value={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "ストリーム #{index} ({track_type}:{sub_index}) disposition を削除={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "ストリーム #{index} ({track_type}:{sub_index}) key を削除={key} value={value}",
|
||||
"clean_effects": "効果音のみ",
|
||||
"comment": "コメント",
|
||||
"default": "デフォルト",
|
||||
"dependent": "依存",
|
||||
"descriptions": "解説",
|
||||
"dub": "吹替",
|
||||
"for pattern": "パターン用",
|
||||
"forced": "強制",
|
||||
"from": "元",
|
||||
"from pattern": "パターンから",
|
||||
"from show": "番組から",
|
||||
"hearing_impaired": "聴覚障害者向け",
|
||||
"karaoke": "カラオケ",
|
||||
"lyrics": "歌詞",
|
||||
"metadata": "メタデータ",
|
||||
"non_diegetic": "非ダイジェティック",
|
||||
"original": "オリジナル",
|
||||
"pattern #{id}": "パターン #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "メディアタグを削除: key='{key}' value='{value}'",
|
||||
"remove stream #{index}": "ストリーム #{index} を削除",
|
||||
"show #{id}": "番組 #{id}",
|
||||
"stereo": "ステレオ",
|
||||
"still_image": "静止画",
|
||||
"sub index": "サブインデックス",
|
||||
"subtitle": "字幕",
|
||||
"timed_thumbnails": "時間指定サムネイル",
|
||||
"undefined": "未定義",
|
||||
"unknown": "不明",
|
||||
"video": "映像",
|
||||
"visual_impaired": "視覚障害者向け"
|
||||
}
|
||||
}
|
||||
361
assets/i18n/nb.json
Normal file
361
assets/i18n/nb.json
Normal file
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "Abkhazian",
|
||||
"AFAR": "afar",
|
||||
"AFRIKAANS": "Afrikansk",
|
||||
"AKAN": "Akan",
|
||||
"ALBANIAN": "Albansk",
|
||||
"AMHARIC": "Amharic",
|
||||
"ARABIC": "Arabisk",
|
||||
"ARAGONESE": "aragonsk",
|
||||
"ARMENIAN": "armensk",
|
||||
"ASSAMESE": "assamisk",
|
||||
"AVARIC": "Avaric",
|
||||
"AVESTAN": "avestisk",
|
||||
"AYMARA": "aymara",
|
||||
"AZERBAIJANI": "Aserbadjansk",
|
||||
"BAMBARA": "bambara",
|
||||
"BASHKIR": "basjkirsk",
|
||||
"BASQUE": "Baskisk",
|
||||
"BELARUSIAN": "Hviterussisk",
|
||||
"BENGALI": "bengali",
|
||||
"BISLAMA": "bislama",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "Bosnisk",
|
||||
"BRETON": "Breton",
|
||||
"BULGARIAN": "Bulgarsk",
|
||||
"BURMESE": "burmesisk",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "chamorro",
|
||||
"CHECHEN": "Chechen",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "Kinesisk",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "tsjuvansk",
|
||||
"CORNISH": "Cornish",
|
||||
"CORSICAN": "Korsikansk",
|
||||
"CREE": "Cree",
|
||||
"CROATIAN": "Kroatsisk",
|
||||
"CZECH": "Tjekkisk",
|
||||
"DANISH": "Dansk",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "dzongkha",
|
||||
"ENGLISH": "Engelsk",
|
||||
"ESPERANTO": "Esperanto",
|
||||
"ESTONIAN": "Estonsk",
|
||||
"EWE": "ewe",
|
||||
"FAROESE": "færøysk",
|
||||
"FIJIAN": "fijiansk",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "Finsk",
|
||||
"FRENCH": "Fransk",
|
||||
"FULAH": "fulani",
|
||||
"GALICIAN": "Galisisk",
|
||||
"GANDA": "ganda",
|
||||
"GEORGIAN": "Georgisk",
|
||||
"GERMAN": "Tysk",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "Guarani",
|
||||
"GUJARATI": "gujarati",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "Hausa",
|
||||
"HEBREW": "Hebraisk",
|
||||
"HERERO": "Herero",
|
||||
"HINDI": "hindi",
|
||||
"HIRI_MOTU": "Hiri Motu",
|
||||
"HUNGARIAN": "Ungarsk",
|
||||
"ICELANDIC": "Islandsk",
|
||||
"IDO": "ido",
|
||||
"IGBO": "ibo",
|
||||
"INDONESIAN": "Indonesisk",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "inuktitut",
|
||||
"INUPIAQ": "unupiak",
|
||||
"IRISH": "Irsk",
|
||||
"ITALIAN": "Italiensk",
|
||||
"JAPANESE": "Japansk",
|
||||
"JAVANESE": "Javanesisk",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "kannada",
|
||||
"KANURI": "Kanuri",
|
||||
"KASHMIRI": "kasjmiri",
|
||||
"KAZAKH": "kasakhisk",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "kinjarwanda",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "komi",
|
||||
"KONGO": "kikongo",
|
||||
"KOREAN": "Koreansk",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "Kurdisk",
|
||||
"LAO": "laotisk",
|
||||
"LATIN": "Latin",
|
||||
"LATVIAN": "Latvisk",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "lingala",
|
||||
"LITHUANIAN": "Lituaisk",
|
||||
"LUBA_KATANGA": "luba-katanga",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "Makedonsk",
|
||||
"MALAGASY": "madagassisk",
|
||||
"MALAY": "malayisk",
|
||||
"MALAYALAM": "malayalam",
|
||||
"MALTESE": "Maltisk",
|
||||
"MANX": "manx",
|
||||
"MAORI": "Maori",
|
||||
"MARATHI": "Marathi",
|
||||
"MARSHALLESE": "Marshallese",
|
||||
"MONGOLIAN": "Mongolsk",
|
||||
"NAURU": "nauru",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "Ndonga",
|
||||
"NEPALI": "nepalsk",
|
||||
"NORTHERN_SAMI": "nordsamisk",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "Norsk",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "ojibwa",
|
||||
"ORIYA": "oriya",
|
||||
"OROMO": "oromo",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "Pali",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "Persisk",
|
||||
"POLISH": "Polsk",
|
||||
"PORTUGUESE": "Portugisisk",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "quechua",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "Romansh",
|
||||
"RUNDI": "rundi",
|
||||
"RUSSIAN": "Russisk",
|
||||
"SAMOAN": "samoansk",
|
||||
"SANGO": "sango",
|
||||
"SANSKRIT": "sanskrit",
|
||||
"SARDINIAN": "Sardinsk",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "Serbisk",
|
||||
"SHONA": "Shona",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "sindhi",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "Slovakisk",
|
||||
"SLOVENIAN": "Slovensk",
|
||||
"SOMALI": "somalisk",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "sundanesisk",
|
||||
"SWAHILI": "swahili",
|
||||
"SWATI": "swati",
|
||||
"SWEDISH": "Svensk",
|
||||
"TAGALOG": "tagalog",
|
||||
"TAHITIAN": "Tahitisk",
|
||||
"TAJIK": "Tajik",
|
||||
"TAMIL": "Tamilsk",
|
||||
"TATAR": "tatarisk",
|
||||
"TELUGU": "telugu",
|
||||
"THAI": "Thai",
|
||||
"TIBETAN": "tibetansk",
|
||||
"TIGRINYA": "Tigrinya",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "tsonga",
|
||||
"TSWANA": "tswana",
|
||||
"TURKISH": "Tyrkisk",
|
||||
"TURKMEN": "turkmensk",
|
||||
"TWI": "twi",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "Ukrainsk",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "urdu",
|
||||
"UZBEK": "usbekisk",
|
||||
"VENDA": "venda",
|
||||
"VIETNAMESE": "Vietnamesisk",
|
||||
"VOLAPUK": "Volapük",
|
||||
"WALLOON": "Vietnamesisk",
|
||||
"WELSH": "Walisisk",
|
||||
"WESTERN_FRISIAN": "Vestfrisisk",
|
||||
"WOLOF": "wolof",
|
||||
"XHOSA": "Xhosa",
|
||||
"YIDDISH": "jiddisk",
|
||||
"YORUBA": "joruba",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "Zulu"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<Ny serie>",
|
||||
"Add": "Legg til",
|
||||
"Add Pattern": "Legg til mønster",
|
||||
"Apply": "Bruk",
|
||||
"Apply failed: {error}": "Kunne ikke bruke endringene: {error}",
|
||||
"Are you sure to delete the following filename pattern?": "Er du sikker på at du vil slette følgende filnavnmønster?",
|
||||
"Are you sure to delete the following shifted season?": "Er du sikker på at du vil slette følgende forskjøvede sesong?",
|
||||
"Are you sure to delete the following show?": "Er du sikker på at du vil slette følgende serie?",
|
||||
"Are you sure to delete the following {track_type} track?": "Er du sikker på at du vil slette følgende {track_type}-spor?",
|
||||
"Are you sure to delete this tag?": "Er du sikker på at du vil slette denne taggen?",
|
||||
"Audio Layout": "Lydoppsett",
|
||||
"Back": "Tilbake",
|
||||
"Cancel": "Avbryt",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "Kan ikke legge til en ny strøm med disposisjonsflagget 'default' eller 'forced' satt",
|
||||
"Changes applied and file reloaded.": "Endringene er brukt og filen er lastet inn på nytt.",
|
||||
"Cleanup": "Rydd opp",
|
||||
"Cleanup disabled.": "Rydding deaktivert.",
|
||||
"Cleanup enabled.": "Rydding aktivert.",
|
||||
"Codec": "Kodek",
|
||||
"Continuing edit session.": "Fortsetter redigeringsøkten.",
|
||||
"Default": "Standard",
|
||||
"Delete": "Slett",
|
||||
"Delete Show": "Slett serie",
|
||||
"Deleted media tag {tag!r}.": "Mediataggen {tag!r} ble slettet.",
|
||||
"Differences": "Forskjeller",
|
||||
"Differences (file->db/output)": "Forskjeller (fil->DB/utdata)",
|
||||
"Discard": "Forkast",
|
||||
"Discard pending metadata changes and quit?": "Forkaste ventende metadataendringer og avslutte?",
|
||||
"Discard pending metadata changes and reload the file state?": "Forkaste ventende metadataendringer og laste filtilstanden på nytt?",
|
||||
"Down": "Ned",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "Tørrkjøring: ville skrevet om via midlertidig fil {target_path}",
|
||||
"Edit": "Rediger",
|
||||
"Edit Pattern": "Rediger mønster",
|
||||
"Edit Show": "Rediger serie",
|
||||
"Edit filename pattern": "Rediger filnavnmønster",
|
||||
"Edit shifted season": "Rediger forskjøvet sesong",
|
||||
"Edit stream": "Rediger strøm",
|
||||
"Episode Offset": "Episodeforskyvning",
|
||||
"Episode offset": "Episodeforskyvning",
|
||||
"File": "Fil",
|
||||
"File patterns": "Filmønstre",
|
||||
"First Episode": "Første episode",
|
||||
"First episode": "Første episode",
|
||||
"Forced": "Tvungen",
|
||||
"Help": "Hjelp",
|
||||
"Help Screen": "Hjelpeskjerm",
|
||||
"ID": "ID",
|
||||
"Identify": "Identifiser",
|
||||
"Index": "Indeks",
|
||||
"Index / Subindex": "Indeks / Underindeks",
|
||||
"Index Episode Digits": "Siffer for episodeindeks",
|
||||
"Index Season Digits": "Siffer for sesongindeks",
|
||||
"Indicator Edisode Digits": "Siffer for episodeindikator",
|
||||
"Indicator Season Digits": "Siffer for sesongindikator",
|
||||
"Keep Editing": "Fortsett redigeringen",
|
||||
"Keeping pending changes.": "Beholder ventende endringer.",
|
||||
"Key": "Nøkkel",
|
||||
"Language": "Språk",
|
||||
"Last Episode": "Siste episode",
|
||||
"Last episode": "Siste episode",
|
||||
"Layout": "Oppsett",
|
||||
"Media Tags": "Mediatagger",
|
||||
"More than one default audio stream detected and no prompt set": "Mer enn én standard lydstrøm funnet og ingen forespørsel satt",
|
||||
"More than one default audio stream detected! Please select stream": "Mer enn én standard lydstrøm funnet. Velg strøm",
|
||||
"More than one default subtitle stream detected and no prompt set": "Mer enn én standard undertekststrøm funnet og ingen forespørsel satt",
|
||||
"More than one default subtitle stream detected! Please select stream": "Mer enn én standard undertekststrøm funnet. Velg strøm",
|
||||
"More than one default video stream detected and no prompt set": "Mer enn én standard videostrøm funnet og ingen forespørsel satt",
|
||||
"More than one default video stream detected! Please select stream": "Mer enn én standard videostrøm funnet. Velg strøm",
|
||||
"More than one forced audio stream detected and no prompt set": "Mer enn én tvungen lydstrøm funnet og ingen forespørsel satt",
|
||||
"More than one forced audio stream detected! Please select stream": "Mer enn én tvungen lydstrøm funnet. Velg strøm",
|
||||
"More than one forced subtitle stream detected and no prompt set": "Mer enn én tvungen undertekststrøm funnet og ingen forespørsel satt",
|
||||
"More than one forced subtitle stream detected! Please select stream": "Mer enn én tvungen undertekststrøm funnet. Velg strøm",
|
||||
"More than one forced video stream detected and no prompt set": "Mer enn én tvungen videostrøm funnet og ingen forespørsel satt",
|
||||
"More than one forced video stream detected! Please select stream": "Mer enn én tvungen videostrøm funnet. Velg strøm",
|
||||
"Name": "Navn",
|
||||
"New Pattern": "Nytt mønster",
|
||||
"New Show": "Ny serie",
|
||||
"New filename pattern": "Nytt filnavnmønster",
|
||||
"New shifted season": "Ny forskjøvet sesong",
|
||||
"New stream": "Ny strøm",
|
||||
"No": "Nei",
|
||||
"No changes to apply.": "Ingen endringer å bruke.",
|
||||
"No changes to revert.": "Ingen endringer å tilbakestille.",
|
||||
"Normalization disabled.": "Normalisering deaktivert.",
|
||||
"Normalization enabled.": "Normalisering aktivert.",
|
||||
"Normalize": "Normaliser",
|
||||
"Notes": "Notater",
|
||||
"Pattern": "Mønster",
|
||||
"Planned Changes (file->edited output)": "Planlagte endringer (fil->redigert utdata)",
|
||||
"Quality": "Kvalitet",
|
||||
"Quit": "Avslutt",
|
||||
"Remove Pattern": "Fjern mønster",
|
||||
"Revert": "Tilbakestill",
|
||||
"Reverted pending changes.": "Ventende endringer ble tilbakestilt.",
|
||||
"Save": "Lagre",
|
||||
"Season Offset": "Sesongforskyvning",
|
||||
"Select a stream first.": "Velg en strøm først.",
|
||||
"Set Default": "Sett som standard",
|
||||
"Set Forced": "Sett som tvungen",
|
||||
"Settings Screen": "Innstillingsskjerm",
|
||||
"Numbering Mapping": "Forskjøvne sesonger",
|
||||
"Show": "Serie",
|
||||
"Shows": "Serier",
|
||||
"Source Season": "Kildesesong",
|
||||
"SrcIndex": "Kildeindeks",
|
||||
"Status": "Status",
|
||||
"Stay": "Bli",
|
||||
"Stream dispositions": "Strømdisposisjoner",
|
||||
"Stream tags": "Strømtagger",
|
||||
"Streams": "Strømmer",
|
||||
"SubIndex": "Underindeks",
|
||||
"Substitute": "Erstatt",
|
||||
"Substitute pattern": "Erstatt mønster",
|
||||
"Title": "Tittel",
|
||||
"Type": "Type",
|
||||
"Unable to update selected stream.": "Kunne ikke oppdatere valgt strøm.",
|
||||
"Up": "Opp",
|
||||
"Update Pattern": "Oppdater mønster",
|
||||
"Updated media tag {tag!r}.": "Mediataggen {tag!r} ble oppdatert.",
|
||||
"Updated stream #{index} ({track_type}).": "Strøm #{index} ({track_type}) oppdatert.",
|
||||
"Value": "Verdi",
|
||||
"Year": "År",
|
||||
"Yes": "Ja",
|
||||
"add media tag: key='{key}' value='{value}'": "legg til mediatagg: nøkkel='{key}' verdi='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "legg til {track_type}-spor: indeks={index} språk={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "vedlegg",
|
||||
"audio": "lyd",
|
||||
"captions": "teksting",
|
||||
"change media tag: key='{key}' value='{value}'": "endre mediatagg: nøkkel='{key}' verdi='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "endre strøm #{index} ({track_type}:{sub_index}) legg til disposisjon={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "endre strøm #{index} ({track_type}:{sub_index}) legg til nøkkel={key} verdi={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "endre strøm #{index} ({track_type}:{sub_index}) endre nøkkel={key} verdi={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "endre strøm #{index} ({track_type}:{sub_index}) fjern disposisjon={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "endre strøm #{index} ({track_type}:{sub_index}) fjern nøkkel={key} verdi={value}",
|
||||
"clean_effects": "bare effekter",
|
||||
"comment": "kommentar",
|
||||
"default": "standard",
|
||||
"dependent": "avhengig",
|
||||
"descriptions": "beskrivelser",
|
||||
"dub": "dubbet",
|
||||
"for pattern": "for mønster",
|
||||
"forced": "tvungen",
|
||||
"from": "fra",
|
||||
"from pattern": "fra mønster",
|
||||
"from show": "fra serie",
|
||||
"hearing_impaired": "hørselshemmet",
|
||||
"karaoke": "karaoke",
|
||||
"lyrics": "sangtekst",
|
||||
"metadata": "metadata",
|
||||
"non_diegetic": "ikke-diegetisk",
|
||||
"original": "original",
|
||||
"pattern #{id}": "mønster #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "fjern mediatagg: nøkkel='{key}' verdi='{value}'",
|
||||
"remove stream #{index}": "fjern strøm #{index}",
|
||||
"show #{id}": "serie #{id}",
|
||||
"stereo": "stereo",
|
||||
"still_image": "stillbilde",
|
||||
"sub index": "underindeks",
|
||||
"subtitle": "undertekst",
|
||||
"timed_thumbnails": "tidsbestemte miniatyrer",
|
||||
"undefined": "udefinert",
|
||||
"unknown": "ukjent",
|
||||
"video": "video",
|
||||
"visual_impaired": "synshemmet"
|
||||
}
|
||||
}
|
||||
361
assets/i18n/pt.json
Normal file
361
assets/i18n/pt.json
Normal file
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "abkhazian",
|
||||
"AFAR": "afar",
|
||||
"AFRIKAANS": "Africanos",
|
||||
"AKAN": "Akan",
|
||||
"ALBANIAN": "Albanês",
|
||||
"AMHARIC": "Amárico",
|
||||
"ARABIC": "Árabe",
|
||||
"ARAGONESE": "Aragonês",
|
||||
"ARMENIAN": "arménio",
|
||||
"ASSAMESE": "assamês",
|
||||
"AVARIC": "Avárico",
|
||||
"AVESTAN": "avéstico",
|
||||
"AYMARA": "aimara",
|
||||
"AZERBAIJANI": "Azerbaijani",
|
||||
"BAMBARA": "bambara",
|
||||
"BASHKIR": "bashkir",
|
||||
"BASQUE": "Basco",
|
||||
"BELARUSIAN": "Bielorusso",
|
||||
"BENGALI": "Bengali",
|
||||
"BISLAMA": "bislamá",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "Bósnio",
|
||||
"BRETON": "Bretão",
|
||||
"BULGARIAN": "Búlgaro",
|
||||
"BURMESE": "birmanês",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "chamorro",
|
||||
"CHECHEN": "Checheno",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "Chinês",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "chuvash",
|
||||
"CORNISH": "Córnico",
|
||||
"CORSICAN": "córsico",
|
||||
"CREE": "Cree",
|
||||
"CROATIAN": "Croata",
|
||||
"CZECH": "Checo",
|
||||
"DANISH": "Dinamarquês",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "dzonga",
|
||||
"ENGLISH": "Inglês",
|
||||
"ESPERANTO": "Esperanto",
|
||||
"ESTONIAN": "Estoniano",
|
||||
"EWE": "eve",
|
||||
"FAROESE": "Faroês",
|
||||
"FIJIAN": "fijiano",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "Finlandês",
|
||||
"FRENCH": "Francês",
|
||||
"FULAH": "fula",
|
||||
"GALICIAN": "Galego",
|
||||
"GANDA": "luganda",
|
||||
"GEORGIAN": "georgiano",
|
||||
"GERMAN": "Alemão",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "Guarani",
|
||||
"GUJARATI": "Guzerate",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "Hauçá",
|
||||
"HEBREW": "Hebreu",
|
||||
"HERERO": "Hereró",
|
||||
"HINDI": "Hindi",
|
||||
"HIRI_MOTU": "Hiri Motu",
|
||||
"HUNGARIAN": "Húngaro",
|
||||
"ICELANDIC": "Islandês",
|
||||
"IDO": "ido",
|
||||
"IGBO": "ibo",
|
||||
"INDONESIAN": "Indonésio",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "inuktitut",
|
||||
"INUPIAQ": "Inupiaque",
|
||||
"IRISH": "Irlandês",
|
||||
"ITALIAN": "Italiano",
|
||||
"JAPANESE": "Japonês",
|
||||
"JAVANESE": "Javanês",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "Kannada",
|
||||
"KANURI": "Canúri",
|
||||
"KASHMIRI": "kashmiri",
|
||||
"KAZAKH": "cazaque",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "kinyarwanda",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "komi",
|
||||
"KONGO": "congolês",
|
||||
"KOREAN": "Coreano",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "Curdo",
|
||||
"LAO": "Laosiano",
|
||||
"LATIN": "Latim",
|
||||
"LATVIAN": "Letão",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "Lingala",
|
||||
"LITHUANIAN": "Lituano",
|
||||
"LUBA_KATANGA": "luba-catanga",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "Macedônio",
|
||||
"MALAGASY": "malgaxe",
|
||||
"MALAY": "Malaio",
|
||||
"MALAYALAM": "malaiala",
|
||||
"MALTESE": "Maltês",
|
||||
"MANX": "Manx",
|
||||
"MAORI": "Maori",
|
||||
"MARATHI": "marata",
|
||||
"MARSHALLESE": "Marshalês",
|
||||
"MONGOLIAN": "Mongol",
|
||||
"NAURU": "nauruano",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "dongo",
|
||||
"NEPALI": "Nepalês",
|
||||
"NORTHERN_SAMI": "northern sami",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "Norueguês",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "ojibwa",
|
||||
"ORIYA": "oriya",
|
||||
"OROMO": "Oromo",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "Páli",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "Persa",
|
||||
"POLISH": "Polaco",
|
||||
"PORTUGUESE": "Português",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "quíchua",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "Romanche",
|
||||
"RUNDI": "rundi",
|
||||
"RUSSIAN": "Russo",
|
||||
"SAMOAN": "Samoano",
|
||||
"SANGO": "sango",
|
||||
"SANSKRIT": "Sânscrito",
|
||||
"SARDINIAN": "Sardo",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "Sérvio",
|
||||
"SHONA": "Xona",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "sindi",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "Eslovaco",
|
||||
"SLOVENIAN": "Eslovêno",
|
||||
"SOMALI": "somali",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "sundanês",
|
||||
"SWAHILI": "suaíli",
|
||||
"SWATI": "swati",
|
||||
"SWEDISH": "Sueco",
|
||||
"TAGALOG": "Tagalo",
|
||||
"TAHITIAN": "Taitiano",
|
||||
"TAJIK": "Tadjique",
|
||||
"TAMIL": "Tâmil",
|
||||
"TATAR": "tatar",
|
||||
"TELUGU": "Telugu",
|
||||
"THAI": "Tailandês",
|
||||
"TIBETAN": "tibetano",
|
||||
"TIGRINYA": "Tigrínia",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "tsonga",
|
||||
"TSWANA": "tswana",
|
||||
"TURKISH": "Turco",
|
||||
"TURKMEN": "turcomano",
|
||||
"TWI": "twi",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "Ucraniano",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "urdu",
|
||||
"UZBEK": "usbeque",
|
||||
"VENDA": "venda",
|
||||
"VIETNAMESE": "Vietnamita",
|
||||
"VOLAPUK": "Volapuque",
|
||||
"WALLOON": "walloon",
|
||||
"WELSH": "galês",
|
||||
"WESTERN_FRISIAN": "Frísio ocidental",
|
||||
"WOLOF": "uolofe",
|
||||
"XHOSA": "xosa",
|
||||
"YIDDISH": "iídiche",
|
||||
"YORUBA": "ioruba",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "zulu"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<Nova série>",
|
||||
"Add": "Adicionar",
|
||||
"Add Pattern": "Adicionar padrão",
|
||||
"Apply": "Aplicar",
|
||||
"Apply failed: {error}": "Falha ao aplicar: {error}",
|
||||
"Are you sure to delete the following filename pattern?": "Tem certeza de que deseja excluir o seguinte padrão de nome de arquivo?",
|
||||
"Are you sure to delete the following shifted season?": "Tem certeza de que deseja excluir a seguinte temporada deslocada?",
|
||||
"Are you sure to delete the following show?": "Tem certeza de que deseja excluir a seguinte série?",
|
||||
"Are you sure to delete the following {track_type} track?": "Tem certeza de que deseja excluir a seguinte faixa {track_type}?",
|
||||
"Are you sure to delete this tag?": "Tem certeza de que deseja excluir esta tag?",
|
||||
"Audio Layout": "Layout de áudio",
|
||||
"Back": "Voltar",
|
||||
"Cancel": "Cancelar",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "Não é possível adicionar outro fluxo com a flag de disposição 'default' ou 'forced' definida",
|
||||
"Changes applied and file reloaded.": "Alterações aplicadas e arquivo recarregado.",
|
||||
"Cleanup": "Limpeza",
|
||||
"Cleanup disabled.": "Limpeza desativada.",
|
||||
"Cleanup enabled.": "Limpeza ativada.",
|
||||
"Codec": "Codec",
|
||||
"Continuing edit session.": "Continuando a sessão de edição.",
|
||||
"Default": "Padrão",
|
||||
"Delete": "Excluir",
|
||||
"Delete Show": "Excluir série",
|
||||
"Deleted media tag {tag!r}.": "Tag de mídia {tag!r} excluída.",
|
||||
"Differences": "Diferenças",
|
||||
"Differences (file->db/output)": "Diferenças (arquivo->BD/saída)",
|
||||
"Discard": "Descartar",
|
||||
"Discard pending metadata changes and quit?": "Descartar alterações pendentes de metadados e sair?",
|
||||
"Discard pending metadata changes and reload the file state?": "Descartar alterações pendentes de metadados e recarregar o estado do arquivo?",
|
||||
"Down": "Baixo",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "Execução simulada: regravaria via arquivo temporário {target_path}",
|
||||
"Edit": "Editar",
|
||||
"Edit Pattern": "Editar padrão",
|
||||
"Edit Show": "Editar série",
|
||||
"Edit filename pattern": "Editar padrão de nome de arquivo",
|
||||
"Edit shifted season": "Editar temporada deslocada",
|
||||
"Edit stream": "Editar fluxo",
|
||||
"Episode Offset": "Deslocamento de episódio",
|
||||
"Episode offset": "Deslocamento de episódio",
|
||||
"File": "Arquivo",
|
||||
"File patterns": "Padrões de arquivo",
|
||||
"First Episode": "Primeiro episódio",
|
||||
"First episode": "Primeiro episódio",
|
||||
"Forced": "Forçado",
|
||||
"Help": "Ajuda",
|
||||
"Help Screen": "Tela de ajuda",
|
||||
"ID": "ID",
|
||||
"Identify": "Identificar",
|
||||
"Index": "Índice",
|
||||
"Index / Subindex": "Índice / Subíndice",
|
||||
"Index Episode Digits": "Dígitos do índice do episódio",
|
||||
"Index Season Digits": "Dígitos do índice da temporada",
|
||||
"Indicator Edisode Digits": "Dígitos do indicador do episódio",
|
||||
"Indicator Season Digits": "Dígitos do indicador da temporada",
|
||||
"Keep Editing": "Continuar editando",
|
||||
"Keeping pending changes.": "Mantendo alterações pendentes.",
|
||||
"Key": "Chave",
|
||||
"Language": "Idioma",
|
||||
"Last Episode": "Último episódio",
|
||||
"Last episode": "Último episódio",
|
||||
"Layout": "Layout",
|
||||
"Media Tags": "Tags de mídia",
|
||||
"More than one default audio stream detected and no prompt set": "Mais de um fluxo de áudio padrão detectado e nenhum prompt definido",
|
||||
"More than one default audio stream detected! Please select stream": "Mais de um fluxo de áudio padrão detectado! Selecione o fluxo",
|
||||
"More than one default subtitle stream detected and no prompt set": "Mais de um fluxo de legenda padrão detectado e nenhum prompt definido",
|
||||
"More than one default subtitle stream detected! Please select stream": "Mais de um fluxo de legenda padrão detectado! Selecione o fluxo",
|
||||
"More than one default video stream detected and no prompt set": "Mais de um fluxo de vídeo padrão detectado e nenhum prompt definido",
|
||||
"More than one default video stream detected! Please select stream": "Mais de um fluxo de vídeo padrão detectado! Selecione o fluxo",
|
||||
"More than one forced audio stream detected and no prompt set": "Mais de um fluxo de áudio forçado detectado e nenhum prompt definido",
|
||||
"More than one forced audio stream detected! Please select stream": "Mais de um fluxo de áudio forçado detectado! Selecione o fluxo",
|
||||
"More than one forced subtitle stream detected and no prompt set": "Mais de um fluxo de legenda forçada detectado e nenhum prompt definido",
|
||||
"More than one forced subtitle stream detected! Please select stream": "Mais de um fluxo de legenda forçada detectado! Selecione o fluxo",
|
||||
"More than one forced video stream detected and no prompt set": "Mais de um fluxo de vídeo forçado detectado e nenhum prompt definido",
|
||||
"More than one forced video stream detected! Please select stream": "Mais de um fluxo de vídeo forçado detectado! Selecione o fluxo",
|
||||
"Name": "Nome",
|
||||
"New Pattern": "Novo padrão",
|
||||
"New Show": "Nova série",
|
||||
"New filename pattern": "Novo padrão de nome de arquivo",
|
||||
"New shifted season": "Nova temporada deslocada",
|
||||
"New stream": "Novo fluxo",
|
||||
"No": "Não",
|
||||
"No changes to apply.": "Nenhuma alteração para aplicar.",
|
||||
"No changes to revert.": "Nenhuma alteração para reverter.",
|
||||
"Normalization disabled.": "Normalização desativada.",
|
||||
"Normalization enabled.": "Normalização ativada.",
|
||||
"Normalize": "Normalizar",
|
||||
"Notes": "Notas",
|
||||
"Pattern": "Padrão",
|
||||
"Planned Changes (file->edited output)": "Alterações planejadas (arquivo->saída editada)",
|
||||
"Quality": "Qualidade",
|
||||
"Quit": "Sair",
|
||||
"Remove Pattern": "Remover padrão",
|
||||
"Revert": "Reverter",
|
||||
"Reverted pending changes.": "Alterações pendentes revertidas.",
|
||||
"Save": "Salvar",
|
||||
"Season Offset": "Deslocamento de temporada",
|
||||
"Select a stream first.": "Selecione um fluxo primeiro.",
|
||||
"Set Default": "Definir como padrão",
|
||||
"Set Forced": "Definir como forçado",
|
||||
"Settings Screen": "Tela de configurações",
|
||||
"Numbering Mapping": "Temporadas deslocadas",
|
||||
"Show": "Série",
|
||||
"Shows": "Séries",
|
||||
"Source Season": "Temporada de origem",
|
||||
"SrcIndex": "Índice de origem",
|
||||
"Status": "Status",
|
||||
"Stay": "Permanecer",
|
||||
"Stream dispositions": "Disposições do fluxo",
|
||||
"Stream tags": "Tags do fluxo",
|
||||
"Streams": "Fluxos",
|
||||
"SubIndex": "Subíndice",
|
||||
"Substitute": "Substituir",
|
||||
"Substitute pattern": "Substituir padrão",
|
||||
"Title": "Título",
|
||||
"Type": "Tipo",
|
||||
"Unable to update selected stream.": "Não foi possível atualizar o fluxo selecionado.",
|
||||
"Up": "Cima",
|
||||
"Update Pattern": "Atualizar padrão",
|
||||
"Updated media tag {tag!r}.": "Tag de mídia {tag!r} atualizada.",
|
||||
"Updated stream #{index} ({track_type}).": "Fluxo #{index} ({track_type}) atualizado.",
|
||||
"Value": "Valor",
|
||||
"Year": "Ano",
|
||||
"Yes": "Sim",
|
||||
"add media tag: key='{key}' value='{value}'": "adicionar tag de mídia: chave='{key}' valor='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "adicionar faixa {track_type}: índice={index} idioma={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "anexo",
|
||||
"audio": "áudio",
|
||||
"captions": "legendas",
|
||||
"change media tag: key='{key}' value='{value}'": "alterar tag de mídia: chave='{key}' valor='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "alterar fluxo #{index} ({track_type}:{sub_index}) adicionar disposição={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "alterar fluxo #{index} ({track_type}:{sub_index}) adicionar chave={key} valor={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "alterar fluxo #{index} ({track_type}:{sub_index}) alterar chave={key} valor={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "alterar fluxo #{index} ({track_type}:{sub_index}) remover disposição={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "alterar fluxo #{index} ({track_type}:{sub_index}) remover chave={key} valor={value}",
|
||||
"clean_effects": "apenas efeitos",
|
||||
"comment": "comentário",
|
||||
"default": "padrão",
|
||||
"dependent": "dependente",
|
||||
"descriptions": "descrições",
|
||||
"dub": "dublado",
|
||||
"for pattern": "para o padrão",
|
||||
"forced": "forçado",
|
||||
"from": "de",
|
||||
"from pattern": "do padrão",
|
||||
"from show": "da série",
|
||||
"hearing_impaired": "deficiência auditiva",
|
||||
"karaoke": "karaokê",
|
||||
"lyrics": "letra",
|
||||
"metadata": "metadados",
|
||||
"non_diegetic": "não diegético",
|
||||
"original": "original",
|
||||
"pattern #{id}": "padrão #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "remover tag de mídia: chave='{key}' valor='{value}'",
|
||||
"remove stream #{index}": "remover fluxo #{index}",
|
||||
"show #{id}": "série #{id}",
|
||||
"stereo": "estéreo",
|
||||
"still_image": "imagem estática",
|
||||
"sub index": "subíndice",
|
||||
"subtitle": "legenda",
|
||||
"timed_thumbnails": "miniaturas temporizadas",
|
||||
"undefined": "indefinido",
|
||||
"unknown": "desconhecido",
|
||||
"video": "vídeo",
|
||||
"visual_impaired": "deficiência visual"
|
||||
}
|
||||
}
|
||||
361
assets/i18n/ta.json
Normal file
361
assets/i18n/ta.json
Normal file
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"iso_languages": {
|
||||
"ABKHAZIAN": "அப்காசியன்",
|
||||
"AFAR": "அஃபர்",
|
||||
"AFRIKAANS": "ஆப்ரிக்கான்ச்",
|
||||
"AKAN": "அகான்",
|
||||
"ALBANIAN": "அல்பேனியன்",
|
||||
"AMHARIC": "அம்ஆரிக்",
|
||||
"ARABIC": "அராபிக்",
|
||||
"ARAGONESE": "அரகோன்ச்",
|
||||
"ARMENIAN": "அர்மேனியன்",
|
||||
"ASSAMESE": "அச்சாமி",
|
||||
"AVARIC": "அவாரிக்",
|
||||
"AVESTAN": "அவேச்டன்",
|
||||
"AYMARA": "அய்மாரா",
|
||||
"AZERBAIJANI": "அசெர்பெய்சானி",
|
||||
"BAMBARA": "பம்பரா",
|
||||
"BASHKIR": "பாச்கிர்",
|
||||
"BASQUE": "பாச்க்",
|
||||
"BELARUSIAN": "பெலாருசியன்",
|
||||
"BENGALI": "பெங்காலி",
|
||||
"BISLAMA": "பிச்லாமா",
|
||||
"BOKMAL": "Bokmål",
|
||||
"BOSNIAN": "போச்னியன்",
|
||||
"BRETON": "ப்ரெடன்",
|
||||
"BULGARIAN": "பல்கேரியன்",
|
||||
"BURMESE": "பர்மீசி",
|
||||
"CATALAN": "Catalan",
|
||||
"CHAMORRO": "சாமோர்ரோ",
|
||||
"CHECHEN": "செக்சன்",
|
||||
"CHICHEWA": "Chichewa",
|
||||
"CHINESE": "சைனீச்",
|
||||
"CHURCH_SLAVIC": "Church Slavic",
|
||||
"CHUVASH": "சுவாச்",
|
||||
"CORNISH": "கோர்னிச்",
|
||||
"CORSICAN": "கோர்சிகேன்",
|
||||
"CREE": "சிரீ",
|
||||
"CROATIAN": "குரேசியன்",
|
||||
"CZECH": "செக்",
|
||||
"DANISH": "டானிச்",
|
||||
"DIVEHI": "Divehi",
|
||||
"DUTCH": "Dutch",
|
||||
"DZONGKHA": "ட்சொங்க்கா",
|
||||
"ENGLISH": "ஆங்கிலம்",
|
||||
"ESPERANTO": "எச்பெரான்டொ",
|
||||
"ESTONIAN": "எச்டோனியன்",
|
||||
"EWE": "இவ்",
|
||||
"FAROESE": "ஃபரோச்",
|
||||
"FIJIAN": "ஃபிசியன்",
|
||||
"FILIPINO": "Filipino",
|
||||
"FINNISH": "பின்னிச்",
|
||||
"FRENCH": "பிரெஞ்சு",
|
||||
"FULAH": "ஃபுல்லா",
|
||||
"GALICIAN": "காலிசியன்",
|
||||
"GANDA": "கான்டா",
|
||||
"GEORGIAN": "சியார்சியன்",
|
||||
"GERMAN": "செர்மன்",
|
||||
"GREEK": "Greek",
|
||||
"GUARANI": "குர்ரானி",
|
||||
"GUJARATI": "குசராத்தி",
|
||||
"HAITIAN": "Haitian",
|
||||
"HAUSA": "ஔசா",
|
||||
"HEBREW": "ஈப்ரு",
|
||||
"HERERO": "இரீரோ",
|
||||
"HINDI": "இந்தி",
|
||||
"HIRI_MOTU": "இரி மோட்டு",
|
||||
"HUNGARIAN": "அங்கேரியன்",
|
||||
"ICELANDIC": "ஐச்லாண்டிக்",
|
||||
"IDO": "ஐடூ",
|
||||
"IGBO": "இக்போ",
|
||||
"INDONESIAN": "இந்தோனேசியன்",
|
||||
"INTERLINGUA": "Interlingua",
|
||||
"INTERLINGUE": "Interlingue",
|
||||
"INUKTITUT": "இனுடிடட்",
|
||||
"INUPIAQ": "இனுபைக்யூ",
|
||||
"IRISH": "ஐரிச்",
|
||||
"ITALIAN": "இத்தாலியன்",
|
||||
"JAPANESE": "சப்பானிய",
|
||||
"JAVANESE": "சவானிச்",
|
||||
"KALAALLISUT": "Kalaallisut",
|
||||
"KANNADA": "கன்னடம்",
|
||||
"KANURI": "கனுரி",
|
||||
"KASHMIRI": "காச்மீரி",
|
||||
"KAZAKH": "கசாக்ச்",
|
||||
"KHMER": "Khmer",
|
||||
"KIKUYU": "Kikuyu",
|
||||
"KINYARWANDA": "கின்யார்வான்டா",
|
||||
"KIRGHIZ": "Kirghiz",
|
||||
"KOMI": "கோமி",
|
||||
"KONGO": "காங்கோ",
|
||||
"KOREAN": "கொரியன்",
|
||||
"KUANYAMA": "Kuanyama",
|
||||
"KURDISH": "குர்திச்",
|
||||
"LAO": "லாவோ",
|
||||
"LATIN": "லத்தீன்",
|
||||
"LATVIAN": "லாட்வியன்",
|
||||
"LIMBURGAN": "Limburgan",
|
||||
"LINGALA": "லின்காலா",
|
||||
"LITHUANIAN": "லிதுவேனியன்",
|
||||
"LUBA_KATANGA": "லூபா-கடான்கா",
|
||||
"LUXEMBOURGISH": "Luxembourgish",
|
||||
"MACEDONIAN": "மேசடோனியன்",
|
||||
"MALAGASY": "மலகாசி",
|
||||
"MALAY": "மலாய்",
|
||||
"MALAYALAM": "மலையாளம்",
|
||||
"MALTESE": "மல்டீச்",
|
||||
"MANX": "மான்ச்",
|
||||
"MAORI": "மௌரி",
|
||||
"MARATHI": "மராத்தி",
|
||||
"MARSHALLESE": "மார்சலீசீ",
|
||||
"MONGOLIAN": "மங்கோலியன்",
|
||||
"NAURU": "நவூரு",
|
||||
"NAVAJO": "Navajo",
|
||||
"NDONGA": "நடோன்கா",
|
||||
"NEPALI": "நேபாலி",
|
||||
"NORTHERN_SAMI": "கிழக்கு சாமி",
|
||||
"NORTH_NDEBELE": "North Ndebele",
|
||||
"NORWEGIAN": "நார்வேசியன்",
|
||||
"NORWEGIAN_NYNORSK": "Nynorsk",
|
||||
"OCCITAN": "Occitan",
|
||||
"OJIBWA": "ஒசிப்வா",
|
||||
"ORIYA": "ஒரியா",
|
||||
"OROMO": "ஒரோமோ",
|
||||
"OSSETIAN": "Ossetian",
|
||||
"PALI": "பாலி",
|
||||
"PANJABI": "Panjabi",
|
||||
"PERSIAN": "பெர்சியன்",
|
||||
"POLISH": "போலிச்",
|
||||
"PORTUGUESE": "போர்த்துக்கீசிய",
|
||||
"PUSHTO": "Pushto",
|
||||
"QUECHUA": "க்யுசோ",
|
||||
"ROMANIAN": "Romanian",
|
||||
"ROMANSH": "ரோமான்ச்ச்",
|
||||
"RUNDI": "ருண்டி",
|
||||
"RUSSIAN": "ரச்யன்",
|
||||
"SAMOAN": "சாமோயன்",
|
||||
"SANGO": "சான்ங்கோ",
|
||||
"SANSKRIT": "சான்ச்கிரிட்",
|
||||
"SARDINIAN": "சார்டினியன்",
|
||||
"SCOTTISH_GAELIC": "Scottish Gaelic",
|
||||
"SERBIAN": "செர்பியன்",
|
||||
"SHONA": "சோனா",
|
||||
"SICHUAN_YI": "Sichuan Yi",
|
||||
"SINDHI": "சிந்தி",
|
||||
"SINHALA": "Sinhala",
|
||||
"SLOVAK": "சுலோவாக்",
|
||||
"SLOVENIAN": "ச்லோவெனியன்",
|
||||
"SOMALI": "சோமாலி",
|
||||
"SOUTHERN_SOTHO": "Southern Sotho",
|
||||
"SOUTH_NDEBELE": "South Ndebele",
|
||||
"SPANISH": "Spanish",
|
||||
"SUNDANESE": "சூடானீச்",
|
||||
"SWAHILI": "ச்வாஇலி",
|
||||
"SWATI": "ச்வாதி",
|
||||
"SWEDISH": "சுவீடிச்",
|
||||
"TAGALOG": "டங்லாக்",
|
||||
"TAHITIAN": "தஇதியன்",
|
||||
"TAJIK": "தாசிக்",
|
||||
"TAMIL": "தமிழ்",
|
||||
"TATAR": "டாட்டர்",
|
||||
"TELUGU": "தெலுங்கு",
|
||||
"THAI": "தாய்",
|
||||
"TIBETAN": "திபெத்திய",
|
||||
"TIGRINYA": "தைக்ரின்யா",
|
||||
"TONGA": "Tonga",
|
||||
"TSONGA": "ட்சாங்கோ",
|
||||
"TSWANA": "ட்ச்வனா",
|
||||
"TURKISH": "துருக்கி",
|
||||
"TURKMEN": "டர்க்மென்",
|
||||
"TWI": "டிவி",
|
||||
"UIGHUR": "Uighur",
|
||||
"UKRAINIAN": "உக்ரெனியன்",
|
||||
"UNDEFINED": "undefined",
|
||||
"URDU": "உருது",
|
||||
"UZBEK": "உச்பெக்",
|
||||
"VENDA": "வேண்டா",
|
||||
"VIETNAMESE": "வியட்னாம்",
|
||||
"VOLAPUK": "வோலாபுக்",
|
||||
"WALLOON": "வாலூன்",
|
||||
"WELSH": "வெல்ச்",
|
||||
"WESTERN_FRISIAN": "மேற்கு ஃபிரிசியன்",
|
||||
"WOLOF": "ஓலோஃப்",
|
||||
"XHOSA": "சோசா",
|
||||
"YIDDISH": "இட்டிச்",
|
||||
"YORUBA": "யோருபா",
|
||||
"ZHUANG": "Zhuang",
|
||||
"ZULU": "சுலு"
|
||||
},
|
||||
"phrases": {
|
||||
"5.0(side)": "5.0(side)",
|
||||
"5.1(side)": "5.1(side)",
|
||||
"6.1": "6.1",
|
||||
"6ch": "6ch",
|
||||
"7.1": "7.1",
|
||||
"<New show>": "<புதிய தொடர்>",
|
||||
"Add": "சேர்",
|
||||
"Add Pattern": "வடிவத்தை சேர்",
|
||||
"Apply": "பயன்படுத்து",
|
||||
"Apply failed: {error}": "பயன்படுத்தல் தோல்வியடைந்தது: {error}",
|
||||
"Are you sure to delete the following filename pattern?": "பின்வரும் கோப்பு பெயர் வடிவத்தை நீக்க விரும்புகிறீர்களா?",
|
||||
"Are you sure to delete the following shifted season?": "பின்வரும் மாற்றிய சீசனை நீக்க விரும்புகிறீர்களா?",
|
||||
"Are you sure to delete the following show?": "பின்வரும் தொடரை நீக்க விரும்புகிறீர்களா?",
|
||||
"Are you sure to delete the following {track_type} track?": "பின்வரும் {track_type} ஸ்ட்ரீமை நீக்க விரும்புகிறீர்களா?",
|
||||
"Are you sure to delete this tag?": "இந்த குறிச்சொல்லை நீக்க விரும்புகிறீர்களா?",
|
||||
"Audio Layout": "ஒலி அமைப்பு",
|
||||
"Back": "பின்",
|
||||
"Cancel": "ரத்து",
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set": "'default' அல்லது 'forced' disposition கொடி அமைந்த மற்றொரு ஸ்ட்ரீமை சேர்க்க முடியாது",
|
||||
"Changes applied and file reloaded.": "மாற்றங்கள் பயன்படுத்தப்பட்டு கோப்பு மீளேற்றப்பட்டது.",
|
||||
"Cleanup": "சுத்திகரிப்பு",
|
||||
"Cleanup disabled.": "சுத்திகரிப்பு முடக்கப்பட்டது.",
|
||||
"Cleanup enabled.": "சுத்திகரிப்பு இயக்கப்பட்டது.",
|
||||
"Codec": "கோடெக்",
|
||||
"Continuing edit session.": "திருத்த அமர்வு தொடர்கிறது.",
|
||||
"Default": "இயல்புநிலை",
|
||||
"Delete": "நீக்கு",
|
||||
"Delete Show": "தொடரை நீக்கு",
|
||||
"Deleted media tag {tag!r}.": "மீடியா குறிச்சொல் {tag!r} நீக்கப்பட்டது.",
|
||||
"Differences": "வேறுபாடுகள்",
|
||||
"Differences (file->db/output)": "வேறுபாடுகள் (கோப்பு->DB/வெளியீடு)",
|
||||
"Discard": "கைவிடு",
|
||||
"Discard pending metadata changes and quit?": "நிலுவையில் உள்ள மெட்டாடேட்டா மாற்றங்களை கைவிட்டு வெளியேறவா?",
|
||||
"Discard pending metadata changes and reload the file state?": "நிலுவையில் உள்ள மெட்டாடேட்டா மாற்றங்களை கைவிட்டு கோப்பு நிலையை மீளேற்றவா?",
|
||||
"Down": "கீழ்",
|
||||
"Dry-run: would rewrite via temporary file {target_path}": "Dry-run: தற்காலிக கோப்பு {target_path} வழியாக மறுஎழுதப்படும்",
|
||||
"Edit": "திருத்து",
|
||||
"Edit Pattern": "வடிவத்தை திருத்து",
|
||||
"Edit Show": "தொடரை திருத்து",
|
||||
"Edit filename pattern": "கோப்பு பெயர் வடிவத்தை திருத்து",
|
||||
"Edit shifted season": "மாற்றிய சீசனை திருத்து",
|
||||
"Edit stream": "ஸ்ட்ரீமை திருத்து",
|
||||
"Episode Offset": "அத்தியாய இடச்சரிவு",
|
||||
"Episode offset": "அத்தியாய இடச்சரிவு",
|
||||
"File": "கோப்பு",
|
||||
"File patterns": "கோப்பு வடிவங்கள்",
|
||||
"First Episode": "முதல் அத்தியாயம்",
|
||||
"First episode": "முதல் அத்தியாயம்",
|
||||
"Forced": "கட்டாயம்",
|
||||
"Help": "உதவி",
|
||||
"Help Screen": "உதவி திரை",
|
||||
"ID": "அடையாளம்",
|
||||
"Identify": "அடையாளம் காட்டு",
|
||||
"Index": "சுட்டி",
|
||||
"Index / Subindex": "சுட்டி / துணைச்சுட்டி",
|
||||
"Index Episode Digits": "அத்தியாய சுட்டி இலக்கங்கள்",
|
||||
"Index Season Digits": "சீசன் சுட்டி இலக்கங்கள்",
|
||||
"Indicator Edisode Digits": "அத்தியாய குறியீட்டு இலக்கங்கள்",
|
||||
"Indicator Season Digits": "சீசன் குறியீட்டு இலக்கங்கள்",
|
||||
"Keep Editing": "திருத்தலை தொடரு",
|
||||
"Keeping pending changes.": "நிலுவையில் உள்ள மாற்றங்கள் வைக்கப்படுகின்றன.",
|
||||
"Key": "சாவி",
|
||||
"Language": "மொழி",
|
||||
"Last Episode": "கடைசி அத்தியாயம்",
|
||||
"Last episode": "கடைசி அத்தியாயம்",
|
||||
"Layout": "அமைப்பு",
|
||||
"Media Tags": "மீடியா குறிச்சொற்கள்",
|
||||
"More than one default audio stream detected and no prompt set": "ஒருக்கும் மேற்பட்ட இயல்புநிலை ஒலி ஸ்ட்ரீம்கள் கண்டறியப்பட்டன, மேலும் எந்த prompt-வும் அமைக்கப்படவில்லை",
|
||||
"More than one default audio stream detected! Please select stream": "ஒருக்கும் மேற்பட்ட இயல்புநிலை ஒலி ஸ்ட்ரீம்கள் கண்டறியப்பட்டன! ஸ்ட்ரீமைத் தேர்ந்தெடுக்கவும்",
|
||||
"More than one default subtitle stream detected and no prompt set": "ஒருக்கும் மேற்பட்ட இயல்புநிலை வசன ஸ்ட்ரீம்கள் கண்டறியப்பட்டன, மேலும் எந்த prompt-வும் அமைக்கப்படவில்லை",
|
||||
"More than one default subtitle stream detected! Please select stream": "ஒருக்கும் மேற்பட்ட இயல்புநிலை வசன ஸ்ட்ரீம்கள் கண்டறியப்பட்டன! ஸ்ட்ரீமைத் தேர்ந்தெடுக்கவும்",
|
||||
"More than one default video stream detected and no prompt set": "ஒருக்கும் மேற்பட்ட இயல்புநிலை வீடியோ ஸ்ட்ரீம்கள் கண்டறியப்பட்டன, மேலும் எந்த prompt-வும் அமைக்கப்படவில்லை",
|
||||
"More than one default video stream detected! Please select stream": "ஒருக்கும் மேற்பட்ட இயல்புநிலை வீடியோ ஸ்ட்ரீம்கள் கண்டறியப்பட்டன! ஸ்ட்ரீமைத் தேர்ந்தெடுக்கவும்",
|
||||
"More than one forced audio stream detected and no prompt set": "ஒருக்கும் மேற்பட்ட கட்டாய ஒலி ஸ்ட்ரீம்கள் கண்டறியப்பட்டன, மேலும் எந்த prompt-வும் அமைக்கப்படவில்லை",
|
||||
"More than one forced audio stream detected! Please select stream": "ஒருக்கும் மேற்பட்ட கட்டாய ஒலி ஸ்ட்ரீம்கள் கண்டறியப்பட்டன! ஸ்ட்ரீமைத் தேர்ந்தெடுக்கவும்",
|
||||
"More than one forced subtitle stream detected and no prompt set": "ஒருக்கும் மேற்பட்ட கட்டாய வசன ஸ்ட்ரீம்கள் கண்டறியப்பட்டன, மேலும் எந்த prompt-வும் அமைக்கப்படவில்லை",
|
||||
"More than one forced subtitle stream detected! Please select stream": "ஒருக்கும் மேற்பட்ட கட்டாய வசன ஸ்ட்ரீம்கள் கண்டறியப்பட்டன! ஸ்ட்ரீமைத் தேர்ந்தெடுக்கவும்",
|
||||
"More than one forced video stream detected and no prompt set": "ஒருக்கும் மேற்பட்ட கட்டாய வீடியோ ஸ்ட்ரீம்கள் கண்டறியப்பட்டன, மேலும் எந்த prompt-வும் அமைக்கப்படவில்லை",
|
||||
"More than one forced video stream detected! Please select stream": "ஒருக்கும் மேற்பட்ட கட்டாய வீடியோ ஸ்ட்ரீம்கள் கண்டறியப்பட்டன! ஸ்ட்ரீமைத் தேர்ந்தெடுக்கவும்",
|
||||
"Name": "பெயர்",
|
||||
"New Pattern": "புதிய வடிவம்",
|
||||
"New Show": "புதிய தொடர்",
|
||||
"New filename pattern": "புதிய கோப்பு பெயர் வடிவம்",
|
||||
"New shifted season": "புதிய மாற்றிய சீசன்",
|
||||
"New stream": "புதிய ஸ்ட்ரீம்",
|
||||
"No": "இல்லை",
|
||||
"No changes to apply.": "பயன்படுத்த மாற்றங்கள் இல்லை.",
|
||||
"No changes to revert.": "மீட்டெடுக்க மாற்றங்கள் இல்லை.",
|
||||
"Normalization disabled.": "சீரமைப்பு முடக்கப்பட்டது.",
|
||||
"Normalization enabled.": "சீரமைப்பு இயக்கப்பட்டது.",
|
||||
"Normalize": "சீரமை",
|
||||
"Notes": "குறிப்புகள்",
|
||||
"Pattern": "வடிவம்",
|
||||
"Planned Changes (file->edited output)": "திட்டமிட்ட மாற்றங்கள் (கோப்பு->திருத்திய வெளியீடு)",
|
||||
"Quality": "தரம்",
|
||||
"Quit": "வெளியேறு",
|
||||
"Remove Pattern": "வடிவத்தை நீக்கு",
|
||||
"Revert": "மீட்டு",
|
||||
"Reverted pending changes.": "நிலுவையில் உள்ள மாற்றங்கள் மீட்டெடுக்கப்பட்டன.",
|
||||
"Save": "சேமி",
|
||||
"Season Offset": "சீசன் இடச்சரிவு",
|
||||
"Select a stream first.": "முதலில் ஒரு ஸ்ட்ரீமைத் தேர்ந்தெடுக்கவும்.",
|
||||
"Set Default": "இயல்புநிலையாக அமை",
|
||||
"Set Forced": "கட்டாயமாக அமை",
|
||||
"Settings Screen": "அமைப்புகள் திரை",
|
||||
"Numbering Mapping": "மாற்றிய சீசன்கள்",
|
||||
"Show": "தொடர்",
|
||||
"Shows": "தொடர்கள்",
|
||||
"Source Season": "மூல சீசன்",
|
||||
"SrcIndex": "மூலச் சுட்டி",
|
||||
"Status": "நிலை",
|
||||
"Stay": "இரு",
|
||||
"Stream dispositions": "ஸ்ட்ரீம் disposition-கள்",
|
||||
"Stream tags": "ஸ்ட்ரீம் குறிச்சொற்கள்",
|
||||
"Streams": "ஸ்ட்ரீம்கள்",
|
||||
"SubIndex": "துணைச்சுட்டி",
|
||||
"Substitute": "மாற்று",
|
||||
"Substitute pattern": "வடிவத்தை மாற்று",
|
||||
"Title": "தலைப்பு",
|
||||
"Type": "வகை",
|
||||
"Unable to update selected stream.": "தேர்ந்தெடுக்கப்பட்ட ஸ்ட்ரீமைப் புதுப்பிக்க முடியவில்லை.",
|
||||
"Up": "மேல்",
|
||||
"Update Pattern": "வடிவத்தை புதுப்பி",
|
||||
"Updated media tag {tag!r}.": "மீடியா குறிச்சொல் {tag!r} புதுப்பிக்கப்பட்டது.",
|
||||
"Updated stream #{index} ({track_type}).": "ஸ்ட்ரீம் #{index} ({track_type}) புதுப்பிக்கப்பட்டது.",
|
||||
"Value": "மதிப்பு",
|
||||
"Year": "ஆண்டு",
|
||||
"Yes": "ஆம்",
|
||||
"add media tag: key='{key}' value='{value}'": "மீடியா குறிச்சொல் சேர்: key='{key}' value='{value}'",
|
||||
"add {track_type} track: index={index} lang={language}": "{track_type} ஸ்ட்ரீம் சேர்: index={index} lang={language}",
|
||||
"attached_pic": "attached_pic",
|
||||
"attachment": "இணைப்பு",
|
||||
"audio": "ஒலி",
|
||||
"captions": "உரைப்பதிவுகள்",
|
||||
"change media tag: key='{key}' value='{value}'": "மீடியா குறிச்சொல் மாற்று: key='{key}' value='{value}'",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}": "ஸ்ட்ரீம் #{index} ({track_type}:{sub_index}) disposition சேர்={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}": "ஸ்ட்ரீம் #{index} ({track_type}:{sub_index}) key சேர்={key} value={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}": "ஸ்ட்ரீம் #{index} ({track_type}:{sub_index}) key மாற்று={key} value={value}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}": "ஸ்ட்ரீம் #{index} ({track_type}:{sub_index}) disposition நீக்கு={disposition}",
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}": "ஸ்ட்ரீம் #{index} ({track_type}:{sub_index}) key நீக்கு={key} value={value}",
|
||||
"clean_effects": "ஒலி விளைவுகள் மட்டும்",
|
||||
"comment": "கருத்துரை",
|
||||
"default": "இயல்புநிலை",
|
||||
"dependent": "சார்ந்த",
|
||||
"descriptions": "விளக்கங்கள்",
|
||||
"dub": "டப்",
|
||||
"for pattern": "வடிவத்திற்கு",
|
||||
"forced": "கட்டாயம்",
|
||||
"from": "இருந்து",
|
||||
"from pattern": "வடிவத்திலிருந்து",
|
||||
"from show": "தொடரிலிருந்து",
|
||||
"hearing_impaired": "கேள்வித்திறன் குறைபாடு",
|
||||
"karaoke": "கரோக்கே",
|
||||
"lyrics": "பாடல்வரிகள்",
|
||||
"metadata": "மெட்டாடேட்டா",
|
||||
"non_diegetic": "அல்லாத-டைஜெடிக்",
|
||||
"original": "மூலம்",
|
||||
"pattern #{id}": "வடிவு #{id}",
|
||||
"remove media tag: key='{key}' value='{value}'": "மீடியா குறிச்சொல் நீக்கு: key='{key}' value='{value}'",
|
||||
"remove stream #{index}": "ஸ்ட்ரீம் #{index} நீக்கு",
|
||||
"show #{id}": "தொடர் #{id}",
|
||||
"stereo": "ஸ்டீரியோ",
|
||||
"still_image": "நிலைப்படம்",
|
||||
"sub index": "துணைச்சுட்டி",
|
||||
"subtitle": "வசனம்",
|
||||
"timed_thumbnails": "நேர நிர்ணய சிறுபடங்கள்",
|
||||
"undefined": "வரையறுக்கப்படாத",
|
||||
"unknown": "தெரியாத",
|
||||
"video": "வீடியோ",
|
||||
"visual_impaired": "பார்வைத்திறன் குறைபாடு"
|
||||
}
|
||||
}
|
||||
170
docs/file_formats.md
Normal file
170
docs/file_formats.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# File Formats
|
||||
|
||||
This document captures source-file-format notes that complement the normative
|
||||
requirements in `requirements/source_file_formats.md`.
|
||||
|
||||
The first documented format is a Matroska source that carries styled ASS/SSA
|
||||
subtitle streams together with embedded font attachments.
|
||||
|
||||
## Styled ASS In Matroska With Embedded Fonts
|
||||
|
||||
These files are typically `.mkv` releases where subtitle rendering quality
|
||||
depends on keeping both parts of the subtitle package together:
|
||||
|
||||
- one or more subtitle streams with codec `ass`
|
||||
- one or more attachment streams that embed font files used by those subtitles
|
||||
|
||||
This matters because ASS subtitles are not plain text subtitles in the narrow
|
||||
WebVTT sense. They can carry layout, styling, positioning, karaoke, signs, and
|
||||
other typesetting effects. If the matching embedded fonts are lost, consumers
|
||||
can still see subtitle text but the intended styling and sometimes glyph
|
||||
coverage can be degraded.
|
||||
|
||||
For FFX this format is special because the ASS subtitle streams should remain
|
||||
normally editable and mappable, while the related font attachments should be
|
||||
transported unchanged.
|
||||
|
||||
## Observed Sample
|
||||
|
||||
Assessment date: `2026-04-17`
|
||||
|
||||
Observed sample file:
|
||||
|
||||
- `tests/assets/boruto_s01e283_ssa.mkv`
|
||||
|
||||
Commands used for assessment:
|
||||
|
||||
```bash
|
||||
ffprobe tests/assets/boruto_s01e283_ssa.mkv
|
||||
ffprobe -hide_banner -show_format -show_streams -of json tests/assets/boruto_s01e283_ssa.mkv
|
||||
```
|
||||
|
||||
Observed stream layout:
|
||||
|
||||
| Stream index | Kind | Key details |
|
||||
| --- | --- | --- |
|
||||
| `0` | video | `codec_name=h264` |
|
||||
| `1` | audio | `codec_name=aac`, `language=jpn` |
|
||||
| `2` | subtitle | `codec_name=ass`, `language=ger`, default |
|
||||
| `3` | subtitle | `codec_name=ass`, `language=eng` |
|
||||
| `4`-`13` | attachment | `tags.mimetype=font/ttf`, `.ttf` filenames |
|
||||
|
||||
Observed attachment filenames:
|
||||
|
||||
- `AmazonEmberTanuki-Italic.ttf`
|
||||
- `AmazonEmberTanuki-Regular.ttf`
|
||||
- `Arial.ttf`
|
||||
- `Arial Bold.ttf`
|
||||
- `Georgia.ttf`
|
||||
- `Times New Roman.ttf`
|
||||
- `Times New Roman Bold.ttf`
|
||||
- `Trebuchet MS.ttf`
|
||||
- `Verdana.ttf`
|
||||
- `Verdana Bold.ttf`
|
||||
|
||||
Important probe behavior from the real sample:
|
||||
|
||||
- Plain `ffprobe` lists the font streams as `Attachment: none`.
|
||||
- Plain `ffprobe` also prints warnings such as `Could not find codec
|
||||
parameters for stream 4 (Attachment: none): unknown codec` and later
|
||||
`Unsupported codec with id 0 for input stream ...`.
|
||||
- The JSON produced by `FileProperties.FFPROBE_COMMAND_TOKENS`
|
||||
(`ffprobe -hide_banner -show_format -show_streams -of json`) still exposes
|
||||
the attachment streams clearly through `codec_type="attachment"` and the
|
||||
attachment tags.
|
||||
- In that JSON, the attachment streams do not expose `codec_name`.
|
||||
|
||||
This last point is important for FFX: robust detection must not depend on
|
||||
attachment `codec_name` being present.
|
||||
|
||||
## Detection Guidance
|
||||
|
||||
Current known indicators for this format are:
|
||||
|
||||
- one or more subtitle streams with `codec_type="subtitle"` and
|
||||
`codec_name="ass"`
|
||||
- one or more attachment streams with `codec_type="attachment"`
|
||||
- attachment tags that identify embedded fonts, especially
|
||||
`tags.mimetype="font/ttf"`
|
||||
- attachment filenames that end in `.ttf`
|
||||
|
||||
The pattern can vary. FFX should therefore treat the above as a cluster of
|
||||
signals rather than an exact signature tied to one file.
|
||||
|
||||
Inference from the observed sample plus FFmpeg documentation:
|
||||
|
||||
- MIME matching should not be limited to `font/ttf` alone.
|
||||
- The Boruto sample uses `font/ttf`.
|
||||
- FFmpeg's Matroska attachment example uses
|
||||
`mimetype=application/x-truetype-font` for a `.ttf` attachment.
|
||||
- Detection should therefore normalize multiple TTF-like MIME values rather
|
||||
than depend on a single exact string.
|
||||
|
||||
## Processing Expectations In FFX
|
||||
|
||||
The format-specific requirements live in
|
||||
`requirements/source_file_formats.md`. In practical terms, FFX should:
|
||||
|
||||
- recognize the ASS-plus-font-attachment pattern even when attachment probe
|
||||
data is incomplete
|
||||
- tell the operator that the pattern was detected and that special handling is
|
||||
being used
|
||||
- reject sidecar subtitle import for such sources, because converting or
|
||||
replacing these subtitle tracks with ordinary external text subtitles would
|
||||
break the intended subtitle package
|
||||
- continue to allow normal manipulation of the ASS subtitle tracks themselves
|
||||
- preserve the font attachment streams unchanged
|
||||
|
||||
## FFmpeg Notes
|
||||
|
||||
Relevant FFmpeg documentation confirms several behaviors that line up with
|
||||
FFX's needs:
|
||||
|
||||
- FFmpeg documents `-attach` as adding an attachment stream to the output, and
|
||||
explicitly names Matroska fonts used in subtitle rendering as an example.
|
||||
- FFmpeg documents attachment streams as regular streams that are created after
|
||||
the mapped media streams.
|
||||
- FFmpeg documents `-dump_attachment` for extracting attachment streams, which
|
||||
is useful for debugging or validating a source file's embedded fonts.
|
||||
- FFmpeg's Matroska example requires a `mimetype` metadata tag for attached
|
||||
fonts, which is consistent with using attachment tags as detection signals.
|
||||
- FFmpeg also notes that attachments are implemented as codec extradata. That
|
||||
helps explain why probe output for attachment streams can look different from
|
||||
ordinary audio, video, and subtitle streams.
|
||||
|
||||
Implication for FFX:
|
||||
|
||||
- Attachment preservation is not an optional cosmetic feature for this format.
|
||||
It is part of preserving the subtitle package correctly.
|
||||
|
||||
## Jellyfin Notes
|
||||
|
||||
Jellyfin's documentation also supports keeping this format intact:
|
||||
|
||||
- Jellyfin's subtitle compatibility table lists `ASS/SSA` as supported in
|
||||
`MKV` and not supported in `MP4`.
|
||||
- Jellyfin notes that when subtitles must be transcoded, they are either
|
||||
converted to a supported format or burned into the video, and burning them in
|
||||
is the most CPU-intensive path.
|
||||
- Jellyfin's subtitle-extraction example for `SSA/ASS` first dumps attachment
|
||||
streams and then extracts the ASS subtitle stream, which reflects the real
|
||||
relationship between ASS subtitles and embedded fonts in MKV releases.
|
||||
- Jellyfin's font documentation says text-based subtitles require fonts to
|
||||
render properly.
|
||||
- Jellyfin's configuration documentation says the web client uses configured
|
||||
fallback fonts for ASS subtitles when other fonts such as MKV attachments or
|
||||
client-side fonts are not available.
|
||||
|
||||
Inference from the Jellyfin compatibility tables:
|
||||
|
||||
- Keeping this subtitle format in Matroska is the safest interoperability
|
||||
choice for Jellyfin consumers.
|
||||
- Converting the subtitle payload to WebVTT would lose styled ASS behavior.
|
||||
- Dropping the attachment streams would force client or fallback font
|
||||
substitution and can change appearance or glyph coverage.
|
||||
|
||||
## References
|
||||
|
||||
- FFmpeg documentation: https://ffmpeg.org/ffmpeg.html
|
||||
- Jellyfin codec support: https://jellyfin.org/docs/general/clients/codec-support/
|
||||
- Jellyfin configuration and fonts: https://jellyfin.org/docs/general/administration/configuration/
|
||||
@@ -1,13 +1,13 @@
|
||||
[project]
|
||||
name = "ffx"
|
||||
description = "FFX recoding and metadata managing tool"
|
||||
version = "0.2.4"
|
||||
version = "0.4.3"
|
||||
license = {file = "LICENSE.md"}
|
||||
dependencies = [
|
||||
"requests",
|
||||
"jinja2",
|
||||
"click",
|
||||
"textual",
|
||||
"textual>=8.0",
|
||||
"sqlalchemy",
|
||||
]
|
||||
readme = {file = "README.md", content-type = "text/markdown"}
|
||||
|
||||
220
src/ffx/_iso_language.py
Normal file
220
src/ffx/_iso_language.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from enum import Enum
|
||||
import difflib
|
||||
|
||||
|
||||
class IsoLanguage(Enum):
|
||||
|
||||
ABKHAZIAN = {"name": "Abkhazian", "iso639_1": "ab", "iso639_2": ["abk"]}
|
||||
AFAR = {"name": "Afar", "iso639_1": "aa", "iso639_2": ["aar"]}
|
||||
AFRIKAANS = {"name": "Afrikaans", "iso639_1": "af", "iso639_2": ["afr"]}
|
||||
AKAN = {"name": "Akan", "iso639_1": "ak", "iso639_2": ["aka"]}
|
||||
ALBANIAN = {"name": "Albanian", "iso639_1": "sq", "iso639_2": ["sqi", "alb"]}
|
||||
AMHARIC = {"name": "Amharic", "iso639_1": "am", "iso639_2": ["amh"]}
|
||||
ARABIC = {"name": "Arabic", "iso639_1": "ar", "iso639_2": ["ara"]}
|
||||
ARAGONESE = {"name": "Aragonese", "iso639_1": "an", "iso639_2": ["arg"]}
|
||||
ARMENIAN = {"name": "Armenian", "iso639_1": "hy", "iso639_2": ["hye", "arm"]}
|
||||
ASSAMESE = {"name": "Assamese", "iso639_1": "as", "iso639_2": ["asm"]}
|
||||
AVARIC = {"name": "Avaric", "iso639_1": "av", "iso639_2": ["ava"]}
|
||||
AVESTAN = {"name": "Avestan", "iso639_1": "ae", "iso639_2": ["ave"]}
|
||||
AYMARA = {"name": "Aymara", "iso639_1": "ay", "iso639_2": ["aym"]}
|
||||
AZERBAIJANI = {"name": "Azerbaijani", "iso639_1": "az", "iso639_2": ["aze"]}
|
||||
BAMBARA = {"name": "Bambara", "iso639_1": "bm", "iso639_2": ["bam"]}
|
||||
BASHKIR = {"name": "Bashkir", "iso639_1": "ba", "iso639_2": ["bak"]}
|
||||
BASQUE = {"name": "Basque", "iso639_1": "eu", "iso639_2": ["eus", "baq"]}
|
||||
BELARUSIAN = {"name": "Belarusian", "iso639_1": "be", "iso639_2": ["bel"]}
|
||||
BENGALI = {"name": "Bengali", "iso639_1": "bn", "iso639_2": ["ben"]}
|
||||
BISLAMA = {"name": "Bislama", "iso639_1": "bi", "iso639_2": ["bis"]}
|
||||
BOKMAL = {"name": "Bokmål", "iso639_1": "nb", "iso639_2": ["nob"]}
|
||||
BOSNIAN = {"name": "Bosnian", "iso639_1": "bs", "iso639_2": ["bos"]}
|
||||
BRETON = {"name": "Breton", "iso639_1": "br", "iso639_2": ["bre"]}
|
||||
BULGARIAN = {"name": "Bulgarian", "iso639_1": "bg", "iso639_2": ["bul"]}
|
||||
BURMESE = {"name": "Burmese", "iso639_1": "my", "iso639_2": ["mya", "bur"]}
|
||||
CATALAN = {"name": "Catalan", "iso639_1": "ca", "iso639_2": ["cat"]}
|
||||
CHAMORRO = {"name": "Chamorro", "iso639_1": "ch", "iso639_2": ["cha"]}
|
||||
CHECHEN = {"name": "Chechen", "iso639_1": "ce", "iso639_2": ["che"]}
|
||||
CHICHEWA = {"name": "Chichewa", "iso639_1": "ny", "iso639_2": ["nya"]}
|
||||
CHINESE = {"name": "Chinese", "iso639_1": "zh", "iso639_2": ["zho", "chi"]}
|
||||
CHURCH_SLAVIC = {"name": "Church Slavic", "iso639_1": "cu", "iso639_2": ["chu"]}
|
||||
CHUVASH = {"name": "Chuvash", "iso639_1": "cv", "iso639_2": ["chv"]}
|
||||
CORNISH = {"name": "Cornish", "iso639_1": "kw", "iso639_2": ["cor"]}
|
||||
CORSICAN = {"name": "Corsican", "iso639_1": "co", "iso639_2": ["cos"]}
|
||||
CREE = {"name": "Cree", "iso639_1": "cr", "iso639_2": ["cre"]}
|
||||
CROATIAN = {"name": "Croatian", "iso639_1": "hr", "iso639_2": ["hrv"]}
|
||||
CZECH = {"name": "Czech", "iso639_1": "cs", "iso639_2": ["ces", "cze"]}
|
||||
DANISH = {"name": "Danish", "iso639_1": "da", "iso639_2": ["dan"]}
|
||||
DIVEHI = {"name": "Divehi", "iso639_1": "dv", "iso639_2": ["div"]}
|
||||
DUTCH = {"name": "Dutch", "iso639_1": "nl", "iso639_2": ["nld", "dut"]}
|
||||
DZONGKHA = {"name": "Dzongkha", "iso639_1": "dz", "iso639_2": ["dzo"]}
|
||||
ENGLISH = {"name": "English", "iso639_1": "en", "iso639_2": ["eng"]}
|
||||
ESPERANTO = {"name": "Esperanto", "iso639_1": "eo", "iso639_2": ["epo"]}
|
||||
ESTONIAN = {"name": "Estonian", "iso639_1": "et", "iso639_2": ["est"]}
|
||||
EWE = {"name": "Ewe", "iso639_1": "ee", "iso639_2": ["ewe"]}
|
||||
FAROESE = {"name": "Faroese", "iso639_1": "fo", "iso639_2": ["fao"]}
|
||||
FIJIAN = {"name": "Fijian", "iso639_1": "fj", "iso639_2": ["fij"]}
|
||||
FINNISH = {"name": "Finnish", "iso639_1": "fi", "iso639_2": ["fin"]}
|
||||
FRENCH = {"name": "French", "iso639_1": "fr", "iso639_2": ["fra", "fre"]}
|
||||
FULAH = {"name": "Fulah", "iso639_1": "ff", "iso639_2": ["ful"]}
|
||||
GALICIAN = {"name": "Galician", "iso639_1": "gl", "iso639_2": ["glg"]}
|
||||
GANDA = {"name": "Ganda", "iso639_1": "lg", "iso639_2": ["lug"]}
|
||||
GEORGIAN = {"name": "Georgian", "iso639_1": "ka", "iso639_2": ["kat", "geo"]}
|
||||
GERMAN = {"name": "German", "iso639_1": "de", "iso639_2": ["deu", "ger"]}
|
||||
GREEK = {"name": "Greek", "iso639_1": "el", "iso639_2": ["ell", "gre"]}
|
||||
GUARANI = {"name": "Guarani", "iso639_1": "gn", "iso639_2": ["grn"]}
|
||||
GUJARATI = {"name": "Gujarati", "iso639_1": "gu", "iso639_2": ["guj"]}
|
||||
HAITIAN = {"name": "Haitian", "iso639_1": "ht", "iso639_2": ["hat"]}
|
||||
HAUSA = {"name": "Hausa", "iso639_1": "ha", "iso639_2": ["hau"]}
|
||||
HEBREW = {"name": "Hebrew", "iso639_1": "he", "iso639_2": ["heb"]}
|
||||
HERERO = {"name": "Herero", "iso639_1": "hz", "iso639_2": ["her"]}
|
||||
HINDI = {"name": "Hindi", "iso639_1": "hi", "iso639_2": ["hin"]}
|
||||
HIRI_MOTU = {"name": "Hiri Motu", "iso639_1": "ho", "iso639_2": ["hmo"]}
|
||||
HUNGARIAN = {"name": "Hungarian", "iso639_1": "hu", "iso639_2": ["hun"]}
|
||||
ICELANDIC = {"name": "Icelandic", "iso639_1": "is", "iso639_2": ["isl", "ice"]}
|
||||
IDO = {"name": "Ido", "iso639_1": "io", "iso639_2": ["ido"]}
|
||||
IGBO = {"name": "Igbo", "iso639_1": "ig", "iso639_2": ["ibo"]}
|
||||
INDONESIAN = {"name": "Indonesian", "iso639_1": "id", "iso639_2": ["ind"]}
|
||||
INTERLINGUA = {"name": "Interlingua", "iso639_1": "ia", "iso639_2": ["ina"]}
|
||||
INTERLINGUE = {"name": "Interlingue", "iso639_1": "ie", "iso639_2": ["ile"]}
|
||||
INUKTITUT = {"name": "Inuktitut", "iso639_1": "iu", "iso639_2": ["iku"]}
|
||||
INUPIAQ = {"name": "Inupiaq", "iso639_1": "ik", "iso639_2": ["ipk"]}
|
||||
IRISH = {"name": "Irish", "iso639_1": "ga", "iso639_2": ["gle"]}
|
||||
ITALIAN = {"name": "Italian", "iso639_1": "it", "iso639_2": ["ita"]}
|
||||
JAPANESE = {"name": "Japanese", "iso639_1": "ja", "iso639_2": ["jpn"]}
|
||||
JAVANESE = {"name": "Javanese", "iso639_1": "jv", "iso639_2": ["jav"]}
|
||||
KALAALLISUT = {"name": "Kalaallisut", "iso639_1": "kl", "iso639_2": ["kal"]}
|
||||
KANNADA = {"name": "Kannada", "iso639_1": "kn", "iso639_2": ["kan"]}
|
||||
KANURI = {"name": "Kanuri", "iso639_1": "kr", "iso639_2": ["kau"]}
|
||||
KASHMIRI = {"name": "Kashmiri", "iso639_1": "ks", "iso639_2": ["kas"]}
|
||||
KAZAKH = {"name": "Kazakh", "iso639_1": "kk", "iso639_2": ["kaz"]}
|
||||
KHMER = {"name": "Khmer", "iso639_1": "km", "iso639_2": ["khm"]}
|
||||
KIKUYU = {"name": "Kikuyu", "iso639_1": "ki", "iso639_2": ["kik"]}
|
||||
KINYARWANDA = {"name": "Kinyarwanda", "iso639_1": "rw", "iso639_2": ["kin"]}
|
||||
KIRGHIZ = {"name": "Kirghiz", "iso639_1": "ky", "iso639_2": ["kir"]}
|
||||
KOMI = {"name": "Komi", "iso639_1": "kv", "iso639_2": ["kom"]}
|
||||
KONGO = {"name": "Kongo", "iso639_1": "kg", "iso639_2": ["kon"]}
|
||||
KOREAN = {"name": "Korean", "iso639_1": "ko", "iso639_2": ["kor"]}
|
||||
KUANYAMA = {"name": "Kuanyama", "iso639_1": "kj", "iso639_2": ["kua"]}
|
||||
KURDISH = {"name": "Kurdish", "iso639_1": "ku", "iso639_2": ["kur"]}
|
||||
LAO = {"name": "Lao", "iso639_1": "lo", "iso639_2": ["lao"]}
|
||||
LATIN = {"name": "Latin", "iso639_1": "la", "iso639_2": ["lat"]}
|
||||
LATVIAN = {"name": "Latvian", "iso639_1": "lv", "iso639_2": ["lav"]}
|
||||
LIMBURGAN = {"name": "Limburgan", "iso639_1": "li", "iso639_2": ["lim"]}
|
||||
LINGALA = {"name": "Lingala", "iso639_1": "ln", "iso639_2": ["lin"]}
|
||||
LITHUANIAN = {"name": "Lithuanian", "iso639_1": "lt", "iso639_2": ["lit"]}
|
||||
LUBA_KATANGA = {"name": "Luba-Katanga", "iso639_1": "lu", "iso639_2": ["lub"]}
|
||||
LUXEMBOURGISH = {"name": "Luxembourgish", "iso639_1": "lb", "iso639_2": ["ltz"]}
|
||||
MACEDONIAN = {"name": "Macedonian", "iso639_1": "mk", "iso639_2": ["mkd", "mac"]}
|
||||
MALAGASY = {"name": "Malagasy", "iso639_1": "mg", "iso639_2": ["mlg"]}
|
||||
MALAY = {"name": "Malay", "iso639_1": "ms", "iso639_2": ["msa", "may"]}
|
||||
MALAYALAM = {"name": "Malayalam", "iso639_1": "ml", "iso639_2": ["mal"]}
|
||||
MALTESE = {"name": "Maltese", "iso639_1": "mt", "iso639_2": ["mlt"]}
|
||||
MANX = {"name": "Manx", "iso639_1": "gv", "iso639_2": ["glv"]}
|
||||
MAORI = {"name": "Maori", "iso639_1": "mi", "iso639_2": ["mri", "mao"]}
|
||||
MARATHI = {"name": "Marathi", "iso639_1": "mr", "iso639_2": ["mar"]}
|
||||
MARSHALLESE = {"name": "Marshallese", "iso639_1": "mh", "iso639_2": ["mah"]}
|
||||
MONGOLIAN = {"name": "Mongolian", "iso639_1": "mn", "iso639_2": ["mon"]}
|
||||
NAURU = {"name": "Nauru", "iso639_1": "na", "iso639_2": ["nau"]}
|
||||
NAVAJO = {"name": "Navajo", "iso639_1": "nv", "iso639_2": ["nav"]}
|
||||
NDONGA = {"name": "Ndonga", "iso639_1": "ng", "iso639_2": ["ndo"]}
|
||||
NEPALI = {"name": "Nepali", "iso639_1": "ne", "iso639_2": ["nep"]}
|
||||
NORTH_NDEBELE = {"name": "North Ndebele", "iso639_1": "nd", "iso639_2": ["nde"]}
|
||||
NORTHERN_SAMI = {"name": "Northern Sami", "iso639_1": "se", "iso639_2": ["sme"]}
|
||||
NORWEGIAN = {"name": "Norwegian", "iso639_1": "no", "iso639_2": ["nor"]}
|
||||
NORWEGIAN_NYNORSK = {"name": "Nynorsk", "iso639_1": "nn", "iso639_2": ["nno"]}
|
||||
OCCITAN = {"name": "Occitan", "iso639_1": "oc", "iso639_2": ["oci"]}
|
||||
OJIBWA = {"name": "Ojibwa", "iso639_1": "oj", "iso639_2": ["oji"]}
|
||||
ORIYA = {"name": "Oriya", "iso639_1": "or", "iso639_2": ["ori"]}
|
||||
OROMO = {"name": "Oromo", "iso639_1": "om", "iso639_2": ["orm"]}
|
||||
OSSETIAN = {"name": "Ossetian", "iso639_1": "os", "iso639_2": ["oss"]}
|
||||
PALI = {"name": "Pali", "iso639_1": "pi", "iso639_2": ["pli"]}
|
||||
PANJABI = {"name": "Panjabi", "iso639_1": "pa", "iso639_2": ["pan"]}
|
||||
PERSIAN = {"name": "Persian", "iso639_1": "fa", "iso639_2": ["fas", "per"]}
|
||||
POLISH = {"name": "Polish", "iso639_1": "pl", "iso639_2": ["pol"]}
|
||||
PORTUGUESE = {"name": "Portuguese", "iso639_1": "pt", "iso639_2": ["por"]}
|
||||
PUSHTO = {"name": "Pushto", "iso639_1": "ps", "iso639_2": ["pus"]}
|
||||
QUECHUA = {"name": "Quechua", "iso639_1": "qu", "iso639_2": ["que"]}
|
||||
ROMANIAN = {"name": "Romanian", "iso639_1": "ro", "iso639_2": ["ron", "rum"]}
|
||||
ROMANSH = {"name": "Romansh", "iso639_1": "rm", "iso639_2": ["roh"]}
|
||||
RUNDI = {"name": "Rundi", "iso639_1": "rn", "iso639_2": ["run"]}
|
||||
RUSSIAN = {"name": "Russian", "iso639_1": "ru", "iso639_2": ["rus"]}
|
||||
SAMOAN = {"name": "Samoan", "iso639_1": "sm", "iso639_2": ["smo"]}
|
||||
SANGO = {"name": "Sango", "iso639_1": "sg", "iso639_2": ["sag"]}
|
||||
SANSKRIT = {"name": "Sanskrit", "iso639_1": "sa", "iso639_2": ["san"]}
|
||||
SARDINIAN = {"name": "Sardinian", "iso639_1": "sc", "iso639_2": ["srd"]}
|
||||
SCOTTISH_GAELIC = {"name": "Scottish Gaelic", "iso639_1": "gd", "iso639_2": ["gla"]}
|
||||
SERBIAN = {"name": "Serbian", "iso639_1": "sr", "iso639_2": ["srp"]}
|
||||
SHONA = {"name": "Shona", "iso639_1": "sn", "iso639_2": ["sna"]}
|
||||
SICHUAN_YI = {"name": "Sichuan Yi", "iso639_1": "ii", "iso639_2": ["iii"]}
|
||||
SINDHI = {"name": "Sindhi", "iso639_1": "sd", "iso639_2": ["snd"]}
|
||||
SINHALA = {"name": "Sinhala", "iso639_1": "si", "iso639_2": ["sin"]}
|
||||
SLOVAK = {"name": "Slovak", "iso639_1": "sk", "iso639_2": ["slk", "slo"]}
|
||||
SLOVENIAN = {"name": "Slovenian", "iso639_1": "sl", "iso639_2": ["slv"]}
|
||||
SOMALI = {"name": "Somali", "iso639_1": "so", "iso639_2": ["som"]}
|
||||
SOUTH_NDEBELE = {"name": "South Ndebele", "iso639_1": "nr", "iso639_2": ["nbl"]}
|
||||
SOUTHERN_SOTHO = {"name": "Southern Sotho", "iso639_1": "st", "iso639_2": ["sot"]}
|
||||
SPANISH = {"name": "Spanish", "iso639_1": "es", "iso639_2": ["spa"]}
|
||||
SUNDANESE = {"name": "Sundanese", "iso639_1": "su", "iso639_2": ["sun"]}
|
||||
SWAHILI = {"name": "Swahili", "iso639_1": "sw", "iso639_2": ["swa"]}
|
||||
SWATI = {"name": "Swati", "iso639_1": "ss", "iso639_2": ["ssw"]}
|
||||
SWEDISH = {"name": "Swedish", "iso639_1": "sv", "iso639_2": ["swe"]}
|
||||
TAGALOG = {"name": "Tagalog", "iso639_1": "tl", "iso639_2": ["tgl"]}
|
||||
TAHITIAN = {"name": "Tahitian", "iso639_1": "ty", "iso639_2": ["tah"]}
|
||||
TAJIK = {"name": "Tajik", "iso639_1": "tg", "iso639_2": ["tgk"]}
|
||||
TAMIL = {"name": "Tamil", "iso639_1": "ta", "iso639_2": ["tam"]}
|
||||
TATAR = {"name": "Tatar", "iso639_1": "tt", "iso639_2": ["tat"]}
|
||||
TELUGU = {"name": "Telugu", "iso639_1": "te", "iso639_2": ["tel"]}
|
||||
THAI = {"name": "Thai", "iso639_1": "th", "iso639_2": ["tha"]}
|
||||
TIBETAN = {"name": "Tibetan", "iso639_1": "bo", "iso639_2": ["bod", "tib"]}
|
||||
TIGRINYA = {"name": "Tigrinya", "iso639_1": "ti", "iso639_2": ["tir"]}
|
||||
TONGA = {"name": "Tonga", "iso639_1": "to", "iso639_2": ["ton"]}
|
||||
TSONGA = {"name": "Tsonga", "iso639_1": "ts", "iso639_2": ["tso"]}
|
||||
TSWANA = {"name": "Tswana", "iso639_1": "tn", "iso639_2": ["tsn"]}
|
||||
TURKISH = {"name": "Turkish", "iso639_1": "tr", "iso639_2": ["tur"]}
|
||||
TURKMEN = {"name": "Turkmen", "iso639_1": "tk", "iso639_2": ["tuk"]}
|
||||
TWI = {"name": "Twi", "iso639_1": "tw", "iso639_2": ["twi"]}
|
||||
UIGHUR = {"name": "Uighur", "iso639_1": "ug", "iso639_2": ["uig"]}
|
||||
UKRAINIAN = {"name": "Ukrainian", "iso639_1": "uk", "iso639_2": ["ukr"]}
|
||||
URDU = {"name": "Urdu", "iso639_1": "ur", "iso639_2": ["urd"]}
|
||||
UZBEK = {"name": "Uzbek", "iso639_1": "uz", "iso639_2": ["uzb"]}
|
||||
VENDA = {"name": "Venda", "iso639_1": "ve", "iso639_2": ["ven"]}
|
||||
VIETNAMESE = {"name": "Vietnamese", "iso639_1": "vi", "iso639_2": ["vie"]}
|
||||
VOLAPUK = {"name": "Volapük", "iso639_1": "vo", "iso639_2": ["vol"]}
|
||||
WALLOON = {"name": "Walloon", "iso639_1": "wa", "iso639_2": ["wln"]}
|
||||
WELSH = {"name": "Welsh", "iso639_1": "cy", "iso639_2": ["cym", "wel"]}
|
||||
WESTERN_FRISIAN = {"name": "Western Frisian", "iso639_1": "fy", "iso639_2": ["fry"]}
|
||||
WOLOF = {"name": "Wolof", "iso639_1": "wo", "iso639_2": ["wol"]}
|
||||
XHOSA = {"name": "Xhosa", "iso639_1": "xh", "iso639_2": ["xho"]}
|
||||
YIDDISH = {"name": "Yiddish", "iso639_1": "yi", "iso639_2": ["yid"]}
|
||||
YORUBA = {"name": "Yoruba", "iso639_1": "yo", "iso639_2": ["yor"]}
|
||||
ZHUANG = {"name": "Zhuang", "iso639_1": "za", "iso639_2": ["zha"]}
|
||||
ZULU = {"name": "Zulu", "iso639_1": "zu", "iso639_2": ["zul"]}
|
||||
|
||||
FILIPINO = {"name": "Filipino", "iso639_1": "tl", "iso639_2": ["fil"]}
|
||||
|
||||
UNDEFINED = {"name": "undefined", "iso639_1": "xx", "iso639_2": ["und"]}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def find(label : str):
|
||||
|
||||
closestMatches = difflib.get_close_matches(label, [l.value["name"] for l in IsoLanguage], n=1)
|
||||
|
||||
if closestMatches:
|
||||
foundLangs = [l for l in IsoLanguage if l.value["name"] == closestMatches[0]]
|
||||
return foundLangs[0] if foundLangs else IsoLanguage.UNDEFINED
|
||||
else:
|
||||
return IsoLanguage.UNDEFINED
|
||||
|
||||
@staticmethod
|
||||
def findThreeLetter(theeLetter : str):
|
||||
foundLangs = [l for l in IsoLanguage if str(theeLetter) in l.value["iso639_2"]]
|
||||
return foundLangs[0] if foundLangs else IsoLanguage.UNDEFINED
|
||||
|
||||
|
||||
def label(self):
|
||||
return str(self.value["name"])
|
||||
|
||||
def twoLetter(self):
|
||||
return str(self.value["iso639_1"])
|
||||
|
||||
def threeLetter(self):
|
||||
return str(self.value["iso639_2"][0])
|
||||
67
src/ffx/attachment_format.py
Normal file
67
src/ffx/attachment_format.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from enum import Enum
|
||||
import os
|
||||
|
||||
|
||||
class AttachmentFormat(Enum):
|
||||
|
||||
TTF = {'identifier': 'ttf', 'format': None, 'extension': 'ttf', 'label': 'TTF'}
|
||||
PNG = {'identifier': 'png', 'format': None, 'extension': 'png', 'label': 'PNG'}
|
||||
|
||||
UNKNOWN = {'identifier': 'unknown', 'format': None, 'extension': None, 'label': 'UNKNOWN'}
|
||||
|
||||
def identifier(self):
|
||||
return str(self.value['identifier'])
|
||||
|
||||
def label(self):
|
||||
return str(self.value['label'])
|
||||
|
||||
def format(self):
|
||||
return self.value['format']
|
||||
|
||||
def extension(self):
|
||||
return str(self.value['extension'])
|
||||
|
||||
@staticmethod
|
||||
def identify(identifier: str):
|
||||
formats = [f for f in AttachmentFormat if f.value['identifier'] == str(identifier)]
|
||||
if formats:
|
||||
return formats[0]
|
||||
return AttachmentFormat.UNKNOWN
|
||||
|
||||
@staticmethod
|
||||
def identifyFfprobeStream(streamObj: dict):
|
||||
identifier = streamObj.get("codec_name")
|
||||
identifiedFormat = AttachmentFormat.identify(identifier)
|
||||
if identifiedFormat != AttachmentFormat.UNKNOWN:
|
||||
return identifiedFormat
|
||||
|
||||
if str(streamObj.get("codec_type", "")).strip() != "attachment":
|
||||
return AttachmentFormat.UNKNOWN
|
||||
|
||||
tags = streamObj.get("tags", {}) or {}
|
||||
mimetype = str(tags.get("mimetype", "")).strip().lower()
|
||||
filename = str(tags.get("filename", "")).strip().lower()
|
||||
filenameExtension = os.path.splitext(filename)[1]
|
||||
|
||||
if (
|
||||
mimetype in {
|
||||
"font/ttf",
|
||||
"application/x-truetype-font",
|
||||
"application/x-font-ttf",
|
||||
}
|
||||
or "truetype" in mimetype
|
||||
or filenameExtension == ".ttf"
|
||||
):
|
||||
return AttachmentFormat.TTF
|
||||
|
||||
if mimetype in {"image/png", "image/x-png"} or filenameExtension == ".png":
|
||||
return AttachmentFormat.PNG
|
||||
|
||||
return AttachmentFormat.UNKNOWN
|
||||
|
||||
@staticmethod
|
||||
def fromTrackCodec(trackCodec):
|
||||
identifier = getattr(trackCodec, "identifier", None)
|
||||
if callable(identifier):
|
||||
return AttachmentFormat.identify(trackCodec.identifier())
|
||||
return AttachmentFormat.UNKNOWN
|
||||
398
src/ffx/cli.py
398
src/ffx/cli.py
@@ -34,6 +34,7 @@ if TYPE_CHECKING:
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
|
||||
LIGHTWEIGHT_COMMANDS = {None, 'version', 'help', 'setup', 'configure_workstation', 'upgrade', 'rename'}
|
||||
CONFIG_ONLY_COMMANDS = {'edit'}
|
||||
CPU_OPTION_HELP = (
|
||||
"Limit CPU for started processes. Use an absolute cpulimit value such as 200 "
|
||||
+ "(about 2 cores), or use a percentage such as 25% for a share of present cores. "
|
||||
@@ -67,6 +68,14 @@ CUT_OPTION_HELP = (
|
||||
+ "or --cut START,DURATION for an explicit start and duration. "
|
||||
+ "Omit to disable."
|
||||
)
|
||||
COPY_VIDEO_OPTION_HELP = (
|
||||
"Copy video streams without re-encoding. Skips video encoder options "
|
||||
+ "and video filters."
|
||||
)
|
||||
COPY_AUDIO_OPTION_HELP = (
|
||||
"Copy audio streams without re-encoding. Skips audio encoder options "
|
||||
+ "and audio filters."
|
||||
)
|
||||
|
||||
|
||||
def normalizeNicenessOption(ctx, param, value):
|
||||
@@ -249,10 +258,17 @@ def buildRenameTargetFilename(
|
||||
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
@click.option('--language', 'app_language', type=str, default='', help='Set application language')
|
||||
@click.option('--database-file', type=str, default='', help='Path to database file')
|
||||
@click.option(
|
||||
'--debug',
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help='Enable debug-only TUI diagnostics such as the log pane',
|
||||
)
|
||||
@click.option('-v', '--verbose', type=int, default=0, help='Set verbosity of output')
|
||||
@click.option("--dry-run", is_flag=True, default=False)
|
||||
def ffx(ctx, database_file, verbose, dry_run):
|
||||
def ffx(ctx, app_language, database_file, debug, verbose, dry_run):
|
||||
"""FFX"""
|
||||
|
||||
ctx.obj = {}
|
||||
@@ -260,22 +276,38 @@ def ffx(ctx, database_file, verbose, dry_run):
|
||||
if ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
from ffx.i18n import (
|
||||
read_configured_language,
|
||||
resolve_application_language,
|
||||
set_current_language,
|
||||
)
|
||||
|
||||
resolvedLanguage = resolve_application_language(
|
||||
cli_language=app_language,
|
||||
config_language=read_configured_language(),
|
||||
)
|
||||
set_current_language(resolvedLanguage)
|
||||
ctx.obj['language'] = resolvedLanguage
|
||||
ctx.obj['debug'] = bool(debug)
|
||||
|
||||
if ctx.invoked_subcommand in LIGHTWEIGHT_COMMANDS:
|
||||
ctx.obj['dry_run'] = dry_run
|
||||
ctx.obj['verbosity'] = verbose
|
||||
return
|
||||
|
||||
from ffx.configuration_controller import ConfigurationController
|
||||
from ffx.database import databaseContext
|
||||
from ffx.logging_utils import configure_ffx_logger
|
||||
|
||||
ctx.obj['config'] = ConfigurationController()
|
||||
|
||||
ctx.obj['database'] = databaseContext(databasePath=database_file
|
||||
if database_file else ctx.obj['config'].getDatabaseFilePath())
|
||||
|
||||
ctx.obj['dry_run'] = dry_run
|
||||
ctx.obj['verbosity'] = verbose
|
||||
ctx.obj['debug'] = bool(debug)
|
||||
ctx.obj['language'] = resolve_application_language(
|
||||
cli_language=app_language,
|
||||
config_language=ctx.obj['config'].getLanguage(),
|
||||
)
|
||||
set_current_language(ctx.obj['language'])
|
||||
|
||||
# Critical 50
|
||||
# Error 40
|
||||
@@ -291,6 +323,17 @@ def ffx(ctx, database_file, verbose, dry_run):
|
||||
consoleLogVerbosity,
|
||||
)
|
||||
|
||||
if ctx.invoked_subcommand in CONFIG_ONLY_COMMANDS:
|
||||
return
|
||||
|
||||
from ffx.database import databaseContext
|
||||
|
||||
ctx.obj['database'] = databaseContext(
|
||||
databasePath=database_file
|
||||
if database_file
|
||||
else ctx.obj['config'].getDatabaseFilePath()
|
||||
)
|
||||
|
||||
|
||||
# Define a subcommand
|
||||
@ffx.command()
|
||||
@@ -303,7 +346,7 @@ def version():
|
||||
def help():
|
||||
click.echo(f"ffx {VERSION}\n")
|
||||
click.echo("Maintenance commands: setup, configure_workstation, upgrade")
|
||||
click.echo("Media commands: shows, inspect, convert, rename, unmux, cropdetect")
|
||||
click.echo("Media commands: shows, inspect, edit, convert, rename, unmux, cropdetect")
|
||||
click.echo("Use 'ffx --help' or 'ffx <command> --help' for full command help.")
|
||||
|
||||
|
||||
@@ -350,6 +393,41 @@ def getTrackedGitChanges(repoPath):
|
||||
return [line for line in completed.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def getCurrentGitBranch(repoPath):
|
||||
completed = subprocess.run(
|
||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
cwd=repoPath,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if completed.returncode != 0:
|
||||
commandLabel = 'git rev-parse --abbrev-ref HEAD'
|
||||
errorOutput = completed.stderr.strip() or completed.stdout.strip()
|
||||
raise click.ClickException(
|
||||
f"Unable to inspect bundle repository branch using '{commandLabel}': {errorOutput}"
|
||||
)
|
||||
|
||||
return completed.stdout.strip() or "unknown"
|
||||
|
||||
|
||||
def getBundleVersion(repoPath):
|
||||
constantsPath = os.path.join(repoPath, 'src', 'ffx', 'constants.py')
|
||||
|
||||
try:
|
||||
with open(constantsPath, encoding='utf-8') as constantsFile:
|
||||
for line in constantsFile:
|
||||
strippedLine = line.strip()
|
||||
if strippedLine.startswith('VERSION=') or strippedLine.startswith('VERSION ='):
|
||||
return strippedLine.split('=', 1)[1].strip().strip('"\'')
|
||||
except OSError as ex:
|
||||
raise click.ClickException(
|
||||
f"Unable to inspect bundle version from {constantsPath}: {ex}"
|
||||
) from ex
|
||||
|
||||
raise click.ClickException(f"Unable to inspect bundle version from {constantsPath}")
|
||||
|
||||
|
||||
def runScriptWrapper(ctx, scriptPath, missingDescription, commandArgs):
|
||||
if not os.path.isfile(scriptPath):
|
||||
raise click.ClickException(f"{missingDescription} not found at {scriptPath}")
|
||||
@@ -364,6 +442,20 @@ def runScriptWrapper(ctx, scriptPath, missingDescription, commandArgs):
|
||||
ctx.exit(completed.returncode)
|
||||
|
||||
|
||||
def runTuiApp(ctx) -> None:
|
||||
from ffx.ffx_app import FfxApp
|
||||
from ffx.logging_utils import set_ffx_console_logging_enabled
|
||||
|
||||
logger = ctx.obj.get('logger')
|
||||
set_ffx_console_logging_enabled(logger, enabled=False)
|
||||
|
||||
try:
|
||||
app = FfxApp(ctx.obj)
|
||||
app.run()
|
||||
finally:
|
||||
set_ffx_console_logging_enabled(logger, enabled=True)
|
||||
|
||||
|
||||
@ffx.command(name='setup')
|
||||
@click.pass_context
|
||||
@click.option('--check', is_flag=True, default=False, help='Only verify bundle-setup readiness')
|
||||
@@ -436,10 +528,14 @@ def upgrade(ctx, branch):
|
||||
commandSequences.append(['git', 'reset', '--hard', 'HEAD'])
|
||||
|
||||
if branch:
|
||||
commandSequences.append(['git', 'checkout', branch])
|
||||
commandSequences += [
|
||||
['git', 'fetch', 'origin', branch],
|
||||
['git', 'checkout', '-B', branch, 'FETCH_HEAD'],
|
||||
]
|
||||
else:
|
||||
commandSequences.append(['git', 'pull'])
|
||||
|
||||
commandSequences += [
|
||||
['git', 'pull'],
|
||||
[bundlePipPath, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||
[bundlePipPath, 'install', '--editable', '.'],
|
||||
]
|
||||
@@ -454,19 +550,75 @@ def upgrade(ctx, branch):
|
||||
if completed.returncode != 0:
|
||||
ctx.exit(completed.returncode)
|
||||
|
||||
upgradedBranch = getCurrentGitBranch(bundleRepoPath)
|
||||
upgradedVersion = getBundleVersion(bundleRepoPath)
|
||||
click.echo(f"Updated FFX to version {upgradedVersion} from branch {upgradedBranch}.")
|
||||
|
||||
|
||||
@ffx.command()
|
||||
@click.pass_context
|
||||
@click.option('--shift', is_flag=True, default=False, help='Print resolved season-shift mapping for each file instead of opening the TUI')
|
||||
@click.argument('filenames', nargs=-1)
|
||||
def inspect(ctx, shift, filenames):
|
||||
if not filenames:
|
||||
raise click.ClickException("At least one filename is required.")
|
||||
|
||||
if shift:
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
|
||||
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
||||
|
||||
for filename in filenames:
|
||||
fileProperties = FileProperties(ctx.obj, filename)
|
||||
season = fileProperties.getSeason()
|
||||
episode = fileProperties.getEpisode()
|
||||
|
||||
if season == -1 or episode == -1:
|
||||
click.echo(f"{filename}: no season/episode recognized")
|
||||
continue
|
||||
|
||||
currentPattern = fileProperties.getPattern()
|
||||
shiftedSeason, shiftedEpisode, sourceLabel = shiftedSeasonController.resolveShiftSeason(
|
||||
fileProperties.getShowId(),
|
||||
season=season,
|
||||
episode=episode,
|
||||
patternId=currentPattern.getId() if currentPattern is not None else None,
|
||||
)
|
||||
if shiftedSeason == season and shiftedEpisode == episode:
|
||||
click.echo(f"{filename}: none")
|
||||
else:
|
||||
click.echo(
|
||||
f"{filename}: {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
|
||||
)
|
||||
return
|
||||
|
||||
if len(filenames) != 1:
|
||||
raise click.ClickException("Inspect without --shift requires exactly one filename.")
|
||||
|
||||
ctx.obj['command'] = 'inspect'
|
||||
ctx.obj['arguments'] = {}
|
||||
ctx.obj['arguments']['filename'] = filenames[0]
|
||||
|
||||
runTuiApp(ctx)
|
||||
|
||||
|
||||
@ffx.command()
|
||||
@click.pass_context
|
||||
@click.argument('filename', nargs=1)
|
||||
def inspect(ctx, filename):
|
||||
from ffx.ffx_app import FfxApp
|
||||
def edit(ctx, filename):
|
||||
if not os.path.isfile(filename):
|
||||
raise click.ClickException(f"File not found: {filename}")
|
||||
|
||||
ctx.obj['command'] = 'inspect'
|
||||
ctx.obj['arguments'] = {}
|
||||
ctx.obj['arguments']['filename'] = filename
|
||||
ctx.obj['command'] = 'edit'
|
||||
ctx.obj['arguments'] = {'filename': filename}
|
||||
ctx.obj['use_pattern'] = False
|
||||
ctx.obj['no_signature'] = True
|
||||
ctx.obj['apply_metadata_cleanup'] = True
|
||||
ctx.obj['apply_metadata_normalization'] = True
|
||||
ctx.obj['resource_limits'] = ctx.obj.get('resource_limits', {})
|
||||
|
||||
app = FfxApp(ctx.obj)
|
||||
app.run()
|
||||
runTuiApp(ctx)
|
||||
|
||||
|
||||
@ffx.command()
|
||||
@@ -526,29 +678,33 @@ def rename(ctx, paths, prefix, season, suffix, dry_run):
|
||||
|
||||
|
||||
def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, targetDirectory = ''):
|
||||
from ffx.track_codec import TrackCodec
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
# executable and input file
|
||||
commandTokens = list(FFMPEG_COMMAND_TOKENS) + ['-i', sourcePath]
|
||||
|
||||
trackType = trackDescriptor.getType()
|
||||
trackCodec = trackDescriptor.getCodec()
|
||||
trackFormat = trackDescriptor.getFormatDescriptor()
|
||||
|
||||
targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix
|
||||
|
||||
# mapping
|
||||
commandTokens += ['-map',
|
||||
f"0:{trackType.indicator()}:{trackDescriptor.getSubIndex()}",
|
||||
'-c',
|
||||
'copy']
|
||||
commandTokens += ['-map', f"0:{trackType.indicator()}:{trackDescriptor.getSubIndex()}"]
|
||||
|
||||
trackCodec = trackDescriptor.getCodec()
|
||||
if trackType == TrackType.VIDEO and trackCodec == TrackCodec.H265:
|
||||
commandTokens += ['-c:v', 'copy', '-bsf:v', 'hevc_mp4toannexb']
|
||||
else:
|
||||
commandTokens += ['-c', 'copy']
|
||||
|
||||
# output format
|
||||
codecFormat = trackCodec.format()
|
||||
codecFormat = trackFormat.format()
|
||||
if codecFormat is not None:
|
||||
commandTokens += ['-f', codecFormat]
|
||||
|
||||
# output filename
|
||||
commandTokens += [f"{targetPathBase}.{trackCodec.extension()}"]
|
||||
commandTokens += [f"{targetPathBase}.{trackFormat.extension()}"]
|
||||
|
||||
return commandTokens
|
||||
|
||||
@@ -585,6 +741,7 @@ def unmux(ctx,
|
||||
cpu):
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.process import executeProcess
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
@@ -605,6 +762,8 @@ def unmux(ctx,
|
||||
if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False):
|
||||
os.makedirs(output_directory, exist_ok=True)
|
||||
|
||||
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
fp = FileProperties(ctx.obj, sourcePath)
|
||||
@@ -621,8 +780,12 @@ def unmux(ctx,
|
||||
currentShowDescriptor,
|
||||
)
|
||||
|
||||
season = fp.getSeason()
|
||||
episode = fp.getEpisode()
|
||||
season, episode = shiftedSeasonController.shiftSeason(
|
||||
fp.getShowId(),
|
||||
season=fp.getSeason(),
|
||||
episode=fp.getEpisode(),
|
||||
patternId=currentPattern.getId() if currentPattern is not None else None,
|
||||
)
|
||||
|
||||
#TODO: Recognition für alle Formate anpassen
|
||||
targetLabel = label if label else fp.getFileBasename()
|
||||
@@ -656,7 +819,7 @@ def unmux(ctx,
|
||||
if not ctx.obj['dry_run']:
|
||||
|
||||
#TODO #425: Codec Enum
|
||||
ctx.obj['logger'].info(f"Unmuxing stream {trackDescriptor.getIndex()} into file {targetPrefix}.{trackDescriptor.getCodec().extension()}")
|
||||
ctx.obj['logger'].info(f"Unmuxing stream {trackDescriptor.getIndex()} into file {targetPrefix}.{trackDescriptor.getFormatDescriptor().extension()}")
|
||||
|
||||
ctx.obj['logger'].debug(f"Executing unmuxing sequence")
|
||||
|
||||
@@ -741,15 +904,12 @@ def cropdetect(ctx,
|
||||
@click.pass_context
|
||||
|
||||
def shows(ctx):
|
||||
from ffx.ffx_app import FfxApp
|
||||
|
||||
ctx.obj['command'] = 'shows'
|
||||
|
||||
app = FfxApp(ctx.obj)
|
||||
app.run()
|
||||
runTuiApp(ctx)
|
||||
|
||||
|
||||
def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
from ffx.i18n import t
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
@@ -759,38 +919,38 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
# The correct tokens should then be created by
|
||||
if len([v for v in mediaDescriptor.getVideoTracks() if v.getDispositionFlag(TrackDisposition.DEFAULT)]) > 1:
|
||||
if context['no_prompt']:
|
||||
raise click.ClickException('More than one default video stream detected and no prompt set')
|
||||
defaultVideoTrackSubIndex = click.prompt("More than one default video stream detected! Please select stream", type=int)
|
||||
raise click.ClickException(t('More than one default video stream detected and no prompt set'))
|
||||
defaultVideoTrackSubIndex = click.prompt(t("More than one default video stream detected! Please select stream"), type=int)
|
||||
mediaDescriptor.setDefaultSubTrack(TrackType.VIDEO, defaultVideoTrackSubIndex)
|
||||
|
||||
if len([v for v in mediaDescriptor.getVideoTracks() if v.getDispositionFlag(TrackDisposition.FORCED)]) > 1:
|
||||
if context['no_prompt']:
|
||||
raise click.ClickException('More than one forced video stream detected and no prompt set')
|
||||
forcedVideoTrackSubIndex = click.prompt("More than one forced video stream detected! Please select stream", type=int)
|
||||
raise click.ClickException(t('More than one forced video stream detected and no prompt set'))
|
||||
forcedVideoTrackSubIndex = click.prompt(t("More than one forced video stream detected! Please select stream"), type=int)
|
||||
mediaDescriptor.setForcedSubTrack(TrackType.VIDEO, forcedVideoTrackSubIndex)
|
||||
|
||||
if len([a for a in mediaDescriptor.getAudioTracks() if a.getDispositionFlag(TrackDisposition.DEFAULT)]) > 1:
|
||||
if context['no_prompt']:
|
||||
raise click.ClickException('More than one default audio stream detected and no prompt set')
|
||||
defaultAudioTrackSubIndex = click.prompt("More than one default audio stream detected! Please select stream", type=int)
|
||||
raise click.ClickException(t('More than one default audio stream detected and no prompt set'))
|
||||
defaultAudioTrackSubIndex = click.prompt(t("More than one default audio stream detected! Please select stream"), type=int)
|
||||
mediaDescriptor.setDefaultSubTrack(TrackType.AUDIO, defaultAudioTrackSubIndex)
|
||||
|
||||
if len([a for a in mediaDescriptor.getAudioTracks() if a.getDispositionFlag(TrackDisposition.FORCED)]) > 1:
|
||||
if context['no_prompt']:
|
||||
raise click.ClickException('More than one forced audio stream detected and no prompt set')
|
||||
forcedAudioTrackSubIndex = click.prompt("More than one forced audio stream detected! Please select stream", type=int)
|
||||
raise click.ClickException(t('More than one forced audio stream detected and no prompt set'))
|
||||
forcedAudioTrackSubIndex = click.prompt(t("More than one forced audio stream detected! Please select stream"), type=int)
|
||||
mediaDescriptor.setForcedSubTrack(TrackType.AUDIO, forcedAudioTrackSubIndex)
|
||||
|
||||
if len([s for s in mediaDescriptor.getSubtitleTracks() if s.getDispositionFlag(TrackDisposition.DEFAULT)]) > 1:
|
||||
if context['no_prompt']:
|
||||
raise click.ClickException('More than one default subtitle stream detected and no prompt set')
|
||||
defaultSubtitleTrackSubIndex = click.prompt("More than one default subtitle stream detected! Please select stream", type=int)
|
||||
raise click.ClickException(t('More than one default subtitle stream detected and no prompt set'))
|
||||
defaultSubtitleTrackSubIndex = click.prompt(t("More than one default subtitle stream detected! Please select stream"), type=int)
|
||||
mediaDescriptor.setDefaultSubTrack(TrackType.SUBTITLE, defaultSubtitleTrackSubIndex)
|
||||
|
||||
if len([s for s in mediaDescriptor.getSubtitleTracks() if s.getDispositionFlag(TrackDisposition.FORCED)]) > 1:
|
||||
if context['no_prompt']:
|
||||
raise click.ClickException('More than one forced subtitle stream detected and no prompt set')
|
||||
forcedSubtitleTrackSubIndex = click.prompt("More than one forced subtitle stream detected! Please select stream", type=int)
|
||||
raise click.ClickException(t('More than one forced subtitle stream detected and no prompt set'))
|
||||
forcedSubtitleTrackSubIndex = click.prompt(t("More than one forced subtitle stream detected! Please select stream"), type=int)
|
||||
mediaDescriptor.setForcedSubTrack(TrackType.SUBTITLE, forcedSubtitleTrackSubIndex)
|
||||
|
||||
|
||||
@@ -802,6 +962,8 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
|
||||
|
||||
@click.option('-v', '--video-encoder', type=str, default=DEFAULT_VIDEO_ENCODER_LABEL, help=f"Target video encoder (vp9, av1, h264 or copy)", show_default=True)
|
||||
@click.option('--copy-video', is_flag=True, default=False, help=COPY_VIDEO_OPTION_HELP)
|
||||
@click.option('--copy-audio', is_flag=True, default=False, help=COPY_AUDIO_OPTION_HELP)
|
||||
|
||||
@click.option('-q', '--quality', type=str, default="", help=f"Quality settings to be used with VP9/H264 encoder")
|
||||
@click.option('-p', '--preset', type=str, default="", help=f"Quality preset to be used with AV1 encoder")
|
||||
@@ -846,7 +1008,6 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
metavar="DURATION|START,DURATION",
|
||||
is_flag=False,
|
||||
flag_value=DEFAULT_CUT_OPTION_VALUE,
|
||||
default=None,
|
||||
callback=normalizeCutOption,
|
||||
help=CUT_OPTION_HELP,
|
||||
)
|
||||
@@ -899,6 +1060,8 @@ def convert(ctx,
|
||||
paths,
|
||||
label,
|
||||
video_encoder,
|
||||
copy_video,
|
||||
copy_audio,
|
||||
quality,
|
||||
preset,
|
||||
stereo_bitrate,
|
||||
@@ -958,6 +1121,11 @@ def convert(ctx,
|
||||
Suffices will we appended to filename in case of multiple created files
|
||||
or if the filename has not changed."""
|
||||
from ffx.ffx_controller import FfxController
|
||||
from ffx.diagnostics import (
|
||||
FfmpegSkipFileWarning,
|
||||
getUnremediedIssues,
|
||||
iterUnremediedIssueSummaryLines,
|
||||
)
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.filter.crop_filter import CropFilter
|
||||
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
||||
@@ -966,10 +1134,12 @@ def convert(ctx,
|
||||
from ffx.filter.quality_filter import QualityFilter
|
||||
from ffx.helper import filterFilename, getEpisodeFileBasename, substituteTmdbFilename
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.show_controller import ShowController
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
from ffx.tmdb_controller import TmdbController
|
||||
from ffx.track_codec import TrackCodec
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_type import TrackType
|
||||
from ffx.video_encoder import VideoEncoder
|
||||
|
||||
startTime = time.perf_counter()
|
||||
@@ -977,9 +1147,12 @@ def convert(ctx,
|
||||
context = ctx.obj
|
||||
|
||||
context['video_encoder'] = VideoEncoder.fromLabel(video_encoder)
|
||||
context['copy_video'] = copy_video
|
||||
context['copy_audio'] = copy_audio
|
||||
copyVideoEffective = copy_video or context['video_encoder'] == VideoEncoder.COPY
|
||||
|
||||
# HINT: quick and dirty override for h264, todo improve
|
||||
if context['video_encoder'] in (VideoEncoder.H264, VideoEncoder.COPY):
|
||||
if context['video_encoder'] in (VideoEncoder.H264, VideoEncoder.COPY) or copy_video or copy_audio:
|
||||
targetFormat = ''
|
||||
targetExtension = 'mkv'
|
||||
else:
|
||||
@@ -1112,36 +1285,54 @@ def convert(ctx,
|
||||
tc = TmdbController() if context['use_tmdb'] else None
|
||||
|
||||
|
||||
qualityKwargs = {QualityFilter.QUALITY_KEY: str(quality)}
|
||||
if copyVideoEffective and quality:
|
||||
ctx.obj['logger'].warning("Ignoring quality settings because video is being copied")
|
||||
|
||||
qualityKwargs = {
|
||||
QualityFilter.QUALITY_KEY: "" if copyVideoEffective else str(quality)
|
||||
}
|
||||
qf = QualityFilter(**qualityKwargs)
|
||||
|
||||
|
||||
|
||||
if context['video_encoder'] == VideoEncoder.AV1 and preset:
|
||||
if context['video_encoder'] == VideoEncoder.AV1 and preset and not copyVideoEffective:
|
||||
presetKwargs = {PresetFilter.PRESET_KEY: preset}
|
||||
PresetFilter(**presetKwargs)
|
||||
|
||||
cf = None
|
||||
# if crop != 'none':
|
||||
if crop == 'auto':
|
||||
videoFilterOptionsRequested = (
|
||||
crop != 'none'
|
||||
or deinterlace != 'none'
|
||||
or denoise != 'none'
|
||||
or denoise_strength
|
||||
or denoise_patch_size
|
||||
or denoise_chroma_patch_size
|
||||
or denoise_research_window
|
||||
or denoise_chroma_research_window
|
||||
)
|
||||
if copyVideoEffective and videoFilterOptionsRequested:
|
||||
ctx.obj['logger'].warning("Ignoring video filter options because video is being copied")
|
||||
|
||||
if crop == 'auto' and not copyVideoEffective:
|
||||
cropKwargs = {}
|
||||
cf = CropFilter(**cropKwargs)
|
||||
|
||||
denoiseKwargs = {}
|
||||
if denoise_strength:
|
||||
if denoise_strength and not copyVideoEffective:
|
||||
denoiseKwargs[NlmeansFilter.STRENGTH_KEY] = denoise_strength
|
||||
if denoise_patch_size:
|
||||
if denoise_patch_size and not copyVideoEffective:
|
||||
denoiseKwargs[NlmeansFilter.PATCH_SIZE_KEY] = denoise_patch_size
|
||||
if denoise_chroma_patch_size:
|
||||
if denoise_chroma_patch_size and not copyVideoEffective:
|
||||
denoiseKwargs[NlmeansFilter.CHROMA_PATCH_SIZE_KEY] = denoise_chroma_patch_size
|
||||
if denoise_research_window:
|
||||
if denoise_research_window and not copyVideoEffective:
|
||||
denoiseKwargs[NlmeansFilter.RESEARCH_WINDOW_KEY] = denoise_research_window
|
||||
if denoise_chroma_research_window:
|
||||
if denoise_chroma_research_window and not copyVideoEffective:
|
||||
denoiseKwargs[NlmeansFilter.CHROMA_RESEARCH_WINDOW_KEY] = denoise_chroma_research_window
|
||||
if denoise != 'none' or denoiseKwargs:
|
||||
if not copyVideoEffective and (denoise != 'none' or denoiseKwargs):
|
||||
NlmeansFilter(**denoiseKwargs)
|
||||
|
||||
if deinterlace != 'none':
|
||||
if deinterlace != 'none' and not copyVideoEffective:
|
||||
DeinterlaceFilter()
|
||||
|
||||
chainYield = list(qf.getChainYield())
|
||||
@@ -1149,6 +1340,7 @@ def convert(ctx,
|
||||
ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs")
|
||||
|
||||
jobIndex = 0
|
||||
showController = ShowController(context)
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
@@ -1181,7 +1373,7 @@ def convert(ctx,
|
||||
|
||||
ssc = ShiftedSeasonController(context)
|
||||
|
||||
showId = mediaFileProperties.getShowId()
|
||||
matchedShowId = mediaFileProperties.getShowId()
|
||||
|
||||
#HINT: -1 if not set
|
||||
if 'tmdb' in cliOverrides.keys() and 'season' in cliOverrides['tmdb']:
|
||||
@@ -1200,13 +1392,31 @@ def convert(ctx,
|
||||
sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor()
|
||||
|
||||
|
||||
if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks()
|
||||
if smd.getCodec() == TrackCodec.ASS]
|
||||
and [amd for amd in sourceMediaDescriptor.getAttachmentTracks()
|
||||
if amd.getCodec() == TrackCodec.TTF]):
|
||||
from ffx.attachment_format import AttachmentFormat
|
||||
|
||||
styledAssDetector = getattr(
|
||||
sourceMediaDescriptor,
|
||||
"hasStyledAssSubtitlesWithFontAttachments",
|
||||
None,
|
||||
)
|
||||
styledAssSourceDetected = (
|
||||
bool(styledAssDetector())
|
||||
if callable(styledAssDetector)
|
||||
else False
|
||||
)
|
||||
if styledAssSourceDetected:
|
||||
styledAssMessage = (
|
||||
"Styled ASS subtitles with embedded font attachments detected; "
|
||||
+ "preserving source font attachments."
|
||||
)
|
||||
click.echo(styledAssMessage)
|
||||
targetFormat = ''
|
||||
targetExtension = 'mkv'
|
||||
if context['import_subtitles']:
|
||||
raise click.ClickException(
|
||||
"External subtitle import is incompatible with styled ASS "
|
||||
+ "sources that carry embedded font attachments."
|
||||
)
|
||||
|
||||
|
||||
#HINT: This is None if the filename did not match anything in database
|
||||
@@ -1233,6 +1443,12 @@ def convert(ctx,
|
||||
|
||||
else:
|
||||
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
|
||||
if styledAssSourceDetected:
|
||||
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
|
||||
sourceMediaDescriptor,
|
||||
AttachmentFormat.TTF,
|
||||
context=ctx.obj,
|
||||
)
|
||||
checkUniqueDispositions(context, targetMediaDescriptor)
|
||||
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
|
||||
|
||||
@@ -1242,6 +1458,8 @@ def convert(ctx,
|
||||
targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors()
|
||||
|
||||
for ttd in targetTrackDescriptorList:
|
||||
if ttd.getType() == TrackType.ATTACHMENT:
|
||||
continue
|
||||
|
||||
tti = ttd.getIndex()
|
||||
ttsi = ttd.getSourceIndex()
|
||||
@@ -1263,7 +1481,8 @@ def convert(ctx,
|
||||
targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
||||
context['subtitle_prefix'],
|
||||
showSeason,
|
||||
showEpisode)
|
||||
showEpisode,
|
||||
preserve_dispositions=True)
|
||||
|
||||
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
||||
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
||||
@@ -1278,6 +1497,14 @@ def convert(ctx,
|
||||
|
||||
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
|
||||
|
||||
qualityShowId = (
|
||||
cliOverrides['tmdb']['show']
|
||||
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||
else matchedShowId
|
||||
)
|
||||
if currentShowDescriptor is None and qualityShowId != -1:
|
||||
currentShowDescriptor = showController.getShowDescriptor(qualityShowId)
|
||||
|
||||
|
||||
defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context)
|
||||
indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
|
||||
@@ -1286,19 +1513,43 @@ def convert(ctx,
|
||||
indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||
|
||||
|
||||
# Shift season and episode if defined for this show
|
||||
if ('tmdb' not in cliOverrides.keys() and showId != -1
|
||||
and showSeason != -1 and showEpisode != -1):
|
||||
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(showId,
|
||||
showIdForShift = (
|
||||
cliOverrides['tmdb']['show']
|
||||
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||
else matchedShowId
|
||||
)
|
||||
patternIdForShift = currentPattern.getId() if currentPattern is not None else None
|
||||
hasExplicitTargetSeasonOrEpisode = (
|
||||
'tmdb' in cliOverrides.keys()
|
||||
and (
|
||||
'season' in cliOverrides['tmdb']
|
||||
or 'episode' in cliOverrides['tmdb']
|
||||
)
|
||||
)
|
||||
|
||||
# Shift season and episode if defined for the matched pattern or show
|
||||
if (
|
||||
not hasExplicitTargetSeasonOrEpisode
|
||||
and showSeason != -1
|
||||
and showEpisode != -1
|
||||
):
|
||||
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(
|
||||
showIdForShift,
|
||||
season=showSeason,
|
||||
episode=showEpisode)
|
||||
episode=showEpisode,
|
||||
patternId=patternIdForShift,
|
||||
)
|
||||
else:
|
||||
shiftedShowSeason = showSeason
|
||||
shiftedShowEpisode = showEpisode
|
||||
|
||||
# Assemble target filename accordingly depending on TMDB lookup is enabled
|
||||
#HINT: -1 if not set
|
||||
showId = cliOverrides['tmdb']['show'] if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb'] else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
|
||||
showId = (
|
||||
cliOverrides['tmdb']['show']
|
||||
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||
else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
|
||||
)
|
||||
|
||||
if context['use_tmdb'] and showId != -1 and shiftedShowSeason != -1 and shiftedShowEpisode != -1:
|
||||
|
||||
@@ -1379,17 +1630,30 @@ def convert(ctx,
|
||||
if rename_only:
|
||||
shutil.move(sourcePath, targetPath)
|
||||
else:
|
||||
try:
|
||||
fc.runJob(sourcePath,
|
||||
targetPath,
|
||||
targetFormat,
|
||||
chainIteration,
|
||||
cropArguments,
|
||||
currentPattern)
|
||||
currentPattern,
|
||||
currentShowDescriptor)
|
||||
except FfmpegSkipFileWarning:
|
||||
if os.path.exists(targetPath):
|
||||
os.remove(targetPath)
|
||||
continue
|
||||
|
||||
|
||||
|
||||
endTime = time.perf_counter()
|
||||
ctx.obj['logger'].info(f"\nDONE\nTime elapsed {endTime - startTime}")
|
||||
unremediedIssues = getUnremediedIssues(context)
|
||||
if unremediedIssues:
|
||||
ctx.obj['logger'].warning("\nFiles with ffmpeg findings that require review:")
|
||||
for summaryLine in iterUnremediedIssueSummaryLines(context):
|
||||
ctx.obj['logger'].warning(summaryLine)
|
||||
else:
|
||||
ctx.obj['logger'].info("All files converted with no issues.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -16,6 +16,7 @@ class ConfigurationController():
|
||||
DATABASE_PATH_CONFIG_KEY = 'databasePath'
|
||||
LOG_DIRECTORY_CONFIG_KEY = 'logDirectory'
|
||||
SUBTITLES_DIRECTORY_CONFIG_KEY = 'subtitlesDirectory'
|
||||
LANGUAGE_CONFIG_KEY = 'language'
|
||||
OUTPUT_FILENAME_TEMPLATE_KEY = 'outputFilenameTemplate'
|
||||
DEFAULT_INDEX_SEASON_DIGITS_CONFIG_KEY = 'defaultIndexSeasonDigits'
|
||||
DEFAULT_INDEX_EPISODE_DIGITS_CONFIG_KEY = 'defaultIndexEpisodeDigits'
|
||||
@@ -68,6 +69,9 @@ class ConfigurationController():
|
||||
)
|
||||
return os.path.expanduser(str(subtitlesDirectory)) if subtitlesDirectory else ''
|
||||
|
||||
def getLanguage(self):
|
||||
return str(self.__configurationData.get(ConfigurationController.LANGUAGE_CONFIG_KEY, '')).strip()
|
||||
|
||||
@classmethod
|
||||
def getConfiguredIntegerValue(cls, configurationData: dict, configKey: str, defaultValue: int) -> int:
|
||||
configuredValue = configurationData.get(configKey, defaultValue)
|
||||
|
||||
80
src/ffx/confirm_screen.py
Normal file
80
src/ffx/confirm_screen.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from textual.containers import Grid
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Button, Footer, Header, Static
|
||||
|
||||
from .i18n import t
|
||||
from .screen_support import build_screen_log_pane
|
||||
|
||||
class ConfirmScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 4 7;
|
||||
grid-rows: 2 2 2 2 2 2 2;
|
||||
grid-columns: 1fr 1fr 1fr 1fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 80;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Button {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.four {
|
||||
column-span: 4;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
confirm_label: str = "Confirm",
|
||||
cancel_label: str = "Cancel",
|
||||
):
|
||||
super().__init__()
|
||||
self.__message = str(message)
|
||||
self.__confirmLabel = str(t(confirm_label))
|
||||
self.__cancelLabel = str(t(cancel_label))
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
# Row 1
|
||||
yield Static(self.__message, classes="four")
|
||||
|
||||
# Row 2
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
# Row 3
|
||||
yield Button(self.__confirmLabel, id="confirm_button")
|
||||
yield Button(self.__cancelLabel, id="cancel_button")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "confirm_button":
|
||||
self.dismiss(True)
|
||||
|
||||
if event.button.id == "cancel_button":
|
||||
self.dismiss(False)
|
||||
|
||||
def action_back(self):
|
||||
self.dismiss(False)
|
||||
@@ -1,5 +1,5 @@
|
||||
VERSION='0.2.4'
|
||||
DATABASE_VERSION = 2
|
||||
VERSION='0.4.3'
|
||||
DATABASE_VERSION = 3
|
||||
|
||||
DEFAULT_QUALITY = 32
|
||||
DEFAULT_AV1_PRESET = 5
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os, click
|
||||
import os, shutil, click
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Import the full model package so SQLAlchemy registers every mapped class
|
||||
@@ -9,6 +9,11 @@ import ffx.model
|
||||
from ffx.model.show import Base
|
||||
|
||||
from ffx.model.property import Property
|
||||
from ffx.model.migration import (
|
||||
DatabaseVersionException,
|
||||
getMigrationPlan,
|
||||
migrateDatabase,
|
||||
)
|
||||
|
||||
from ffx.constants import DATABASE_VERSION
|
||||
|
||||
@@ -16,10 +21,6 @@ from ffx.constants import DATABASE_VERSION
|
||||
DATABASE_VERSION_KEY = 'database_version'
|
||||
EXPECTED_TABLE_NAMES = set(Base.metadata.tables.keys())
|
||||
|
||||
class DatabaseVersionException(Exception):
|
||||
def __init__(self, errorMessage):
|
||||
super().__init__(errorMessage)
|
||||
|
||||
def databaseContext(databasePath: str = ''):
|
||||
|
||||
databaseContext = {}
|
||||
@@ -33,7 +34,13 @@ def databaseContext(databasePath: str = ''):
|
||||
if not os.path.exists(ffxVarDir):
|
||||
os.makedirs(ffxVarDir)
|
||||
databasePath = os.path.join(ffxVarDir, 'ffx.db')
|
||||
else:
|
||||
databasePath = os.path.expanduser(databasePath)
|
||||
|
||||
if databasePath != ':memory:':
|
||||
databasePath = os.path.abspath(databasePath)
|
||||
|
||||
databaseContext['path'] = databasePath
|
||||
databaseContext['url'] = f"sqlite:///{databasePath}"
|
||||
databaseContext['engine'] = create_engine(databaseContext['url'])
|
||||
databaseContext['session'] = sessionmaker(bind=databaseContext['engine'])
|
||||
@@ -68,14 +75,113 @@ def bootstrapDatabaseIfNeeded(databaseContext):
|
||||
|
||||
Base.metadata.create_all(databaseContext['engine'])
|
||||
|
||||
|
||||
def ensureDatabaseVersion(databaseContext):
|
||||
|
||||
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
||||
if currentDatabaseVersion:
|
||||
if currentDatabaseVersion != DATABASE_VERSION:
|
||||
raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})")
|
||||
else:
|
||||
if not currentDatabaseVersion:
|
||||
setDatabaseVersion(databaseContext, DATABASE_VERSION)
|
||||
return
|
||||
|
||||
if currentDatabaseVersion > DATABASE_VERSION:
|
||||
raise DatabaseVersionException(
|
||||
f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})"
|
||||
)
|
||||
|
||||
if currentDatabaseVersion < DATABASE_VERSION:
|
||||
promptForDatabaseMigration(databaseContext, currentDatabaseVersion, DATABASE_VERSION)
|
||||
migrateDatabase(databaseContext, currentDatabaseVersion, DATABASE_VERSION, setDatabaseVersion)
|
||||
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
||||
|
||||
if currentDatabaseVersion != DATABASE_VERSION:
|
||||
raise DatabaseVersionException(
|
||||
f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})"
|
||||
)
|
||||
|
||||
ensureCurrentSchemaCompatibility(databaseContext)
|
||||
|
||||
|
||||
def ensureCurrentSchemaCompatibility(databaseContext):
|
||||
engine = databaseContext['engine']
|
||||
inspector = inspect(engine)
|
||||
showColumns = {
|
||||
column['name']
|
||||
for column in inspector.get_columns('shows')
|
||||
}
|
||||
|
||||
alterStatements = []
|
||||
if 'quality' not in showColumns:
|
||||
alterStatements.append("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
|
||||
if 'notes' not in showColumns:
|
||||
alterStatements.append("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
|
||||
|
||||
if not alterStatements:
|
||||
return
|
||||
|
||||
with engine.begin() as connection:
|
||||
for alterStatement in alterStatements:
|
||||
connection.execute(text(alterStatement))
|
||||
|
||||
|
||||
def promptForDatabaseMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int):
|
||||
migrationPlan = getMigrationPlan(currentDatabaseVersion, targetDatabaseVersion)
|
||||
|
||||
click.echo("Database migration required.")
|
||||
click.echo(f"Current version: {currentDatabaseVersion}")
|
||||
click.echo(f"Target version: {targetDatabaseVersion}")
|
||||
click.echo("Steps required:")
|
||||
|
||||
missingSteps = []
|
||||
for migrationStep in migrationPlan:
|
||||
moduleStatus = "present" if migrationStep.modulePresent else "missing"
|
||||
click.echo(
|
||||
f" {migrationStep.versionFrom} -> {migrationStep.versionTo}: "
|
||||
+ f"{migrationStep.moduleName} [{moduleStatus}]"
|
||||
)
|
||||
if not migrationStep.modulePresent:
|
||||
missingSteps.append(migrationStep)
|
||||
|
||||
if missingSteps:
|
||||
firstMissingStep = missingSteps[0]
|
||||
raise DatabaseVersionException(
|
||||
f"No migration path from database version "
|
||||
+ f"{firstMissingStep.versionFrom} to {firstMissingStep.versionTo}"
|
||||
)
|
||||
|
||||
if not click.confirm(
|
||||
"Create a backup and continue with database migration?",
|
||||
default=True,
|
||||
):
|
||||
raise click.ClickException("Database migration aborted by user.")
|
||||
|
||||
backupPath = backupDatabaseBeforeMigration(
|
||||
databaseContext,
|
||||
currentDatabaseVersion,
|
||||
targetDatabaseVersion,
|
||||
)
|
||||
click.echo(f"Database backup created: {backupPath}")
|
||||
|
||||
|
||||
def backupDatabaseBeforeMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int) -> str:
|
||||
databasePath = databaseContext.get('path', '')
|
||||
if not databasePath or databasePath == ':memory:':
|
||||
raise click.ClickException("Database migration backup requires a file-backed SQLite database.")
|
||||
|
||||
if not os.path.isfile(databasePath):
|
||||
raise click.ClickException(f"Database file not found for backup: {databasePath}")
|
||||
|
||||
backupPath = f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.bak"
|
||||
backupIndex = 1
|
||||
while os.path.exists(backupPath):
|
||||
backupPath = (
|
||||
f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.{backupIndex}.bak"
|
||||
)
|
||||
backupIndex += 1
|
||||
|
||||
databaseContext['engine'].dispose()
|
||||
shutil.copy2(databasePath, backupPath)
|
||||
|
||||
return backupPath
|
||||
|
||||
|
||||
def getDatabaseVersion(databaseContext):
|
||||
|
||||
24
src/ffx/diagnostics/__init__.py
Normal file
24
src/ffx/diagnostics/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from .base import FfmpegRemedy, FfmpegRemedyDecision, FfmpegSkipFileWarning
|
||||
from .monitor import FfmpegCommandRunner, FfmpegDiagnosticMonitor
|
||||
from .retry_with_generated_pts import RetryWithGeneratedPtsRemedy
|
||||
from .state import (
|
||||
getDiagnosticsState,
|
||||
getUnremediedIssues,
|
||||
iterUnremediedIssueSummaryLines,
|
||||
recordUnremediedIssue,
|
||||
)
|
||||
from .warn_corrupt_mpeg_audio import WarnCorruptMpegAudioRemedy
|
||||
|
||||
__all__ = [
|
||||
"FfmpegCommandRunner",
|
||||
"FfmpegDiagnosticMonitor",
|
||||
"FfmpegRemedy",
|
||||
"FfmpegRemedyDecision",
|
||||
"FfmpegSkipFileWarning",
|
||||
"RetryWithGeneratedPtsRemedy",
|
||||
"WarnCorruptMpegAudioRemedy",
|
||||
"getDiagnosticsState",
|
||||
"getUnremediedIssues",
|
||||
"iterUnremediedIssueSummaryLines",
|
||||
"recordUnremediedIssue",
|
||||
]
|
||||
33
src/ffx/diagnostics/base.py
Normal file
33
src/ffx/diagnostics/base.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class FfmpegSkipFileWarning(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FfmpegRemedyDecision:
|
||||
stop_process: bool = False
|
||||
retry_input_tokens: tuple[str, ...] = ()
|
||||
skip_file: bool = False
|
||||
console_warning: str = ""
|
||||
summary_identifier: str = ""
|
||||
unremedied_issue_identifier: str = ""
|
||||
|
||||
@property
|
||||
def retry_requested(self) -> bool:
|
||||
return bool(self.retry_input_tokens)
|
||||
|
||||
|
||||
class FfmpegRemedy:
|
||||
identifier = "ffmpeg-remedy"
|
||||
harmless = False
|
||||
|
||||
def inspect_line(
|
||||
self,
|
||||
line: str,
|
||||
session: "FfmpegDiagnosticMonitor",
|
||||
) -> FfmpegRemedyDecision | None:
|
||||
raise NotImplementedError
|
||||
222
src/ffx/diagnostics/monitor.py
Normal file
222
src/ffx/diagnostics/monitor.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from ffx.logging_utils import get_ffx_logger
|
||||
from ffx.process import executeProcess
|
||||
|
||||
from .base import FfmpegSkipFileWarning, FfmpegRemedy
|
||||
from .retry_with_generated_pts import RetryWithGeneratedPtsRemedy
|
||||
from .state import recordUnremediedIssue
|
||||
from .warn_corrupt_mpeg_audio import WarnCorruptMpegAudioRemedy
|
||||
|
||||
UNHANDLED_DIAGNOSTIC_PATTERNS = (
|
||||
re.compile(r"\bwarning\b", re.IGNORECASE),
|
||||
re.compile(r"\berror\b", re.IGNORECASE),
|
||||
re.compile(r"\bfailed\b", re.IGNORECASE),
|
||||
re.compile(r"\binvalid\b", re.IGNORECASE),
|
||||
re.compile(r"\bmissing\b", re.IGNORECASE),
|
||||
re.compile(r"\bcorrupt\b", re.IGNORECASE),
|
||||
re.compile(r"\boverflow\b", re.IGNORECASE),
|
||||
re.compile(r"\bdeprecated\b", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
class FfmpegDiagnosticMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
context: dict | None,
|
||||
command_sequence: list[str],
|
||||
*,
|
||||
remedies: list[FfmpegRemedy] | None = None,
|
||||
emittedWarnings: set[str] | None = None,
|
||||
):
|
||||
self.context = context or {}
|
||||
self.command_sequence = list(command_sequence)
|
||||
self.logger = self.context.get("logger", get_ffx_logger())
|
||||
self.source_path = str(self.context.get("current_source_path", "")).strip()
|
||||
self.remedies = remedies or [
|
||||
RetryWithGeneratedPtsRemedy(),
|
||||
WarnCorruptMpegAudioRemedy(),
|
||||
]
|
||||
self._emittedWarnings = emittedWarnings if emittedWarnings is not None else set()
|
||||
self.retry_input_tokens: tuple[str, ...] = ()
|
||||
self.skip_file = False
|
||||
self.skip_file_message = ""
|
||||
|
||||
def describe_source(self) -> str:
|
||||
return self.source_path if self.source_path else "current file"
|
||||
|
||||
def command_contains_tokens(self, tokens: tuple[str, ...]) -> bool:
|
||||
tokenCount = len(tokens)
|
||||
if tokenCount == 0:
|
||||
return True
|
||||
|
||||
return any(
|
||||
tuple(self.command_sequence[index:index + tokenCount]) == tuple(tokens)
|
||||
for index in range(len(self.command_sequence) - tokenCount + 1)
|
||||
)
|
||||
|
||||
def emitConsoleWarning(self, warningMessage: str) -> None:
|
||||
if warningMessage and warningMessage not in self._emittedWarnings:
|
||||
self.logger.warning(warningMessage)
|
||||
self._emittedWarnings.add(warningMessage)
|
||||
|
||||
def recordUnremediedIssue(self, issueIdentifier: str, issueLine: str) -> None:
|
||||
isFirstIssueForFile = recordUnremediedIssue(
|
||||
self.context,
|
||||
self.describe_source(),
|
||||
issueIdentifier,
|
||||
)
|
||||
if not isFirstIssueForFile:
|
||||
return
|
||||
|
||||
self.emitConsoleWarning(
|
||||
f"ffmpeg reported a diagnostic with no automatic remedy while converting "
|
||||
+ f"{self.describe_source()}. FFX will continue, but review the output "
|
||||
+ f"file. First unhandled line: {issueLine}"
|
||||
)
|
||||
|
||||
def lineLooksLikeUnhandledDiagnostic(self, line: str) -> bool:
|
||||
return any(pattern.search(line) for pattern in UNHANDLED_DIAGNOSTIC_PATTERNS)
|
||||
|
||||
def getUnhandledDiagnosticIdentifier(self, line: str) -> str:
|
||||
loweredLine = str(line).lower()
|
||||
|
||||
if any(token in loweredLine for token in ("error", "failed", "invalid", "missing", "corrupt", "overflow")):
|
||||
return "unhandled-error"
|
||||
if any(token in loweredLine for token in ("warning", "deprecated")):
|
||||
return "unhandled-warning"
|
||||
return "unhandled-diagnostic"
|
||||
|
||||
def getSummaryIdentifier(
|
||||
self,
|
||||
remedy: FfmpegRemedy,
|
||||
decision,
|
||||
) -> str:
|
||||
explicitIdentifier = str(decision.summary_identifier).strip()
|
||||
if explicitIdentifier:
|
||||
return explicitIdentifier
|
||||
|
||||
remedyIdentifier = str(getattr(remedy, "identifier", "")).strip()
|
||||
if remedyIdentifier and remedyIdentifier != FfmpegRemedy.identifier:
|
||||
return remedyIdentifier
|
||||
|
||||
return str(decision.unremedied_issue_identifier).strip()
|
||||
|
||||
def shouldRecordSummary(
|
||||
self,
|
||||
remedy: FfmpegRemedy,
|
||||
decision,
|
||||
) -> bool:
|
||||
if getattr(remedy, "harmless", False):
|
||||
return False
|
||||
|
||||
if decision.retry_requested and not decision.skip_file:
|
||||
return False
|
||||
|
||||
return bool(self.getSummaryIdentifier(remedy, decision))
|
||||
|
||||
def handle_stderr_line(self, line: str) -> bool:
|
||||
strippedLine = str(line).strip()
|
||||
if not strippedLine:
|
||||
return False
|
||||
|
||||
for remedy in self.remedies:
|
||||
decision = remedy.inspect_line(strippedLine, self)
|
||||
if decision is None:
|
||||
continue
|
||||
|
||||
self.emitConsoleWarning(decision.console_warning)
|
||||
|
||||
if decision.retry_requested:
|
||||
self.retry_input_tokens = tuple(decision.retry_input_tokens)
|
||||
|
||||
if self.shouldRecordSummary(remedy, decision):
|
||||
recordUnremediedIssue(
|
||||
self.context,
|
||||
self.describe_source(),
|
||||
self.getSummaryIdentifier(remedy, decision),
|
||||
)
|
||||
|
||||
if decision.skip_file:
|
||||
self.skip_file = True
|
||||
self.skip_file_message = (
|
||||
decision.console_warning
|
||||
or f"Skipping file {self.describe_source()} because ffmpeg reported a fatal diagnostic."
|
||||
)
|
||||
|
||||
return bool(decision.stop_process)
|
||||
|
||||
if self.lineLooksLikeUnhandledDiagnostic(strippedLine):
|
||||
self.recordUnremediedIssue(
|
||||
self.getUnhandledDiagnosticIdentifier(strippedLine),
|
||||
strippedLine,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def retry_requested(self) -> bool:
|
||||
return bool(self.retry_input_tokens)
|
||||
|
||||
|
||||
def insertFfmpegInputOptions(
|
||||
commandSequence: list[str],
|
||||
extraTokens: tuple[str, ...],
|
||||
) -> list[str]:
|
||||
if not extraTokens:
|
||||
return list(commandSequence)
|
||||
|
||||
if not commandSequence:
|
||||
return list(extraTokens)
|
||||
|
||||
return [commandSequence[0]] + list(extraTokens) + list(commandSequence[1:])
|
||||
|
||||
|
||||
class FfmpegCommandRunner:
|
||||
def __init__(
|
||||
self,
|
||||
context: dict | None,
|
||||
*,
|
||||
remedies: list[FfmpegRemedy] | None = None,
|
||||
):
|
||||
self.__context = context or {}
|
||||
self.__remedies = remedies
|
||||
|
||||
def execute(
|
||||
self,
|
||||
commandSequence: list[str],
|
||||
*,
|
||||
directory: str = None,
|
||||
timeoutSeconds: float = None,
|
||||
):
|
||||
emittedWarnings: set[str] = set()
|
||||
attemptCommandSequence = list(commandSequence)
|
||||
|
||||
while True:
|
||||
monitor = FfmpegDiagnosticMonitor(
|
||||
self.__context,
|
||||
attemptCommandSequence,
|
||||
remedies=self.__remedies,
|
||||
emittedWarnings=emittedWarnings,
|
||||
)
|
||||
out, err, rc = executeProcess(
|
||||
attemptCommandSequence,
|
||||
directory=directory,
|
||||
context=self.__context,
|
||||
timeoutSeconds=timeoutSeconds,
|
||||
stderrLineHandler=monitor.handle_stderr_line,
|
||||
)
|
||||
|
||||
if monitor.retry_requested:
|
||||
attemptCommandSequence = insertFfmpegInputOptions(
|
||||
attemptCommandSequence,
|
||||
monitor.retry_input_tokens,
|
||||
)
|
||||
continue
|
||||
|
||||
if monitor.skip_file:
|
||||
raise FfmpegSkipFileWarning(monitor.skip_file_message)
|
||||
|
||||
return out, err, rc
|
||||
41
src/ffx/diagnostics/retry_with_generated_pts.py
Normal file
41
src/ffx/diagnostics/retry_with_generated_pts.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .base import FfmpegRemedy, FfmpegRemedyDecision
|
||||
|
||||
|
||||
class RetryWithGeneratedPtsRemedy(FfmpegRemedy):
|
||||
identifier = "retry-with-generated-pts"
|
||||
RETRY_INPUT_TOKENS = ("-fflags", "+genpts")
|
||||
TIMESTAMP_UNSET_PATTERN = re.compile(
|
||||
r"Timestamps are unset in a packet for stream \d+"
|
||||
)
|
||||
|
||||
def inspect_line(
|
||||
self,
|
||||
line: str,
|
||||
session: "FfmpegDiagnosticMonitor",
|
||||
) -> FfmpegRemedyDecision | None:
|
||||
if self.TIMESTAMP_UNSET_PATTERN.search(line) is None:
|
||||
return None
|
||||
|
||||
if session.command_contains_tokens(self.RETRY_INPUT_TOKENS):
|
||||
return FfmpegRemedyDecision(
|
||||
stop_process=True,
|
||||
skip_file=True,
|
||||
console_warning=(
|
||||
f"Skipping file {session.describe_source()}: ffmpeg still reported "
|
||||
+ "unset packet timestamps after retry with -fflags +genpts."
|
||||
),
|
||||
unremedied_issue_identifier="timestamp-unset-after-genpts",
|
||||
)
|
||||
|
||||
return FfmpegRemedyDecision(
|
||||
stop_process=True,
|
||||
retry_input_tokens=self.RETRY_INPUT_TOKENS,
|
||||
console_warning=(
|
||||
f"ffmpeg reported unset packet timestamps for {session.describe_source()}. "
|
||||
+ "Stopping early and retrying with -fflags +genpts."
|
||||
),
|
||||
)
|
||||
53
src/ffx/diagnostics/state.py
Normal file
53
src/ffx/diagnostics/state.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
DIAGNOSTICS_STATE_KEY = "diagnostics_state"
|
||||
UNREMEDIED_ISSUES_KEY = "unremedied_issues"
|
||||
|
||||
|
||||
def getDiagnosticsState(context: dict | None) -> dict:
|
||||
if context is None:
|
||||
return {UNREMEDIED_ISSUES_KEY: {}}
|
||||
|
||||
if DIAGNOSTICS_STATE_KEY not in context:
|
||||
context[DIAGNOSTICS_STATE_KEY] = {
|
||||
UNREMEDIED_ISSUES_KEY: {},
|
||||
}
|
||||
|
||||
return context[DIAGNOSTICS_STATE_KEY]
|
||||
|
||||
|
||||
def recordUnremediedIssue(
|
||||
context: dict | None,
|
||||
sourcePath: str,
|
||||
identifier: str,
|
||||
) -> bool:
|
||||
if not sourcePath:
|
||||
return False
|
||||
|
||||
diagnosticsState = getDiagnosticsState(context)
|
||||
unremediedIssues = diagnosticsState[UNREMEDIED_ISSUES_KEY]
|
||||
issueList = unremediedIssues.setdefault(sourcePath, [])
|
||||
strippedIdentifier = str(identifier).strip()
|
||||
|
||||
if not strippedIdentifier or strippedIdentifier in issueList:
|
||||
return False
|
||||
|
||||
issueList.append(strippedIdentifier)
|
||||
return True
|
||||
|
||||
|
||||
def getUnremediedIssues(context: dict | None) -> dict[str, list[str]]:
|
||||
diagnosticsState = getDiagnosticsState(context)
|
||||
return diagnosticsState.get(UNREMEDIED_ISSUES_KEY, {})
|
||||
|
||||
|
||||
def iterUnremediedIssueSummaryLines(context: dict | None) -> list[str]:
|
||||
summaryLines = []
|
||||
unremediedIssues = getUnremediedIssues(context)
|
||||
for sourcePath in sorted(unremediedIssues.keys()):
|
||||
identifiers = unremediedIssues[sourcePath]
|
||||
summaryLines.append(f"{os.path.basename(sourcePath)}: {', '.join(identifiers)}")
|
||||
return summaryLines
|
||||
35
src/ffx/diagnostics/warn_corrupt_mpeg_audio.py
Normal file
35
src/ffx/diagnostics/warn_corrupt_mpeg_audio.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .base import FfmpegRemedy, FfmpegRemedyDecision
|
||||
|
||||
|
||||
class WarnCorruptMpegAudioRemedy(FfmpegRemedy):
|
||||
identifier = "warn-corrupt-mpeg-audio"
|
||||
PATTERNS = (
|
||||
re.compile(r"\[mp3float @ .*\] invalid block type", re.IGNORECASE),
|
||||
re.compile(r"\[mp3float @ .*\] invalid new backstep -?\d+", re.IGNORECASE),
|
||||
re.compile(r"\[mp3float @ .*\] Header missing"),
|
||||
re.compile(r"\[mp3float @ .*\] overread, skip ", re.IGNORECASE),
|
||||
re.compile(r"Error while decoding MPEG audio frame\."),
|
||||
re.compile(
|
||||
r"Error submitting packet to decoder: Invalid data found when processing input"
|
||||
),
|
||||
)
|
||||
|
||||
def inspect_line(
|
||||
self,
|
||||
line: str,
|
||||
session: "FfmpegDiagnosticMonitor",
|
||||
) -> FfmpegRemedyDecision | None:
|
||||
if not any(pattern.search(line) for pattern in self.PATTERNS):
|
||||
return None
|
||||
|
||||
return FfmpegRemedyDecision(
|
||||
console_warning=(
|
||||
f"ffmpeg reported damaged MPEG audio frames while converting "
|
||||
+ f"{session.describe_source()}. FFX will continue, but the output "
|
||||
+ "audio may contain gaps or glitches."
|
||||
),
|
||||
)
|
||||
27
src/ffx/ffmpeg_diagnostics.py
Normal file
27
src/ffx/ffmpeg_diagnostics.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from .diagnostics import (
|
||||
FfmpegCommandRunner,
|
||||
FfmpegDiagnosticMonitor,
|
||||
FfmpegRemedy,
|
||||
FfmpegRemedyDecision,
|
||||
FfmpegSkipFileWarning,
|
||||
RetryWithGeneratedPtsRemedy,
|
||||
WarnCorruptMpegAudioRemedy,
|
||||
getDiagnosticsState,
|
||||
getUnremediedIssues,
|
||||
iterUnremediedIssueSummaryLines,
|
||||
recordUnremediedIssue,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FfmpegCommandRunner",
|
||||
"FfmpegDiagnosticMonitor",
|
||||
"FfmpegRemedy",
|
||||
"FfmpegRemedyDecision",
|
||||
"FfmpegSkipFileWarning",
|
||||
"RetryWithGeneratedPtsRemedy",
|
||||
"WarnCorruptMpegAudioRemedy",
|
||||
"getDiagnosticsState",
|
||||
"getUnremediedIssues",
|
||||
"iterUnremediedIssueSummaryLines",
|
||||
"recordUnremediedIssue",
|
||||
]
|
||||
@@ -1,7 +1,10 @@
|
||||
from textual.app import App
|
||||
|
||||
from .i18n import set_current_language, t
|
||||
from .shows_screen import ShowsScreen
|
||||
from .media_details_screen import MediaDetailsScreen
|
||||
from .inspect_details_screen import InspectDetailsScreen
|
||||
from .media_edit_screen import MediaEditScreen
|
||||
from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled
|
||||
|
||||
|
||||
class FfxApp(App):
|
||||
@@ -9,8 +12,8 @@ class FfxApp(App):
|
||||
TITLE = "FFX"
|
||||
|
||||
BINDINGS = [
|
||||
("q", "quit()", "Quit"),
|
||||
("h", "switch_mode('help')", "Help"),
|
||||
("q", "quit()", t("Quit")),
|
||||
("h", "switch_mode('help')", t("Help")),
|
||||
]
|
||||
|
||||
|
||||
@@ -19,6 +22,14 @@ class FfxApp(App):
|
||||
|
||||
# Data 'input' variable
|
||||
self.context = context
|
||||
set_current_language(self.context.get("language"))
|
||||
debug_mode = bool(self.context.get("debug", False))
|
||||
set_screen_log_pane_enabled(debug_mode)
|
||||
configure_screen_log_handler(
|
||||
self.context.get("logger"),
|
||||
self,
|
||||
enabled=debug_mode,
|
||||
)
|
||||
|
||||
|
||||
def on_mount(self) -> None:
|
||||
@@ -29,10 +40,12 @@ class FfxApp(App):
|
||||
self.push_screen(ShowsScreen())
|
||||
|
||||
if self.context['command'] == 'inspect':
|
||||
self.push_screen(MediaDetailsScreen())
|
||||
self.push_screen(InspectDetailsScreen())
|
||||
|
||||
if self.context['command'] == 'edit':
|
||||
self.push_screen(MediaEditScreen())
|
||||
|
||||
|
||||
def getContext(self):
|
||||
"""Data 'output' method"""
|
||||
return self.context
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import os, click
|
||||
import os, click, subprocess
|
||||
from functools import lru_cache
|
||||
from logging import Logger
|
||||
|
||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||
from ffx.diagnostics import FfmpegCommandRunner
|
||||
|
||||
from ffx.media_descriptor import MediaDescriptor
|
||||
from ffx.audio_layout import AudioLayout
|
||||
@@ -61,9 +63,51 @@ class FfxController():
|
||||
sourceMediaDescriptor)
|
||||
|
||||
self.__logger: Logger = context['logger']
|
||||
self.__warnedH264Fallback = False
|
||||
self.__ffmpegCommandRunner = FfmpegCommandRunner(context)
|
||||
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=None)
|
||||
def isFfmpegEncoderAvailable(encoderName: str) -> bool:
|
||||
completed = subprocess.run(
|
||||
["ffmpeg", "-encoders"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
return False
|
||||
|
||||
resolvedEncoderName = str(encoderName).strip()
|
||||
|
||||
for line in completed.stdout.splitlines():
|
||||
if not line.startswith(" "):
|
||||
continue
|
||||
|
||||
tokens = line.split(maxsplit=2)
|
||||
if len(tokens) >= 2 and tokens[1] == resolvedEncoderName:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@classmethod
|
||||
def getSupportedSoftwareH264Encoder(cls) -> str | None:
|
||||
if cls.isFfmpegEncoderAvailable("libx264"):
|
||||
return "libx264"
|
||||
if cls.isFfmpegEncoderAvailable("libopenh264"):
|
||||
return "libopenh264"
|
||||
return None
|
||||
|
||||
|
||||
def executeCommandSequence(self, commandSequence):
|
||||
if commandSequence and str(commandSequence[0]).strip() == "ffmpeg":
|
||||
out, err, rc = self.__ffmpegCommandRunner.execute(
|
||||
commandSequence,
|
||||
timeoutSeconds=None,
|
||||
)
|
||||
else:
|
||||
out, err, rc = executeProcess(commandSequence, context=self.__context)
|
||||
if rc:
|
||||
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
|
||||
@@ -79,11 +123,28 @@ class FfxController():
|
||||
|
||||
# -c:v libx264 -preset slow -crf 17
|
||||
def generateH264Tokens(self, quality, subIndex : int = 0):
|
||||
h264Encoder = self.getSupportedSoftwareH264Encoder()
|
||||
|
||||
if h264Encoder == "libx264":
|
||||
return [f"-c:v:{int(subIndex)}", 'libx264',
|
||||
"-preset", "slow",
|
||||
'-crf', str(quality)]
|
||||
|
||||
if h264Encoder == "libopenh264":
|
||||
if not self.__warnedH264Fallback:
|
||||
self.__logger.warning(
|
||||
"libx264 encoder unavailable; falling back to libopenh264 for H.264 encoding."
|
||||
)
|
||||
self.__warnedH264Fallback = True
|
||||
|
||||
return [f"-c:v:{int(subIndex)}", 'libopenh264',
|
||||
'-pix_fmt', 'yuv420p']
|
||||
|
||||
raise click.ClickException(
|
||||
"H.264 encoding requested but no supported software H.264 encoder is available. "
|
||||
+ "Tried libx264 and libopenh264."
|
||||
)
|
||||
|
||||
|
||||
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0
|
||||
def generateVP9Pass1Tokens(self, quality, subIndex : int = 0):
|
||||
@@ -119,6 +180,16 @@ class FfxController():
|
||||
def generateAudioCopyTokens(self, subIndex):
|
||||
return [f"-c:a:{int(subIndex)}", 'copy']
|
||||
|
||||
def generateVideoCopyAllTokens(self):
|
||||
if self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
||||
return ["-c:v", "copy"]
|
||||
return []
|
||||
|
||||
def generateAudioCopyAllTokens(self):
|
||||
if self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.AUDIO):
|
||||
return ["-c:a", "copy"]
|
||||
return []
|
||||
|
||||
def generateSubtitleCopyTokens(self, subIndex):
|
||||
return [f"-c:s:{int(subIndex)}", 'copy']
|
||||
|
||||
@@ -239,18 +310,27 @@ class FfxController():
|
||||
return audioTokens
|
||||
|
||||
|
||||
def generateAudioProcessingTokens(self):
|
||||
if self.__context.get('copy_audio', False):
|
||||
return self.generateAudioCopyAllTokens()
|
||||
return self.generateAudioEncodingTokens()
|
||||
|
||||
|
||||
def runJob(self,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
targetFormat: str = '',
|
||||
chainIteration: list = [],
|
||||
cropArguments: dict = {},
|
||||
currentPattern: Pattern = None):
|
||||
currentPattern: Pattern = None,
|
||||
currentShowDescriptor = None):
|
||||
# quality: int = DEFAULT_QUALITY,
|
||||
# preset: int = DEFAULT_AV1_PRESET):
|
||||
|
||||
|
||||
videoEncoder: VideoEncoder = self.__context.get('video_encoder', VideoEncoder.VP9)
|
||||
self.__context['current_source_path'] = sourcePath
|
||||
copyVideo = self.__context.get('copy_video', False) or videoEncoder == VideoEncoder.COPY
|
||||
|
||||
|
||||
qualityFilters = [fy for fy in chainIteration if fy['identifier'] == 'quality']
|
||||
@@ -261,10 +341,16 @@ class FfxController():
|
||||
deinterlaceFilters = [fy for fy in chainIteration if fy['identifier'] == 'bwdif']
|
||||
|
||||
|
||||
if copyVideo:
|
||||
quality = None
|
||||
self.__context['encoding_metadata_tags'] = {}
|
||||
else:
|
||||
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
|
||||
self.__logger.info(f"Setting quality {quality} from command line parameter")
|
||||
self.__logger.info(f"Setting quality {quality} from command line")
|
||||
elif currentPattern is not None and (quality := currentPattern.quality):
|
||||
self.__logger.info(f"Setting quality {quality} from pattern default")
|
||||
self.__logger.info(f"Setting quality {quality} from pattern")
|
||||
elif currentShowDescriptor is not None and (quality := currentShowDescriptor.getQuality()):
|
||||
self.__logger.info(f"Setting quality {quality} from show")
|
||||
else:
|
||||
quality = (QualityFilter.DEFAULT_H264_QUALITY
|
||||
if (videoEncoder == VideoEncoder.H264)
|
||||
@@ -273,6 +359,7 @@ class FfxController():
|
||||
|
||||
|
||||
preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET
|
||||
if not copyVideo:
|
||||
self.__context['encoding_metadata_tags'] = self.generateEncodingMetadataTags(
|
||||
videoEncoder,
|
||||
quality,
|
||||
@@ -282,7 +369,7 @@ class FfxController():
|
||||
|
||||
filterParamTokens = []
|
||||
|
||||
if cropArguments:
|
||||
if cropArguments and not copyVideo:
|
||||
|
||||
cropParams = (f"crop="
|
||||
+ f"{cropArguments[CropFilter.OUTPUT_WIDTH_KEY]}"
|
||||
@@ -292,6 +379,7 @@ class FfxController():
|
||||
|
||||
filterParamTokens.append(cropParams)
|
||||
|
||||
if not copyVideo:
|
||||
filterParamTokens.extend(denoiseFilters[0]['tokens'] if denoiseFilters else [])
|
||||
filterParamTokens.extend(deinterlaceFilters[0]['tokens'] if deinterlaceFilters else [])
|
||||
|
||||
@@ -324,6 +412,29 @@ class FfxController():
|
||||
self.executeCommandSequence(commandSequence)
|
||||
return
|
||||
|
||||
if copyVideo:
|
||||
|
||||
commandSequence = (commandTokens
|
||||
+ self.__targetMediaDescriptor.getImportFileTokens()
|
||||
+ self.__targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor = self.__sourceMediaDescriptor)
|
||||
+ self.__mdcs.generateDispositionTokens())
|
||||
|
||||
commandSequence += self.__mdcs.generateMetadataTokens()
|
||||
commandSequence += self.generateVideoCopyAllTokens()
|
||||
commandSequence += self.generateAudioProcessingTokens()
|
||||
|
||||
if self.__context['perform_cut']:
|
||||
commandSequence += self.generateCropTokens()
|
||||
|
||||
commandSequence += self.generateOutputTokens(targetPath,
|
||||
targetFormat)
|
||||
|
||||
self.__logger.debug("FfxController.runJob(): Running command sequence")
|
||||
|
||||
if not self.__context['dry_run']:
|
||||
self.executeCommandSequence(commandSequence)
|
||||
return
|
||||
|
||||
if videoEncoder == VideoEncoder.AV1:
|
||||
|
||||
commandSequence = (commandTokens
|
||||
@@ -340,7 +451,7 @@ class FfxController():
|
||||
if td.getCodec != TrackCodec.PNG:
|
||||
commandSequence += self.generateAV1Tokens(int(quality), int(preset))
|
||||
|
||||
commandSequence += self.generateAudioEncodingTokens()
|
||||
commandSequence += self.generateAudioProcessingTokens()
|
||||
|
||||
if self.__context['perform_cut']:
|
||||
commandSequence += self.generateCropTokens()
|
||||
@@ -370,7 +481,7 @@ class FfxController():
|
||||
if td.getCodec != TrackCodec.PNG:
|
||||
commandSequence += self.generateH264Tokens(int(quality))
|
||||
|
||||
commandSequence += self.generateAudioEncodingTokens()
|
||||
commandSequence += self.generateAudioProcessingTokens()
|
||||
|
||||
if self.__context['perform_cut']:
|
||||
commandSequence += self.generateCropTokens()
|
||||
@@ -429,7 +540,7 @@ class FfxController():
|
||||
if td.getCodec != TrackCodec.PNG:
|
||||
commandSequence2 += self.generateVP9Pass2Tokens(int(quality))
|
||||
|
||||
commandSequence2 += self.generateAudioEncodingTokens()
|
||||
commandSequence2 += self.generateAudioProcessingTokens()
|
||||
|
||||
if self.__context['perform_cut']:
|
||||
commandSequence2 += self.generateCropTokens()
|
||||
|
||||
@@ -63,11 +63,19 @@ class FileProperties():
|
||||
self.__sourceFileBasename = self.__sourceFilename
|
||||
self.__sourceFilenameExtension = ''
|
||||
|
||||
self.__pc = PatternController(context)
|
||||
self.__usePattern = bool(self.context.get('use_pattern', True))
|
||||
self.__pc = (
|
||||
PatternController(context)
|
||||
if self.__usePattern and 'database' in self.context
|
||||
else None
|
||||
)
|
||||
|
||||
# Checking if database contains matching pattern
|
||||
matchResult = self.__pc.matchFilename(self.__sourceFilename) if self.__usePattern else {}
|
||||
matchResult = (
|
||||
self.__pc.matchFilename(self.__sourceFilename)
|
||||
if self.__pc is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
self.__logger.debug(f"FileProperties.__init__(): Match result: {matchResult}")
|
||||
|
||||
|
||||
@@ -2,12 +2,30 @@ from textual.app import ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer, Placeholder
|
||||
|
||||
from .i18n import t
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
class HelpScreen(Screen):
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
context = self.app.getContext()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Placeholder("Help Screen")
|
||||
# Row 1
|
||||
yield Placeholder(t("Help Screen"))
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
@@ -6,12 +6,23 @@ from .configuration_controller import ConfigurationController
|
||||
from .logging_utils import get_ffx_logger
|
||||
from .show_descriptor import ShowDescriptor
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EmptyStringUndefined(Undefined):
|
||||
def __str__(self):
|
||||
return ''
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
|
||||
DEBUG = 'debug'
|
||||
INFO = 'info'
|
||||
WARNING = 'warning'
|
||||
ERROR = 'error'
|
||||
CRITICAL = 'critical'
|
||||
|
||||
|
||||
DIFF_ADDED_KEY = 'added'
|
||||
DIFF_REMOVED_KEY = 'removed'
|
||||
DIFF_CHANGED_KEY = 'changed'
|
||||
@@ -119,7 +130,7 @@ def setDiff(a : set, b : set) -> set:
|
||||
def permutateList(inputList: list, permutation: list):
|
||||
|
||||
# 0,1,2: ABC
|
||||
# 0,2,1: ACB
|
||||
# 0,2,1: ACBffmpeg:
|
||||
# 1,2,0: BCA
|
||||
|
||||
pass
|
||||
|
||||
158
src/ffx/i18n.py
Normal file
158
src/ffx/i18n.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_LANGUAGE = "de"
|
||||
SOURCE_LANGUAGE = "en"
|
||||
SUPPORTED_LANGUAGES = {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"fr": "Français",
|
||||
"ja": "日本語",
|
||||
"nb": "Norsk bokmål",
|
||||
"eo": "Esperanto",
|
||||
"ta": "தமிழ்",
|
||||
"pt": "Português",
|
||||
"es": "Español",
|
||||
}
|
||||
LANGUAGE_ALIASES = {
|
||||
"deu": "de",
|
||||
"ger": "de",
|
||||
"english": "en",
|
||||
"eng": "en",
|
||||
"fra": "fr",
|
||||
"fre": "fr",
|
||||
"french": "fr",
|
||||
"jpn": "ja",
|
||||
"japanese": "ja",
|
||||
"nor": "nb",
|
||||
"nob": "nb",
|
||||
"no": "nb",
|
||||
"nn": "nb",
|
||||
"bokmal": "nb",
|
||||
"norwegian": "nb",
|
||||
"epo": "eo",
|
||||
"esperanto": "eo",
|
||||
"tam": "ta",
|
||||
"tamil": "ta",
|
||||
"por": "pt",
|
||||
"portuguese": "pt",
|
||||
"spa": "es",
|
||||
"spanish": "es",
|
||||
}
|
||||
|
||||
_catalog_cache: dict[str, dict] = {}
|
||||
_current_language = DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _assets_directory() -> Path:
|
||||
return Path(__file__).resolve().parents[2] / "assets" / "i18n"
|
||||
|
||||
|
||||
def normalize_language_code(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
normalized = str(value).strip().replace("-", "_")
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
base_language = normalized.split(".")[0].split("_")[0].lower()
|
||||
if base_language in SUPPORTED_LANGUAGES:
|
||||
return base_language
|
||||
|
||||
return LANGUAGE_ALIASES.get(base_language)
|
||||
|
||||
|
||||
def detect_system_language(env: dict[str, str] | None = None) -> str | None:
|
||||
environment = env or os.environ
|
||||
for key in ("LC_ALL", "LC_MESSAGES", "LANG"):
|
||||
if language_code := normalize_language_code(environment.get(key)):
|
||||
return language_code
|
||||
return None
|
||||
|
||||
|
||||
def get_default_config_path(home_directory: str | None = None) -> Path:
|
||||
base_home = Path(home_directory or os.path.expanduser("~"))
|
||||
return base_home / ".local" / "etc" / "ffx.json"
|
||||
|
||||
|
||||
def read_configured_language(
|
||||
config_path: str | os.PathLike | None = None,
|
||||
*,
|
||||
home_directory: str | None = None,
|
||||
) -> str | None:
|
||||
resolved_path = Path(config_path) if config_path is not None else get_default_config_path(home_directory)
|
||||
if not resolved_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
config_data = json.loads(resolved_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
return normalize_language_code(config_data.get("language"))
|
||||
|
||||
|
||||
def resolve_application_language(
|
||||
*,
|
||||
cli_language: str | None = None,
|
||||
config_language: str | None = None,
|
||||
system_language: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
for candidate in (
|
||||
cli_language,
|
||||
config_language,
|
||||
system_language or detect_system_language(env),
|
||||
):
|
||||
if normalized := normalize_language_code(candidate):
|
||||
return normalized
|
||||
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def set_current_language(language_code: str | None) -> str:
|
||||
global _current_language
|
||||
_current_language = normalize_language_code(language_code) or DEFAULT_LANGUAGE
|
||||
return _current_language
|
||||
|
||||
|
||||
def get_current_language() -> str:
|
||||
return _current_language
|
||||
|
||||
|
||||
def _load_catalog(language_code: str) -> dict:
|
||||
normalized = normalize_language_code(language_code) or DEFAULT_LANGUAGE
|
||||
if normalized not in _catalog_cache:
|
||||
catalog_path = _assets_directory() / f"{normalized}.json"
|
||||
if catalog_path.is_file():
|
||||
_catalog_cache[normalized] = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
_catalog_cache[normalized] = {"phrases": {}, "iso_languages": {}}
|
||||
return _catalog_cache[normalized]
|
||||
|
||||
|
||||
def _lookup_phrase(language_code: str, source_text: str) -> str | None:
|
||||
phrases = _load_catalog(language_code).get("phrases", {})
|
||||
return phrases.get(source_text)
|
||||
|
||||
|
||||
def t(source_text: str, **kwargs) -> str:
|
||||
translated = (
|
||||
_lookup_phrase(get_current_language(), source_text)
|
||||
or _lookup_phrase(SOURCE_LANGUAGE, source_text)
|
||||
or source_text
|
||||
)
|
||||
return translated.format(**kwargs) if kwargs else translated
|
||||
|
||||
|
||||
def translate_iso_language(member_name: str, fallback: str) -> str:
|
||||
for language_code in (get_current_language(), SOURCE_LANGUAGE):
|
||||
translations = _load_catalog(language_code).get("iso_languages", {})
|
||||
if member_name in translations:
|
||||
return str(translations[member_name])
|
||||
return str(fallback)
|
||||
603
src/ffx/inspect_details_screen.py
Normal file
603
src/ffx/inspect_details_screen.py
Normal file
@@ -0,0 +1,603 @@
|
||||
import re
|
||||
|
||||
import click
|
||||
from rich.text import Text
|
||||
|
||||
from textual.containers import Grid
|
||||
from textual.widgets import Button, Footer, Header, Input, Static
|
||||
from textual.widgets._data_table import CellDoesNotExist
|
||||
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
|
||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
|
||||
from .i18n import t
|
||||
from .media_workflow_screen_base import MediaWorkflowScreenBase
|
||||
from .pattern_details_screen import PatternDetailsScreen
|
||||
from .screen_support import (
|
||||
add_auto_table_column,
|
||||
build_screen_controllers,
|
||||
build_screen_log_pane,
|
||||
go_back_or_exit,
|
||||
localized_column_width,
|
||||
update_table_column_label,
|
||||
)
|
||||
from .show_details_screen import ShowDetailsScreen
|
||||
|
||||
|
||||
class InspectDetailsScreen(MediaWorkflowScreenBase):
|
||||
|
||||
GRID_COLUMN_LABEL_MIN = 12
|
||||
GRID_COLUMN_2 = 20
|
||||
GRID_COLUMN_3 = 40
|
||||
GRID_COLUMN_4 = "4fr"
|
||||
GRID_COLUMN_5 = 10
|
||||
GRID_COLUMN_6 = "5fr"
|
||||
|
||||
CSS = f"""
|
||||
|
||||
Grid {{
|
||||
grid-size: 6 8;
|
||||
grid-rows: 9 2 2 2 2 10 2 10;
|
||||
grid-columns: {GRID_COLUMN_LABEL_MIN} {GRID_COLUMN_2} {GRID_COLUMN_3} {GRID_COLUMN_4} {GRID_COLUMN_5} {GRID_COLUMN_6};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 120;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}}
|
||||
|
||||
DataTable .datatable--cursor {{
|
||||
background: darkorange;
|
||||
color: black;
|
||||
}}
|
||||
|
||||
DataTable .datatable--header {{
|
||||
background: steelblue;
|
||||
color: white;
|
||||
}}
|
||||
|
||||
Input {{
|
||||
border: none;
|
||||
}}
|
||||
Button {{
|
||||
border: none;
|
||||
}}
|
||||
|
||||
DataTable {{
|
||||
min-height: 24;
|
||||
width: 100%;
|
||||
}}
|
||||
|
||||
.two {{
|
||||
column-span: 2;
|
||||
}}
|
||||
.three {{
|
||||
column-span: 3;
|
||||
}}
|
||||
.four {{
|
||||
column-span: 4;
|
||||
}}
|
||||
.five {{
|
||||
column-span: 5;
|
||||
}}
|
||||
|
||||
#differences-table {{
|
||||
row-span: 10;
|
||||
}}
|
||||
|
||||
.yellow {{
|
||||
tint: yellow 40%;
|
||||
}}
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _grid_columns_spec(cls, label_column_width: int | None = None) -> str:
|
||||
return " ".join(
|
||||
[
|
||||
str(
|
||||
cls.GRID_COLUMN_LABEL_MIN
|
||||
if label_column_width is None
|
||||
else int(label_column_width)
|
||||
),
|
||||
str(cls.GRID_COLUMN_2),
|
||||
str(cls.GRID_COLUMN_3),
|
||||
str(cls.GRID_COLUMN_4),
|
||||
str(cls.GRID_COLUMN_5),
|
||||
str(cls.GRID_COLUMN_6),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
COMMAND_NAME = "inspect"
|
||||
DIFFERENCES_COLUMN_LABEL = "Differences (file->db/output)"
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
("q", "app.quit", t("Quit")),
|
||||
("n", "new_pattern", t("New Pattern")),
|
||||
("u", "update_pattern", t("Update Pattern")),
|
||||
("e", "edit_pattern", t("Edit Pattern")),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self._showRowData: dict[object, ShowDescriptor | None] = {}
|
||||
self._showSortColumnKey = None
|
||||
self._showSortReverse = False
|
||||
self._showColumnLabels: dict[object, str] = {}
|
||||
super().__init__()
|
||||
|
||||
controllers = build_screen_controllers(
|
||||
self.context,
|
||||
pattern=True,
|
||||
show=True,
|
||||
track=True,
|
||||
tag=True,
|
||||
)
|
||||
self._pc = controllers["pattern"]
|
||||
self._sc = controllers["show"]
|
||||
self._tc = controllers["track"]
|
||||
self._tac = controllers["tag"]
|
||||
|
||||
self.reloadProperties(reset_draft=True)
|
||||
|
||||
def compose(self):
|
||||
self._build_media_tags_table()
|
||||
self._build_tracks_table()
|
||||
self._build_differences_table()
|
||||
|
||||
yield Header()
|
||||
|
||||
with Grid(id="main_grid"):
|
||||
|
||||
self.showsTable = self._build_shows_table()
|
||||
|
||||
# Row 1
|
||||
yield Static(t("Show"))
|
||||
yield self.showsTable
|
||||
yield Static(" ")
|
||||
yield self.differencesTable
|
||||
|
||||
|
||||
# Row 2
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# Row 3
|
||||
yield Static(" ")
|
||||
yield Button(t("Substitute"), id="pattern_button")
|
||||
yield Static(" ", classes="three")
|
||||
|
||||
|
||||
# Row 4
|
||||
yield Static(t("Pattern"))
|
||||
yield Input(type="text", id="pattern_input", classes="three")
|
||||
yield Static(" ")
|
||||
|
||||
|
||||
# Row 5
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# Row 6
|
||||
yield Static(t("Media Tags"))
|
||||
yield self.mediaTagsTable
|
||||
yield Static(" ")
|
||||
|
||||
|
||||
# Row 7
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# Row 8
|
||||
yield Static(t("Streams"))
|
||||
yield self.tracksTable
|
||||
yield Static(" ")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
def _update_grid_layout(self) -> None:
|
||||
leftColumnWidth = max(
|
||||
localized_column_width(t("Show"), self.GRID_COLUMN_LABEL_MIN),
|
||||
localized_column_width(t("Pattern"), self.GRID_COLUMN_LABEL_MIN),
|
||||
localized_column_width(t("Media Tags"), self.GRID_COLUMN_LABEL_MIN),
|
||||
localized_column_width(t("Streams"), self.GRID_COLUMN_LABEL_MIN),
|
||||
)
|
||||
grid = self.query_one("#main_grid", Grid)
|
||||
grid.styles.grid_columns = self._grid_columns_spec(leftColumnWidth)
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
def getDisplayedMediaDescriptor(self):
|
||||
if self._currentPattern is not None and self._targetMediaDescriptor is not None:
|
||||
return self._targetMediaDescriptor
|
||||
return self._sourceMediaDescriptor
|
||||
|
||||
def getTrackEditSourceDescriptor(self):
|
||||
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
|
||||
if (
|
||||
selectedTrackDescriptor is None
|
||||
or self._currentPattern is None
|
||||
or self._targetMediaDescriptor is None
|
||||
):
|
||||
return selectedTrackDescriptor
|
||||
|
||||
for sourceTrackDescriptor in self._sourceMediaDescriptor.getTrackDescriptors():
|
||||
if (
|
||||
sourceTrackDescriptor.getSourceIndex()
|
||||
== selectedTrackDescriptor.getSourceIndex()
|
||||
and sourceTrackDescriptor.getType() == selectedTrackDescriptor.getType()
|
||||
):
|
||||
return sourceTrackDescriptor
|
||||
|
||||
return None
|
||||
|
||||
def _build_shows_table(self):
|
||||
from textual.widgets import DataTable
|
||||
|
||||
showsTable = DataTable(classes="three")
|
||||
idLabel = t("ID")
|
||||
nameLabel = t("Name")
|
||||
yearLabel = t("Year")
|
||||
self._showColumnKeyId = add_auto_table_column(showsTable, idLabel)
|
||||
self._showColumnKeyName = add_auto_table_column(showsTable, nameLabel)
|
||||
self._showColumnKeyYear = add_auto_table_column(showsTable, yearLabel)
|
||||
self._showColumnLabels = {
|
||||
self._showColumnKeyId: idLabel,
|
||||
self._showColumnKeyName: nameLabel,
|
||||
self._showColumnKeyYear: yearLabel,
|
||||
}
|
||||
showsTable.cursor_type = "row"
|
||||
return showsTable
|
||||
|
||||
def _get_selected_show_row_key(self):
|
||||
try:
|
||||
row_key, _ = self.showsTable.coordinate_to_cell_key(
|
||||
self.showsTable.cursor_coordinate
|
||||
)
|
||||
return row_key
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
|
||||
def _move_show_cursor_to_row_key(self, row_key):
|
||||
if row_key is None:
|
||||
return
|
||||
|
||||
try:
|
||||
row_index = int(self.showsTable.get_row_index(row_key))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
self.showsTable.move_cursor(row=row_index)
|
||||
|
||||
def _sort_key_for_show_column(self, column_key):
|
||||
if column_key == self._showColumnKeyId:
|
||||
return lambda value: int(value) if str(value).strip().isdigit() else -1
|
||||
if column_key == self._showColumnKeyYear:
|
||||
return lambda value: int(value) if str(value).strip().isdigit() else -1
|
||||
if column_key == self._showColumnKeyName:
|
||||
return lambda value: str(value).casefold()
|
||||
return None
|
||||
|
||||
def _update_show_header_labels(self):
|
||||
if not hasattr(self, "showsTable"):
|
||||
return
|
||||
|
||||
arrow_up = "▴"
|
||||
arrow_down = "▾"
|
||||
|
||||
for column_key, base_label in self._showColumnLabels.items():
|
||||
column = self.showsTable.columns.get(column_key)
|
||||
if column is None:
|
||||
continue
|
||||
|
||||
label_text = base_label
|
||||
if column_key == self._showSortColumnKey:
|
||||
label_text = (
|
||||
f"{base_label} "
|
||||
f"{arrow_down if self._showSortReverse else arrow_up}"
|
||||
)
|
||||
|
||||
update_table_column_label(self.showsTable, column_key, Text(label_text))
|
||||
|
||||
def _apply_show_sort(self, *, preserve_row_key=None):
|
||||
if self._showSortColumnKey is None:
|
||||
self._update_show_header_labels()
|
||||
return
|
||||
|
||||
self.showsTable.sort(
|
||||
self._showSortColumnKey,
|
||||
key=self._sort_key_for_show_column(self._showSortColumnKey),
|
||||
reverse=self._showSortReverse,
|
||||
)
|
||||
self._move_show_cursor_to_row_key(preserve_row_key)
|
||||
self._update_show_header_labels()
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
self._update_grid_layout()
|
||||
|
||||
if self._currentPattern is None:
|
||||
self._add_show_row(None)
|
||||
|
||||
for show in self._sc.getAllShows():
|
||||
self._add_show_row(show.getDescriptor(self.context))
|
||||
|
||||
self._showSortColumnKey = self._showColumnKeyName
|
||||
self._apply_show_sort()
|
||||
|
||||
if self._currentPattern is not None:
|
||||
showIdentifier = self._currentPattern.getShowId()
|
||||
showRowIndex = self.getRowIndexFromShowId(showIdentifier)
|
||||
if showRowIndex is not None:
|
||||
self.showsTable.move_cursor(row=showRowIndex)
|
||||
|
||||
self.query_one("#pattern_input", Input).value = self._currentPattern.getPattern()
|
||||
else:
|
||||
self.query_one("#pattern_input", Input).value = self._mediaFilename
|
||||
self.highlightPattern(True)
|
||||
|
||||
self.updateMediaTags()
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "pattern_button":
|
||||
pattern = self.query_one("#pattern_input", Input).value
|
||||
patternMatch = re.search(FileProperties.SE_INDICATOR_PATTERN, pattern)
|
||||
if patternMatch:
|
||||
self.query_one("#pattern_input", Input).value = pattern.replace(
|
||||
patternMatch.group(1),
|
||||
FileProperties.SE_INDICATOR_PATTERN,
|
||||
)
|
||||
|
||||
if event.button.id == "select_default_button":
|
||||
if self.setSelectedTrackDefault():
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
|
||||
if event.button.id == "select_forced_button":
|
||||
if self.setSelectedTrackForced():
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
|
||||
def on_data_table_header_selected(self, event) -> None:
|
||||
if event.data_table is not self.showsTable:
|
||||
return
|
||||
|
||||
selected_row_key = self._get_selected_show_row_key()
|
||||
|
||||
if self._showSortColumnKey == event.column_key:
|
||||
self._showSortReverse = not self._showSortReverse
|
||||
else:
|
||||
self._showSortColumnKey = event.column_key
|
||||
self._showSortReverse = False
|
||||
|
||||
self._apply_show_sort(preserve_row_key=selected_row_key)
|
||||
|
||||
def removeShow(self, showId: int = -1):
|
||||
for row_key, show_descriptor in list(self._showRowData.items()):
|
||||
if (
|
||||
(showId == -1 and show_descriptor is None)
|
||||
or (
|
||||
show_descriptor is not None
|
||||
and show_descriptor.getId() == showId
|
||||
)
|
||||
):
|
||||
self.showsTable.remove_row(row_key)
|
||||
self._showRowData.pop(row_key, None)
|
||||
return
|
||||
|
||||
def getRowIndexFromShowId(self, showId: int = -1) -> int | None:
|
||||
for row_key, show_descriptor in self._showRowData.items():
|
||||
if (
|
||||
(showId == -1 and show_descriptor is None)
|
||||
or (
|
||||
show_descriptor is not None
|
||||
and show_descriptor.getId() == showId
|
||||
)
|
||||
):
|
||||
return int(self.showsTable.get_row_index(row_key))
|
||||
|
||||
return None
|
||||
|
||||
def _add_show_row(self, show_descriptor: ShowDescriptor | None):
|
||||
if show_descriptor is None:
|
||||
row_key = self.showsTable.add_row(" ", t("<New show>"), " ")
|
||||
else:
|
||||
row_key = self.showsTable.add_row(
|
||||
str(show_descriptor.getId()),
|
||||
str(show_descriptor.getName()),
|
||||
str(show_descriptor.getYear()),
|
||||
)
|
||||
|
||||
self._showRowData[row_key] = show_descriptor
|
||||
return row_key
|
||||
|
||||
def highlightPattern(self, state: bool):
|
||||
patternInput = self.query_one("#pattern_input", Input)
|
||||
patternInput.styles.background = "red" if state else None
|
||||
|
||||
def getSelectedShowDescriptor(self) -> ShowDescriptor | None:
|
||||
try:
|
||||
row_key, _ = self.showsTable.coordinate_to_cell_key(
|
||||
self.showsTable.cursor_coordinate
|
||||
)
|
||||
|
||||
if row_key is not None:
|
||||
return self._showRowData.get(row_key)
|
||||
except (CellDoesNotExist, AttributeError):
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def getPatternObjFromInput(self):
|
||||
patternObj = {}
|
||||
try:
|
||||
patternObj["show_id"] = self.getSelectedShowDescriptor().getId()
|
||||
patternObj["pattern"] = str(self.query_one("#pattern_input", Input).value)
|
||||
except Exception:
|
||||
return {}
|
||||
return patternObj
|
||||
|
||||
def handle_new_pattern(self, showDescriptor: ShowDescriptor):
|
||||
if type(showDescriptor) is not ShowDescriptor:
|
||||
raise TypeError(
|
||||
"InspectDetailsScreen.handle_new_pattern(): Argument 'showDescriptor' has to be of type ShowDescriptor"
|
||||
)
|
||||
|
||||
self.removeShow()
|
||||
|
||||
showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId())
|
||||
if showRowIndex is None:
|
||||
row_key = self._add_show_row(showDescriptor)
|
||||
self._apply_show_sort(preserve_row_key=row_key)
|
||||
|
||||
showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId())
|
||||
if showRowIndex is not None:
|
||||
self.showsTable.move_cursor(row=showRowIndex)
|
||||
|
||||
patternObj = self.getPatternObjFromInput()
|
||||
if patternObj:
|
||||
mediaTags = {}
|
||||
for tagKey, tagValue in self._sourceMediaDescriptor.getTags().items():
|
||||
if (
|
||||
tagKey not in self._ignoreGlobalKeys
|
||||
and tagKey not in self._removeGlobalKeys
|
||||
):
|
||||
mediaTags[tagKey] = tagValue
|
||||
|
||||
patternId = self._pc.savePatternSchema(
|
||||
patternObj,
|
||||
trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(),
|
||||
mediaTags=mediaTags,
|
||||
)
|
||||
if patternId:
|
||||
self.reloadProperties(reset_draft=True)
|
||||
self.updateMediaTags()
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
self.highlightPattern(False)
|
||||
|
||||
def action_new_pattern(self):
|
||||
selectedShowDescriptor = self.getSelectedShowDescriptor()
|
||||
if selectedShowDescriptor is None:
|
||||
self.app.push_screen(ShowDetailsScreen(), self.handle_new_pattern)
|
||||
else:
|
||||
self.handle_new_pattern(selectedShowDescriptor)
|
||||
|
||||
def action_update_pattern(self):
|
||||
if self._currentPattern is not None:
|
||||
patternObj = self.getPatternObjFromInput()
|
||||
if (
|
||||
patternObj
|
||||
and self._currentPattern.getPattern() != patternObj["pattern"]
|
||||
):
|
||||
updated = self._pc.updatePattern(
|
||||
self._currentPattern.getId(),
|
||||
patternObj,
|
||||
)
|
||||
if updated:
|
||||
self.reloadProperties(reset_draft=True)
|
||||
self.updateMediaTags()
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
return updated
|
||||
|
||||
tagDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {})
|
||||
for addedTagKey in tagDifferences.get(DIFF_ADDED_KEY, {}).keys():
|
||||
self._tac.deleteMediaTagByKey(self._currentPattern.getId(), addedTagKey)
|
||||
|
||||
for removedTagKey in tagDifferences.get(DIFF_REMOVED_KEY, {}).keys():
|
||||
currentTags = self._sourceMediaDescriptor.getTags()
|
||||
self._tac.updateMediaTag(
|
||||
self._currentPattern.getId(),
|
||||
removedTagKey,
|
||||
currentTags[removedTagKey],
|
||||
)
|
||||
|
||||
for changedTagKey in tagDifferences.get(DIFF_CHANGED_KEY, {}).keys():
|
||||
currentTags = self._sourceMediaDescriptor.getTags()
|
||||
self._tac.updateMediaTag(
|
||||
self._currentPattern.getId(),
|
||||
changedTagKey,
|
||||
currentTags[changedTagKey],
|
||||
)
|
||||
|
||||
trackDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TRACKS_KEY, {})
|
||||
|
||||
for trackDescriptor in trackDifferences.get(DIFF_ADDED_KEY, {}).values():
|
||||
self._tc.addTrack(trackDescriptor, patternId=self._currentPattern.getId())
|
||||
|
||||
for trackDescriptor in trackDifferences.get(DIFF_REMOVED_KEY, {}).values():
|
||||
self._tc.deleteTrack(trackDescriptor.getId())
|
||||
|
||||
for trackIndex, trackDiff in trackDifferences.get(DIFF_CHANGED_KEY, {}).items():
|
||||
targetTracks = [
|
||||
track
|
||||
for track in self._targetMediaDescriptor.getTrackDescriptors()
|
||||
if track.getIndex() == trackIndex
|
||||
]
|
||||
targetTrackId = targetTracks[0].getId() if targetTracks else None
|
||||
targetTrackIndex = targetTracks[0].getIndex() if targetTracks else None
|
||||
|
||||
tagsDiff = trackDiff.get(TrackDescriptor.TAGS_KEY, {})
|
||||
for tagKey, tagValue in tagsDiff.get(DIFF_ADDED_KEY, {}).items():
|
||||
self._tac.updateTrackTag(targetTrackId, tagKey, tagValue)
|
||||
for tagKey in tagsDiff.get(DIFF_REMOVED_KEY, {}).keys():
|
||||
self._tac.deleteTrackTagByKey(targetTrackId, tagKey)
|
||||
for tagKey, tagValue in tagsDiff.get(DIFF_CHANGED_KEY, {}).items():
|
||||
self._tac.updateTrackTag(targetTrackId, tagKey, tagValue)
|
||||
|
||||
dispositionDiff = trackDiff.get(TrackDescriptor.DISPOSITION_SET_KEY, {})
|
||||
for changedDisposition in dispositionDiff.get(DIFF_ADDED_KEY, set()):
|
||||
if targetTrackIndex is not None:
|
||||
self._tc.setDispositionState(
|
||||
self._currentPattern.getId(),
|
||||
targetTrackIndex,
|
||||
changedDisposition,
|
||||
True,
|
||||
)
|
||||
for changedDisposition in dispositionDiff.get(DIFF_REMOVED_KEY, set()):
|
||||
if targetTrackIndex is not None:
|
||||
self._tc.setDispositionState(
|
||||
self._currentPattern.getId(),
|
||||
targetTrackIndex,
|
||||
changedDisposition,
|
||||
False,
|
||||
)
|
||||
|
||||
self.reloadProperties(reset_draft=True)
|
||||
self.updateMediaTags()
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
|
||||
def action_edit_pattern(self):
|
||||
patternObj = self.getPatternObjFromInput()
|
||||
if patternObj.get("pattern"):
|
||||
selectedPatternId = self._pc.findPattern(patternObj)
|
||||
if selectedPatternId is None:
|
||||
raise click.ClickException(
|
||||
"InspectDetailsScreen.action_edit_pattern(): Pattern to edit has no id"
|
||||
)
|
||||
|
||||
self.app.push_screen(
|
||||
PatternDetailsScreen(
|
||||
patternId=selectedPatternId,
|
||||
showId=self.getSelectedShowDescriptor().getId(),
|
||||
),
|
||||
self.handle_edit_pattern,
|
||||
)
|
||||
|
||||
def handle_edit_pattern(self, screenResult):
|
||||
self.reloadProperties(reset_draft=True)
|
||||
if self._currentPattern is not None:
|
||||
self.query_one("#pattern_input", Input).value = self._currentPattern.getPattern()
|
||||
self.updateMediaTags()
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
@@ -1,6 +1,8 @@
|
||||
from enum import Enum
|
||||
import difflib
|
||||
|
||||
from .i18n import translate_iso_language
|
||||
|
||||
|
||||
class IsoLanguage(Enum):
|
||||
|
||||
@@ -196,11 +198,15 @@ class IsoLanguage(Enum):
|
||||
@staticmethod
|
||||
def find(label : str):
|
||||
|
||||
closestMatches = difflib.get_close_matches(label, [l.value["name"] for l in IsoLanguage], n=1)
|
||||
candidate_map = {}
|
||||
for language in IsoLanguage:
|
||||
candidate_map[language.value["name"]] = language
|
||||
candidate_map[translate_iso_language(language.name, language.value["name"])] = language
|
||||
|
||||
closestMatches = difflib.get_close_matches(label, list(candidate_map.keys()), n=1)
|
||||
|
||||
if closestMatches:
|
||||
foundLangs = [l for l in IsoLanguage if l.value["name"] == closestMatches[0]]
|
||||
return foundLangs[0] if foundLangs else IsoLanguage.UNDEFINED
|
||||
return candidate_map.get(closestMatches[0], IsoLanguage.UNDEFINED)
|
||||
else:
|
||||
return IsoLanguage.UNDEFINED
|
||||
|
||||
@@ -211,7 +217,7 @@ class IsoLanguage(Enum):
|
||||
|
||||
|
||||
def label(self):
|
||||
return str(self.value["name"])
|
||||
return str(translate_iso_language(self.name, self.value["name"]))
|
||||
|
||||
def twoLetter(self):
|
||||
return str(self.value["iso639_1"])
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
FFX_LOGGER_NAME = "FFX"
|
||||
CONSOLE_HANDLER_NAME = "ffx-console"
|
||||
FILE_HANDLER_NAME = "ffx-file"
|
||||
MUTED_CONSOLE_LEVEL = logging.CRITICAL + 1
|
||||
|
||||
|
||||
def get_ffx_logger(name: str = FFX_LOGGER_NAME) -> logging.Logger:
|
||||
@@ -66,3 +67,31 @@ def configure_ffx_logger(
|
||||
)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def set_ffx_console_logging_enabled(
|
||||
logger: logging.Logger | None,
|
||||
*,
|
||||
enabled: bool,
|
||||
):
|
||||
if logger is None:
|
||||
return None
|
||||
|
||||
console_handler = next(
|
||||
(handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME),
|
||||
None,
|
||||
)
|
||||
if console_handler is None:
|
||||
return None
|
||||
|
||||
if enabled:
|
||||
saved_level = getattr(console_handler, "_ffx_saved_level", None)
|
||||
if saved_level is not None:
|
||||
console_handler.setLevel(saved_level)
|
||||
delattr(console_handler, "_ffx_saved_level")
|
||||
return console_handler
|
||||
|
||||
if not hasattr(console_handler, "_ffx_saved_level"):
|
||||
console_handler._ffx_saved_level = console_handler.level
|
||||
console_handler.setLevel(MUTED_CONSOLE_LEVEL)
|
||||
return console_handler
|
||||
|
||||
@@ -2,6 +2,7 @@ import os, re, click
|
||||
|
||||
from typing import List, Self
|
||||
|
||||
from ffx.attachment_format import AttachmentFormat
|
||||
from ffx.track_type import TrackType
|
||||
from ffx.iso_language import IsoLanguage
|
||||
|
||||
@@ -328,6 +329,96 @@ class MediaDescriptor:
|
||||
if s.getType() == TrackType.ATTACHMENT
|
||||
]
|
||||
|
||||
def hasStyledAssSubtitlesWithFontAttachments(self) -> bool:
|
||||
return (
|
||||
any(
|
||||
trackDescriptor.getCodec() == TrackCodec.ASS
|
||||
for trackDescriptor in self.getSubtitleTracks()
|
||||
)
|
||||
and any(
|
||||
trackDescriptor.getAttachmentFormat() == AttachmentFormat.TTF
|
||||
for trackDescriptor in self.getAttachmentTracks()
|
||||
)
|
||||
)
|
||||
|
||||
def withoutAttachmentTracks(
|
||||
self,
|
||||
attachmentFormat: AttachmentFormat | None = None,
|
||||
context: dict | None = None,
|
||||
):
|
||||
filteredTrackDescriptors = []
|
||||
for trackDescriptor in self.__trackDescriptors:
|
||||
if trackDescriptor.getType() == TrackType.ATTACHMENT and (
|
||||
attachmentFormat is None
|
||||
or trackDescriptor.getAttachmentFormat() == attachmentFormat
|
||||
):
|
||||
continue
|
||||
filteredTrackDescriptors.append(
|
||||
trackDescriptor.clone(
|
||||
context=context if context is not None else self.__context
|
||||
)
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
|
||||
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: filteredTrackDescriptors,
|
||||
}
|
||||
if context is not None:
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||
elif self.__context:
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
|
||||
|
||||
filteredMediaDescriptor = MediaDescriptor(**kwargs)
|
||||
filteredMediaDescriptor.reindexSubIndices()
|
||||
return filteredMediaDescriptor
|
||||
|
||||
def withoutAttachmentsForComparison(self):
|
||||
return self.withoutAttachmentTracks(context=self.__context)
|
||||
|
||||
def withSourceAttachmentTracks(
|
||||
self,
|
||||
sourceMediaDescriptor: Self,
|
||||
attachmentFormat: AttachmentFormat | None = None,
|
||||
context: dict | None = None,
|
||||
):
|
||||
trackDescriptors = []
|
||||
for trackDescriptor in self.__trackDescriptors:
|
||||
if trackDescriptor.getType() == TrackType.ATTACHMENT and (
|
||||
attachmentFormat is None
|
||||
or trackDescriptor.getAttachmentFormat() == attachmentFormat
|
||||
):
|
||||
continue
|
||||
trackDescriptors.append(
|
||||
trackDescriptor.clone(
|
||||
context=context if context is not None else self.__context
|
||||
)
|
||||
)
|
||||
|
||||
for sourceTrackDescriptor in sourceMediaDescriptor.getAttachmentTracks():
|
||||
if (
|
||||
attachmentFormat is not None
|
||||
and sourceTrackDescriptor.getAttachmentFormat() != attachmentFormat
|
||||
):
|
||||
continue
|
||||
attachmentClone = sourceTrackDescriptor.clone(
|
||||
context=context if context is not None else self.__context
|
||||
)
|
||||
attachmentClone.setIndex(len(trackDescriptors))
|
||||
trackDescriptors.append(attachmentClone)
|
||||
|
||||
kwargs = {
|
||||
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
|
||||
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: trackDescriptors,
|
||||
}
|
||||
if context is not None:
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||
elif self.__context:
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
|
||||
|
||||
mergedMediaDescriptor = MediaDescriptor(**kwargs)
|
||||
mergedMediaDescriptor.reindexSubIndices()
|
||||
return mergedMediaDescriptor
|
||||
|
||||
|
||||
def getImportFileTokens(self, use_sub_index: bool = True):
|
||||
"""Generate ffmpeg import options for external stream files"""
|
||||
@@ -421,11 +512,11 @@ class MediaDescriptor:
|
||||
|
||||
if sourceMediaDescriptor:
|
||||
fontDescriptors = [ftd for ftd in sourceMediaDescriptor.getAttachmentTracks()
|
||||
if ftd.getCodec() == TrackCodec.TTF]
|
||||
if ftd.getAttachmentFormat() == AttachmentFormat.TTF]
|
||||
else:
|
||||
fontDescriptors = [ftd for ftd in self.__trackDescriptors
|
||||
if ftd.getType() == TrackType.ATTACHMENT
|
||||
and ftd.getCodec() == TrackCodec.TTF]
|
||||
and ftd.getAttachmentFormat() == AttachmentFormat.TTF]
|
||||
|
||||
for ad in sorted(fontDescriptors, key=lambda d: d.getIndex()):
|
||||
inputMappingTokens += ["-map", f"0:{ad.getIndex()}"]
|
||||
@@ -500,7 +591,14 @@ class MediaDescriptor:
|
||||
return subtitleFileDescriptors
|
||||
|
||||
|
||||
def importSubtitles(self, searchDirectory, prefix, season: int = -1, episode: int = -1):
|
||||
def importSubtitles(
|
||||
self,
|
||||
searchDirectory,
|
||||
prefix,
|
||||
season: int = -1,
|
||||
episode: int = -1,
|
||||
preserve_dispositions: bool = False,
|
||||
):
|
||||
|
||||
# click.echo(f"Season: {season} Episode: {episode}")
|
||||
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
|
||||
@@ -543,7 +641,7 @@ class MediaDescriptor:
|
||||
# Prefer metadata coming from the external single-track source when
|
||||
# it is provided explicitly by the filename contract.
|
||||
matchingTrack.getTags()["language"] = msfd["language"]
|
||||
if msfd["disposition_set"]:
|
||||
if msfd["disposition_set"] and not preserve_dispositions:
|
||||
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
||||
|
||||
|
||||
@@ -554,3 +652,19 @@ class MediaDescriptor:
|
||||
yield (f"{td.getIndex()}:{td.getType().indicator()}:{td.getSubIndex()} "
|
||||
+ '|'.join([d.indicator() for d in td.getDispositionSet()])
|
||||
+ ' ' + ' '.join([str(k)+'='+str(v) for k,v in td.getTags().items()]))
|
||||
|
||||
def clone(self, context: dict | None = None):
|
||||
kwargs = {
|
||||
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
|
||||
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: [
|
||||
trackDescriptor.clone(context=context if context is not None else self.__context)
|
||||
for trackDescriptor in self.__trackDescriptors
|
||||
],
|
||||
}
|
||||
|
||||
if context is not None:
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||
elif self.__context:
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
|
||||
|
||||
return MediaDescriptor(**kwargs)
|
||||
|
||||
@@ -8,6 +8,7 @@ from ffx.helper import dictDiff, setDiff, DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF
|
||||
|
||||
from ffx.track_codec import TrackCodec
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
|
||||
class MediaDescriptorChangeSet():
|
||||
@@ -29,20 +30,50 @@ class MediaDescriptorChangeSet():
|
||||
self.__configurationData = self.__context['config'].getData()
|
||||
|
||||
metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {}
|
||||
applyCleanup = bool(self.__context.get('apply_metadata_cleanup', True))
|
||||
self.__applyMetadataNormalization = bool(
|
||||
self.__context.get("apply_metadata_normalization", True)
|
||||
)
|
||||
|
||||
self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {}
|
||||
self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else []
|
||||
self.__removeGlobalKeys = (
|
||||
metadataConfiguration['remove']
|
||||
if applyCleanup and 'remove' in metadataConfiguration.keys()
|
||||
else []
|
||||
)
|
||||
self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else []
|
||||
self.__removeTrackKeys = (metadataConfiguration['streams']['remove']
|
||||
if 'streams' in metadataConfiguration.keys()
|
||||
and 'remove' in metadataConfiguration['streams'].keys() else [])
|
||||
self.__removeTrackKeys = (
|
||||
metadataConfiguration['streams']['remove']
|
||||
if (
|
||||
applyCleanup
|
||||
and 'streams' in metadataConfiguration.keys()
|
||||
and 'remove' in metadataConfiguration['streams'].keys()
|
||||
)
|
||||
else []
|
||||
)
|
||||
self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore']
|
||||
if 'streams' in metadataConfiguration.keys()
|
||||
and 'ignore' in metadataConfiguration['streams'].keys() else [])
|
||||
|
||||
|
||||
self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else []
|
||||
self.__sourceTrackDescriptors = sourceMediaDescriptor.getTrackDescriptors() if sourceMediaDescriptor is not None else []
|
||||
self.__targetTrackDescriptors = (
|
||||
[
|
||||
trackDescriptor
|
||||
for trackDescriptor in targetMediaDescriptor.getTrackDescriptors()
|
||||
if trackDescriptor.getType() != TrackType.ATTACHMENT
|
||||
]
|
||||
if targetMediaDescriptor is not None
|
||||
else []
|
||||
)
|
||||
self.__sourceTrackDescriptors = (
|
||||
[
|
||||
trackDescriptor
|
||||
for trackDescriptor in sourceMediaDescriptor.getTrackDescriptors()
|
||||
if trackDescriptor.getType() != TrackType.ATTACHMENT
|
||||
]
|
||||
if sourceMediaDescriptor is not None
|
||||
else []
|
||||
)
|
||||
self.__targetTrackDescriptorsByIndex = {
|
||||
trackDescriptor.getIndex(): trackDescriptor
|
||||
for trackDescriptor in self.__targetTrackDescriptors
|
||||
@@ -119,7 +150,11 @@ class MediaDescriptorChangeSet():
|
||||
|
||||
sourceTrackTags = sourceTrackDescriptor.getTags() if sourceTrackDescriptor is not None else {}
|
||||
targetTrackTags = (
|
||||
self.normalizeTrackTags(targetTrackDescriptor.getTags())
|
||||
self.normalizeTrackTags(
|
||||
targetTrackDescriptor.getTags(),
|
||||
trackDescriptor=targetTrackDescriptor,
|
||||
fallbackTrackTags=sourceTrackTags,
|
||||
)
|
||||
if targetTrackDescriptor is not None
|
||||
else {}
|
||||
)
|
||||
@@ -148,7 +183,7 @@ class MediaDescriptorChangeSet():
|
||||
return trackCompareResult
|
||||
|
||||
def normalizeTrackTagValue(self, tagKey, tagValue):
|
||||
if tagKey != "language":
|
||||
if not self.__applyMetadataNormalization or tagKey != "language":
|
||||
return tagValue
|
||||
|
||||
if isinstance(tagValue, IsoLanguage):
|
||||
@@ -160,12 +195,40 @@ class MediaDescriptorChangeSet():
|
||||
|
||||
return tagValue
|
||||
|
||||
def normalizeTrackTags(self, trackTags: dict):
|
||||
return {
|
||||
def resolveTrackLanguage(self, tagValue):
|
||||
if isinstance(tagValue, IsoLanguage):
|
||||
return tagValue
|
||||
|
||||
trackLanguage = IsoLanguage.findThreeLetter(str(tagValue))
|
||||
if trackLanguage != IsoLanguage.UNDEFINED:
|
||||
return trackLanguage
|
||||
|
||||
return None
|
||||
|
||||
def normalizeTrackTags(
|
||||
self,
|
||||
trackTags: dict,
|
||||
trackDescriptor: TrackDescriptor = None,
|
||||
fallbackTrackTags: dict = None,
|
||||
):
|
||||
normalizedTrackTags = {
|
||||
tagKey: self.normalizeTrackTagValue(tagKey, tagValue)
|
||||
for tagKey, tagValue in trackTags.items()
|
||||
}
|
||||
|
||||
if (
|
||||
self.__applyMetadataNormalization
|
||||
and trackDescriptor is not None
|
||||
and trackDescriptor.getType() in (TrackType.VIDEO, TrackType.AUDIO, TrackType.SUBTITLE)
|
||||
):
|
||||
trackTitle = str(normalizedTrackTags.get("title", "")).strip()
|
||||
fallbackTitle = str((fallbackTrackTags or {}).get("title", "")).strip()
|
||||
trackLanguage = self.resolveTrackLanguage(normalizedTrackTags.get("language"))
|
||||
if not trackTitle and not fallbackTitle and trackLanguage is not None:
|
||||
normalizedTrackTags["title"] = trackLanguage.label()
|
||||
|
||||
return normalizedTrackTags
|
||||
|
||||
|
||||
def generateDispositionTokens(self):
|
||||
"""
|
||||
@@ -213,6 +276,8 @@ class MediaDescriptorChangeSet():
|
||||
# else:
|
||||
# dispositionTokens += [f"-disposition:{streamIndicator}:{subIndex}", '0']
|
||||
for ttd in self.__targetTrackDescriptors:
|
||||
if ttd.getType() == TrackType.ATTACHMENT:
|
||||
continue
|
||||
|
||||
targetDispositions = ttd.getDispositionSet()
|
||||
streamIndicator = ttd.getType().indicator()
|
||||
@@ -267,7 +332,10 @@ class MediaDescriptorChangeSet():
|
||||
addedTracks: dict = self.__changeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_ADDED_KEY]
|
||||
trackDescriptor: TrackDescriptor
|
||||
for trackDescriptor in addedTracks.values():
|
||||
for tagKey, tagValue in self.normalizeTrackTags(trackDescriptor.getTags()).items():
|
||||
for tagKey, tagValue in self.normalizeTrackTags(
|
||||
trackDescriptor.getTags(),
|
||||
trackDescriptor=trackDescriptor,
|
||||
).items():
|
||||
if not tagKey in self.__removeTrackKeys:
|
||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||
+ f":{trackDescriptor.getSubIndex()}",
|
||||
@@ -291,7 +359,11 @@ class MediaDescriptorChangeSet():
|
||||
|
||||
trackDescriptor = self.__targetTrackDescriptorsByIndex[trackIndex]
|
||||
|
||||
for tagKey, tagValue in self.normalizeTrackTags(outputTrackTags).items():
|
||||
for tagKey, tagValue in self.normalizeTrackTags(
|
||||
outputTrackTags,
|
||||
trackDescriptor=trackDescriptor,
|
||||
fallbackTrackTags=trackDescriptor.getTags(),
|
||||
).items():
|
||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||
+ f":{trackDescriptor.getSubIndex()}",
|
||||
f"{tagKey}={tagValue}"]
|
||||
@@ -309,7 +381,11 @@ class MediaDescriptorChangeSet():
|
||||
}
|
||||
| unchangedTrackTags
|
||||
)
|
||||
for tagKey, tagValue in self.normalizeTrackTags(preservedTrackTags).items():
|
||||
for tagKey, tagValue in self.normalizeTrackTags(
|
||||
preservedTrackTags,
|
||||
trackDescriptor=trackDescriptor,
|
||||
fallbackTrackTags=trackDescriptor.getTags(),
|
||||
).items():
|
||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||
+ f":{trackDescriptor.getSubIndex()}",
|
||||
f"{tagKey}={tagValue}"]
|
||||
|
||||
@@ -1,748 +1 @@
|
||||
import os, click, re
|
||||
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button, Input, DataTable
|
||||
from textual.containers import Grid
|
||||
|
||||
from ffx.audio_layout import AudioLayout
|
||||
|
||||
from .show_details_screen import ShowDetailsScreen
|
||||
from .pattern_details_screen import PatternDetailsScreen
|
||||
from .screen_support import build_screen_bootstrap, build_screen_controllers
|
||||
|
||||
from ffx.track_type import TrackType
|
||||
from ffx.track_codec import TrackCodec
|
||||
from ffx.model.track import Track
|
||||
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
|
||||
from textual.widgets._data_table import CellDoesNotExist
|
||||
|
||||
from ffx.media_descriptor import MediaDescriptor
|
||||
from ffx.file_properties import FileProperties
|
||||
|
||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||
|
||||
from ffx.helper import formatRichColor, DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY, DIFF_UNCHANGED_KEY
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
class MediaDetailsScreen(Screen):
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 5 8;
|
||||
grid-rows: 8 2 2 2 2 8 2 2 8;
|
||||
grid-columns: 15 25 90 10 105;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
DataTable .datatable--cursor {
|
||||
background: darkorange;
|
||||
color: black;
|
||||
}
|
||||
|
||||
DataTable .datatable--header {
|
||||
background: steelblue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
Input {
|
||||
border: none;
|
||||
}
|
||||
Button {
|
||||
border: none;
|
||||
}
|
||||
|
||||
DataTable {
|
||||
min-height: 40;
|
||||
}
|
||||
|
||||
#toplabel {
|
||||
height: 1;
|
||||
}
|
||||
.two {
|
||||
column-span: 2;
|
||||
}
|
||||
.three {
|
||||
column-span: 3;
|
||||
}
|
||||
|
||||
.four {
|
||||
column-span: 4;
|
||||
}
|
||||
.five {
|
||||
column-span: 5;
|
||||
}
|
||||
|
||||
.triple {
|
||||
row-span: 3;
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 100%;
|
||||
border: solid green;
|
||||
}
|
||||
|
||||
.purple {
|
||||
tint: purple 40%;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
tint: yellow 40%;
|
||||
}
|
||||
|
||||
#differences-table {
|
||||
row-span: 8;
|
||||
/* tint: magenta 40%; */
|
||||
}
|
||||
|
||||
/* #pattern_input {
|
||||
tint: red 40%;
|
||||
}*/
|
||||
"""
|
||||
|
||||
|
||||
TRACKS_TABLE_INDEX_COLUMN_LABEL = "Index"
|
||||
TRACKS_TABLE_TYPE_COLUMN_LABEL = "Type"
|
||||
TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL = "SubIndex"
|
||||
TRACKS_TABLE_CODEC_COLUMN_LABEL = "Codec"
|
||||
TRACKS_TABLE_LAYOUT_COLUMN_LABEL = "Layout"
|
||||
TRACKS_TABLE_LANGUAGE_COLUMN_LABEL = "Language"
|
||||
TRACKS_TABLE_TITLE_COLUMN_LABEL = "Title"
|
||||
TRACKS_TABLE_DEFAULT_COLUMN_LABEL = "Default"
|
||||
TRACKS_TABLE_FORCED_COLUMN_LABEL = "Forced"
|
||||
|
||||
DIFFERENCES_TABLE_DIFFERENCES_COLUMN_LABEL = 'Differences (file->db/output)'
|
||||
|
||||
|
||||
BINDINGS = [
|
||||
("n", "new_pattern", "New Pattern"),
|
||||
("u", "update_pattern", "Update Pattern"),
|
||||
("e", "edit_pattern", "Edit Pattern"),
|
||||
]
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
bootstrap = build_screen_bootstrap(self.app.getContext())
|
||||
self.context = bootstrap.context
|
||||
|
||||
self.__removeGlobalKeys = bootstrap.remove_global_keys
|
||||
self.__ignoreGlobalKeys = bootstrap.ignore_global_keys
|
||||
|
||||
controllers = build_screen_controllers(
|
||||
self.context,
|
||||
pattern=True,
|
||||
show=True,
|
||||
track=True,
|
||||
tag=True,
|
||||
)
|
||||
self.__pc = controllers['pattern']
|
||||
self.__sc = controllers['show']
|
||||
self.__tc = controllers['track']
|
||||
self.__tac = controllers['tag']
|
||||
|
||||
if not 'command' in self.context.keys() or self.context['command'] != 'inspect':
|
||||
raise click.ClickException(f"MediaDetailsScreen.__init__(): Can only perform command 'inspect'")
|
||||
|
||||
if not 'arguments' in self.context.keys() or not 'filename' in self.context['arguments'].keys() or not self.context['arguments']['filename']:
|
||||
raise click.ClickException(f"MediaDetailsScreen.__init__(): Argument 'filename' is required to be provided for command 'inspect'")
|
||||
|
||||
self.__mediaFilename = self.context['arguments']['filename']
|
||||
|
||||
if not os.path.isfile(self.__mediaFilename):
|
||||
raise click.ClickException(f"MediaDetailsScreen.__init__(): Media file {self.__mediaFilename} does not exist")
|
||||
|
||||
self.loadProperties()
|
||||
|
||||
|
||||
def removeShow(self, showId : int = -1):
|
||||
"""Remove show entry from DataTable.
|
||||
Removes the <New show> entry if showId is not set"""
|
||||
|
||||
for rowKey, row in self.showsTable.rows.items(): # dict[RowKey, Row]
|
||||
|
||||
rowData = self.showsTable.get_row(rowKey)
|
||||
|
||||
try:
|
||||
if (showId == -1 and rowData[0] == ' '
|
||||
or showId == int(rowData[0])):
|
||||
self.showsTable.remove_row(rowKey)
|
||||
return
|
||||
except:
|
||||
continue
|
||||
|
||||
|
||||
|
||||
def getRowIndexFromShowId(self, showId : int = -1) -> int:
|
||||
"""Find the index of the row where the value in the specified column matches the target_value."""
|
||||
|
||||
for rowKey, row in self.showsTable.rows.items(): # dict[RowKey, Row]
|
||||
|
||||
rowData = self.showsTable.get_row(rowKey)
|
||||
|
||||
try:
|
||||
if ((showId == -1 and rowData[0] == ' ')
|
||||
or showId == int(rowData[0])):
|
||||
return int(self.showsTable.get_row_index(rowKey))
|
||||
except:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def loadProperties(self):
|
||||
|
||||
self.__mediaFileProperties = FileProperties(self.context, self.__mediaFilename)
|
||||
self.__sourceMediaDescriptor = self.__mediaFileProperties.getMediaDescriptor()
|
||||
|
||||
#HINT: This is None if the filename did not match anything in database
|
||||
self.__currentPattern = self.__mediaFileProperties.getPattern()
|
||||
|
||||
# keine tags vorhanden
|
||||
self.__targetMediaDescriptor = self.__currentPattern.getMediaDescriptor(self.context) if self.__currentPattern is not None else None
|
||||
|
||||
# Enumerating differences between media descriptors
|
||||
# from file (=current) vs from stored in database (=target)
|
||||
try:
|
||||
mdcs = MediaDescriptorChangeSet(self.context,
|
||||
self.__targetMediaDescriptor,
|
||||
self.__sourceMediaDescriptor)
|
||||
|
||||
self.__mediaChangeSetObj = mdcs.getChangeSetObj()
|
||||
except ValueError:
|
||||
self.__mediaChangeSetObj = {}
|
||||
|
||||
|
||||
def updateDifferences(self):
|
||||
|
||||
self.loadProperties()
|
||||
|
||||
self.differencesTable.clear()
|
||||
|
||||
|
||||
if MediaDescriptorChangeSet.TAGS_KEY in self.__mediaChangeSetObj.keys():
|
||||
|
||||
if DIFF_ADDED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys():
|
||||
for tagKey, tagValue in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_ADDED_KEY].items():
|
||||
if tagKey not in self.__ignoreGlobalKeys:
|
||||
row = (f"add media tag: key='{tagKey}' value='{tagValue}'",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
if DIFF_REMOVED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys():
|
||||
for tagKey, tagValue in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_REMOVED_KEY].items():
|
||||
if tagKey not in self.__ignoreGlobalKeys and tagKey not in self.__removeGlobalKeys:
|
||||
row = (f"remove media tag: key='{tagKey}' value='{tagValue}'",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
if DIFF_CHANGED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys():
|
||||
for tagKey, tagValue in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_CHANGED_KEY].items():
|
||||
if tagKey not in self.__ignoreGlobalKeys:
|
||||
row = (f"change media tag: key='{tagKey}' value='{tagValue}'",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
|
||||
if MediaDescriptorChangeSet.TRACKS_KEY in self.__mediaChangeSetObj.keys():
|
||||
|
||||
if DIFF_ADDED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys():
|
||||
|
||||
trackDescriptor: TrackDescriptor
|
||||
for trackIndex, trackDescriptor in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_ADDED_KEY].items():
|
||||
row = (f"add {trackDescriptor.getType().label()} track: index={trackDescriptor.getIndex()} lang={trackDescriptor.getLanguage().threeLetter()}",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
if DIFF_REMOVED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys():
|
||||
for trackIndex, trackDescriptor in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_REMOVED_KEY].items():
|
||||
row = (f"remove stream #{trackIndex}",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
if DIFF_CHANGED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys():
|
||||
|
||||
changedTracks: dict = self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_CHANGED_KEY]
|
||||
|
||||
targetTrackDescriptors = self.__targetMediaDescriptor.getTrackDescriptors()
|
||||
|
||||
trackDiffObj: dict
|
||||
for trackIndex, trackDiffObj in changedTracks.items():
|
||||
|
||||
ttd: TrackDescriptor = targetTrackDescriptors[trackIndex]
|
||||
|
||||
|
||||
if MediaDescriptorChangeSet.TAGS_KEY in trackDiffObj.keys():
|
||||
|
||||
removedTags = (trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_REMOVED_KEY]
|
||||
if DIFF_REMOVED_KEY in trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY].keys() else {})
|
||||
for tagKey, tagValue in removedTags.items():
|
||||
row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) remove key={tagKey} value={tagValue}",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
addedTags = (trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_ADDED_KEY]
|
||||
if DIFF_ADDED_KEY in trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY].keys() else {})
|
||||
for tagKey, tagValue in addedTags.items():
|
||||
row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) add key={tagKey} value={tagValue}",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
changedTags = (trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_CHANGED_KEY]
|
||||
if DIFF_CHANGED_KEY in trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY].keys() else {})
|
||||
for tagKey, tagValue in changedTags.items():
|
||||
row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) change key={tagKey} value={tagValue}",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
|
||||
if MediaDescriptorChangeSet.DISPOSITION_SET_KEY in trackDiffObj.keys():
|
||||
|
||||
addedDispositions = (trackDiffObj[MediaDescriptorChangeSet.DISPOSITION_SET_KEY][DIFF_ADDED_KEY]
|
||||
if DIFF_ADDED_KEY in trackDiffObj[MediaDescriptorChangeSet.DISPOSITION_SET_KEY].keys() else set())
|
||||
for ad in addedDispositions:
|
||||
row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) add disposition={ad.label()}",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
removedDispositions = (trackDiffObj[MediaDescriptorChangeSet.DISPOSITION_SET_KEY][DIFF_REMOVED_KEY]
|
||||
if DIFF_REMOVED_KEY in trackDiffObj[MediaDescriptorChangeSet.DISPOSITION_SET_KEY].keys() else set())
|
||||
for rd in removedDispositions:
|
||||
row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) remove disposition={rd.label()}",)
|
||||
self.differencesTable.add_row(*map(str, row))
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if self.__currentPattern is None:
|
||||
row = (' ', '<New show>', ' ') # Convert each element to a string before adding
|
||||
self.showsTable.add_row(*map(str, row))
|
||||
|
||||
for show in self.__sc.getAllShows():
|
||||
row = (int(show.id), show.name, show.year) # Convert each element to a string before adding
|
||||
self.showsTable.add_row(*map(str, row))
|
||||
|
||||
for mediaTagKey, mediaTagValue in self.__sourceMediaDescriptor.getTags().items():
|
||||
|
||||
textColor = None
|
||||
if mediaTagKey in self.__ignoreGlobalKeys:
|
||||
textColor = 'blue'
|
||||
if mediaTagKey in self.__removeGlobalKeys:
|
||||
textColor = 'red'
|
||||
|
||||
row = (formatRichColor(mediaTagKey, textColor), formatRichColor(mediaTagValue, textColor)) # Convert each element to a string before adding
|
||||
self.mediaTagsTable.add_row(*map(str, row))
|
||||
|
||||
self.updateTracks()
|
||||
|
||||
|
||||
if self.__currentPattern is not None:
|
||||
|
||||
showIdentifier = self.__currentPattern.getShowId()
|
||||
showRowIndex = self.getRowIndexFromShowId(showIdentifier)
|
||||
if showRowIndex is not None:
|
||||
self.showsTable.move_cursor(row=showRowIndex)
|
||||
|
||||
self.query_one("#pattern_input", Input).value = self.__currentPattern.getPattern()
|
||||
|
||||
self.updateDifferences()
|
||||
|
||||
else:
|
||||
|
||||
self.query_one("#pattern_input", Input).value = self.__mediaFilename
|
||||
self.highlightPattern(True)
|
||||
|
||||
|
||||
def highlightPattern(self, state : bool):
|
||||
if state:
|
||||
self.query_one("#pattern_input", Input).styles.background = 'red'
|
||||
else:
|
||||
self.query_one("#pattern_input", Input).styles.background = None
|
||||
|
||||
|
||||
def updateTracks(self):
|
||||
|
||||
self.tracksTable.clear()
|
||||
|
||||
# trackDescriptorList = self.__sourceMediaDescriptor.getAllTrackDescriptors()
|
||||
trackDescriptorList = self.__sourceMediaDescriptor.getTrackDescriptors()
|
||||
|
||||
typeCounter = {}
|
||||
|
||||
for td in trackDescriptorList:
|
||||
|
||||
trackType = td.getType()
|
||||
if not trackType in typeCounter.keys():
|
||||
typeCounter[trackType] = 0
|
||||
|
||||
dispoSet = td.getDispositionSet()
|
||||
audioLayout = td.getAudioLayout()
|
||||
row = (td.getIndex(),
|
||||
trackType.label(),
|
||||
typeCounter[trackType],
|
||||
td.getCodec().label(),
|
||||
audioLayout.label() if trackType == TrackType.AUDIO
|
||||
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
|
||||
td.getLanguage().label(),
|
||||
td.getTitle(),
|
||||
'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No',
|
||||
'Yes' if TrackDisposition.FORCED in dispoSet else 'No')
|
||||
|
||||
self.tracksTable.add_row(*map(str, row))
|
||||
|
||||
typeCounter[trackType] += 1
|
||||
|
||||
|
||||
def compose(self):
|
||||
|
||||
# Create the DataTable widget
|
||||
self.showsTable = DataTable(classes="two")
|
||||
|
||||
# Define the columns with headers
|
||||
self.column_key_show_id = self.showsTable.add_column("ID", width=10)
|
||||
self.column_key_show_name = self.showsTable.add_column("Name", width=80)
|
||||
self.column_key_show_year = self.showsTable.add_column("Year", width=10)
|
||||
|
||||
self.showsTable.cursor_type = 'row'
|
||||
|
||||
|
||||
self.mediaTagsTable = DataTable(classes="two")
|
||||
|
||||
# Define the columns with headers
|
||||
self.column_key_track_tag_key = self.mediaTagsTable.add_column("Key", width=30)
|
||||
self.column_key_track_tag_value = self.mediaTagsTable.add_column("Value", width=70)
|
||||
|
||||
self.mediaTagsTable.cursor_type = 'row'
|
||||
|
||||
|
||||
self.tracksTable = DataTable(classes="two")
|
||||
|
||||
# Define the columns with headers
|
||||
self.column_key_track_index = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_INDEX_COLUMN_LABEL, width=5)
|
||||
self.column_key_track_type = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_TYPE_COLUMN_LABEL, width=10)
|
||||
self.column_key_track_sub_index = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL, width=8)
|
||||
self.column_key_track_codec = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_CODEC_COLUMN_LABEL, width=10)
|
||||
self.column_key_track_layout = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_LAYOUT_COLUMN_LABEL, width=10)
|
||||
self.column_key_track_language = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_LANGUAGE_COLUMN_LABEL, width=15)
|
||||
self.column_key_track_title = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_TITLE_COLUMN_LABEL, width=48)
|
||||
self.column_key_track_default = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_DEFAULT_COLUMN_LABEL, width=8)
|
||||
self.column_key_track_forced = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_FORCED_COLUMN_LABEL, width=8)
|
||||
|
||||
self.tracksTable.cursor_type = 'row'
|
||||
|
||||
|
||||
# Create the DataTable widget
|
||||
self.differencesTable = DataTable(id='differences-table') # classes="triple"
|
||||
|
||||
# Define the columns with headers
|
||||
self.column_key_differences = self.differencesTable.add_column(MediaDetailsScreen.DIFFERENCES_TABLE_DIFFERENCES_COLUMN_LABEL, width=100)
|
||||
|
||||
self.differencesTable.cursor_type = 'row'
|
||||
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
|
||||
# 1
|
||||
yield Static("Show")
|
||||
yield self.showsTable
|
||||
yield Static(" ")
|
||||
yield self.differencesTable
|
||||
|
||||
# 2
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
# 3
|
||||
yield Static(" ")
|
||||
yield Button("Substitute", id="pattern_button")
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
# 4
|
||||
yield Static("Pattern")
|
||||
yield Input(type="text", id='pattern_input', classes="two")
|
||||
|
||||
yield Static(" ")
|
||||
|
||||
# 5
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
# 6
|
||||
yield Static("Media Tags")
|
||||
yield self.mediaTagsTable
|
||||
yield Static(" ")
|
||||
|
||||
# 7
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
# 8
|
||||
yield Static(" ")
|
||||
yield Button("Set Default", id="select_default_button")
|
||||
yield Button("Set Forced", id="select_forced_button")
|
||||
yield Static(" ")
|
||||
# 9
|
||||
yield Static("Streams")
|
||||
yield self.tracksTable
|
||||
yield Static(" ")
|
||||
|
||||
yield Footer()
|
||||
|
||||
|
||||
def getPatternObjFromInput(self):
|
||||
"""Returns show id and pattern as obj from corresponding inputs"""
|
||||
patternObj = {}
|
||||
try:
|
||||
patternObj['show_id'] = self.getSelectedShowDescriptor().getId()
|
||||
patternObj['pattern'] = str(self.query_one("#pattern_input", Input).value)
|
||||
except:
|
||||
return {}
|
||||
return patternObj
|
||||
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
|
||||
if event.button.id == "pattern_button":
|
||||
|
||||
pattern = self.query_one("#pattern_input", Input).value
|
||||
|
||||
patternMatch = re.search(FileProperties.SE_INDICATOR_PATTERN, pattern)
|
||||
|
||||
if patternMatch:
|
||||
self.query_one("#pattern_input", Input).value = pattern.replace(patternMatch.group(1), FileProperties.SE_INDICATOR_PATTERN)
|
||||
|
||||
|
||||
if event.button.id == "select_default_button":
|
||||
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
|
||||
self.__sourceMediaDescriptor.setDefaultSubTrack(selectedTrackDescriptor.getType(), selectedTrackDescriptor.getSubIndex())
|
||||
self.updateTracks()
|
||||
|
||||
if event.button.id == "select_forced_button":
|
||||
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
|
||||
self.__sourceMediaDescriptor.setForcedSubTrack(selectedTrackDescriptor.getType(), selectedTrackDescriptor.getSubIndex())
|
||||
self.updateTracks()
|
||||
|
||||
|
||||
def getSelectedTrackDescriptor(self):
|
||||
"""Returns a partial track descriptor"""
|
||||
try:
|
||||
|
||||
# Fetch the currently selected row when 'Enter' is pressed
|
||||
#selected_row_index = self.table.cursor_row
|
||||
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_track_data = self.tracksTable.get_row(row_key)
|
||||
|
||||
kwargs = {}
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = self.context
|
||||
kwargs[TrackDescriptor.INDEX_KEY] = int(selected_track_data[0])
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(selected_track_data[1])
|
||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(selected_track_data[2])
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.fromLabel(selected_track_data[3])
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(selected_track_data[4])
|
||||
|
||||
return TrackDescriptor(**kwargs)
|
||||
else:
|
||||
return None
|
||||
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def getSelectedShowDescriptor(self) -> ShowDescriptor:
|
||||
|
||||
try:
|
||||
|
||||
row_key, col_key = self.showsTable.coordinate_to_cell_key(self.showsTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.showsTable.get_row(row_key)
|
||||
|
||||
try:
|
||||
kwargs = {}
|
||||
|
||||
kwargs[ShowDescriptor.CONTEXT_KEY] = self.context
|
||||
kwargs[ShowDescriptor.ID_KEY] = int(selected_row_data[0])
|
||||
kwargs[ShowDescriptor.NAME_KEY] = str(selected_row_data[1])
|
||||
kwargs[ShowDescriptor.YEAR_KEY] = int(selected_row_data[2])
|
||||
|
||||
return ShowDescriptor(**kwargs)
|
||||
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def handle_new_pattern(self, showDescriptor: ShowDescriptor):
|
||||
""""""
|
||||
|
||||
if type(showDescriptor) is not ShowDescriptor:
|
||||
raise TypeError("MediaDetailsScreen.handle_new_pattern(): Argument 'showDescriptor' has to be of type ShowDescriptor")
|
||||
|
||||
self.removeShow()
|
||||
|
||||
showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId())
|
||||
if showRowIndex is None:
|
||||
show = (showDescriptor.getId(), showDescriptor.getName(), showDescriptor.getYear())
|
||||
self.showsTable.add_row(*map(str, show))
|
||||
|
||||
showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId())
|
||||
if showRowIndex is not None:
|
||||
self.showsTable.move_cursor(row=showRowIndex)
|
||||
|
||||
patternObj = self.getPatternObjFromInput()
|
||||
|
||||
if patternObj:
|
||||
mediaTags = {}
|
||||
for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items():
|
||||
|
||||
# Filter tags that make no sense to preserve
|
||||
if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys:
|
||||
mediaTags[tagKey] = tagValue
|
||||
|
||||
patternId = self.__pc.savePatternSchema(
|
||||
patternObj,
|
||||
trackDescriptors=self.__sourceMediaDescriptor.getTrackDescriptors(),
|
||||
mediaTags=mediaTags,
|
||||
)
|
||||
if patternId:
|
||||
self.highlightPattern(False)
|
||||
|
||||
|
||||
def action_new_pattern(self):
|
||||
"""Adding new patterns
|
||||
|
||||
If the corresponding show does not exists in DB it is added beforehand"""
|
||||
|
||||
selectedShowDescriptor = self.getSelectedShowDescriptor()
|
||||
|
||||
#HINT: Callback is invoked after this method has exited. As a workaround the callback is executed directly
|
||||
# from here with a mock-up screen result containing the necessary part of keys to perform correctly.
|
||||
if selectedShowDescriptor is None:
|
||||
self.app.push_screen(ShowDetailsScreen(), self.handle_new_pattern)
|
||||
else:
|
||||
self.handle_new_pattern(selectedShowDescriptor)
|
||||
|
||||
|
||||
def action_update_pattern(self):
|
||||
"""Updating patterns
|
||||
|
||||
When updating the database the actions must reverse the difference (eq to diff db->file)"""
|
||||
|
||||
if self.__currentPattern is not None:
|
||||
patternObj = self.getPatternObjFromInput()
|
||||
if (patternObj
|
||||
and self.__currentPattern.getPattern() != patternObj['pattern']):
|
||||
return self.__pc.updatePattern(self.__currentPattern.getId(), patternObj)
|
||||
|
||||
self.loadProperties()
|
||||
|
||||
# __mediaChangeSetObj is file vs database
|
||||
if MediaDescriptorChangeSet.TAGS_KEY in self.__mediaChangeSetObj.keys():
|
||||
|
||||
if DIFF_ADDED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys():
|
||||
for addedTagKey in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_ADDED_KEY].keys():
|
||||
# click.ClickException(f"delete media tag patternId={self.__currentPattern.getId()} addedTagKey={addedTagKey}")
|
||||
self.__tac.deleteMediaTagByKey(self.__currentPattern.getId(), addedTagKey)
|
||||
|
||||
if DIFF_REMOVED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys():
|
||||
for removedTagKey in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_REMOVED_KEY].keys():
|
||||
currentTags = self.__sourceMediaDescriptor.getTags()
|
||||
# click.ClickException(f"delete media tag patternId={self.__currentPattern.getId()} removedTagKey={removedTagKey} currentTags={currentTags[removedTagKey]}")
|
||||
self.__tac.updateMediaTag(self.__currentPattern.getId(), removedTagKey, currentTags[removedTagKey])
|
||||
|
||||
if DIFF_CHANGED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys():
|
||||
for changedTagKey in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_CHANGED_KEY].keys():
|
||||
currentTags = self.__sourceMediaDescriptor.getTags()
|
||||
# click.ClickException(f"delete media tag patternId={self.__currentPattern.getId()} changedTagKey={changedTagKey} currentTags={currentTags[changedTagKey]}")
|
||||
self.__tac.updateMediaTag(self.__currentPattern.getId(), changedTagKey, currentTags[changedTagKey])
|
||||
|
||||
if MediaDescriptorChangeSet.TRACKS_KEY in self.__mediaChangeSetObj.keys():
|
||||
|
||||
if DIFF_ADDED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys():
|
||||
|
||||
for trackIndex, trackDescriptor in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_ADDED_KEY].items():
|
||||
#targetTracks = [t for t in self.__targetMediaDescriptor.getAllTrackDescriptors() if t.getIndex() == addedTrackIndex]
|
||||
# if targetTracks:
|
||||
# self.__tc.deleteTrack(targetTracks[0].getId()) # id
|
||||
# self.__tc.deleteTrack(targetTracks[0].getId())
|
||||
self.__tc.addTrack(trackDescriptor, patternId = self.__currentPattern.getId())
|
||||
|
||||
if DIFF_REMOVED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys():
|
||||
trackDescriptor: TrackDescriptor
|
||||
for trackIndex, trackDescriptor in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_REMOVED_KEY].items():
|
||||
# Track per inspect/update hinzufügen
|
||||
#self.__tc.addTrack(removedTrack, patternId = self.__currentPattern.getId())
|
||||
self.__tc.deleteTrack(trackDescriptor.getId())
|
||||
|
||||
if DIFF_CHANGED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys():
|
||||
|
||||
# [vsTracks[tp].getIndex()] = trackDiff
|
||||
for trackIndex, trackDiff in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_CHANGED_KEY].items():
|
||||
|
||||
targetTracks = [t for t in self.__targetMediaDescriptor.getTrackDescriptors() if t.getIndex() == trackIndex]
|
||||
targetTrackId = targetTracks[0].getId() if targetTracks else None
|
||||
targetTrackIndex = targetTracks[0].getIndex() if targetTracks else None
|
||||
|
||||
changedCurrentTracks = [t for t in self.__sourceMediaDescriptor.getTrackDescriptors() if t.getIndex() == trackIndex]
|
||||
# changedCurrentTrackId #HINT: Undefined as track descriptors do not come from file with track_id
|
||||
|
||||
if TrackDescriptor.TAGS_KEY in trackDiff.keys():
|
||||
tagsDiff = trackDiff[TrackDescriptor.TAGS_KEY]
|
||||
|
||||
if DIFF_ADDED_KEY in tagsDiff.keys():
|
||||
for tagKey, tagValue in tagsDiff[DIFF_ADDED_KEY].items():
|
||||
|
||||
# if targetTracks:
|
||||
# self.__tac.deleteTrackTagByKey(targetTrackId, addedTrackTagKey)
|
||||
self.__tac.updateTrackTag(targetTrackId, tagKey, tagValue)
|
||||
|
||||
|
||||
if DIFF_REMOVED_KEY in tagsDiff.keys():
|
||||
for tagKey, tagValue in tagsDiff[DIFF_REMOVED_KEY].items():
|
||||
# if changedCurrentTracks:
|
||||
# self.__tac.updateTrackTag(targetTrackId, removedTrackTagKey, changedCurrentTracks[0].getTags()[removedTrackTagKey])
|
||||
self.__tac.deleteTrackTagByKey(targetTrackId, tagKey)
|
||||
|
||||
if DIFF_CHANGED_KEY in tagsDiff.keys():
|
||||
for tagKey, tagValue in tagsDiff[DIFF_CHANGED_KEY].items():
|
||||
# if changedCurrentTracks:
|
||||
# self.__tac.updateTrackTag(targetTrackId, changedTrackTagKey, changedCurrentTracks[0].getTags()[changedTrackTagKey])
|
||||
self.__tac.updateTrackTag(targetTrackId, tagKey, tagValue)
|
||||
|
||||
|
||||
if TrackDescriptor.DISPOSITION_SET_KEY in trackDiff.keys():
|
||||
changedTrackDispositionDiff = trackDiff[TrackDescriptor.DISPOSITION_SET_KEY]
|
||||
|
||||
if DIFF_ADDED_KEY in changedTrackDispositionDiff.keys():
|
||||
for changedDisposition in changedTrackDispositionDiff[DIFF_ADDED_KEY]:
|
||||
if targetTrackIndex is not None:
|
||||
self.__tc.setDispositionState(self.__currentPattern.getId(), targetTrackIndex, changedDisposition, True)
|
||||
|
||||
if DIFF_REMOVED_KEY in changedTrackDispositionDiff.keys():
|
||||
for changedDisposition in changedTrackDispositionDiff[DIFF_REMOVED_KEY]:
|
||||
if targetTrackIndex is not None:
|
||||
self.__tc.setDispositionState(self.__currentPattern.getId(), targetTrackIndex, changedDisposition, False)
|
||||
|
||||
|
||||
self.updateDifferences()
|
||||
|
||||
|
||||
|
||||
def action_edit_pattern(self):
|
||||
|
||||
patternObj = self.getPatternObjFromInput()
|
||||
|
||||
if patternObj['pattern']:
|
||||
|
||||
selectedPatternId = self.__pc.findPattern(patternObj)
|
||||
|
||||
if selectedPatternId is None:
|
||||
raise click.ClickException(f"MediaDetailsScreen.action_edit_pattern(): Pattern to edit has no id")
|
||||
|
||||
self.app.push_screen(PatternDetailsScreen(patternId = selectedPatternId, showId = self.getSelectedShowDescriptor().getId()), self.handle_edit_pattern) # <-
|
||||
|
||||
|
||||
def handle_edit_pattern(self, screenResult):
|
||||
self.query_one("#pattern_input", Input).value = screenResult['pattern']
|
||||
self.updateDifferences()
|
||||
from .inspect_details_screen import InspectDetailsScreen as MediaDetailsScreen
|
||||
|
||||
531
src/ffx/media_edit_screen.py
Normal file
531
src/ffx/media_edit_screen.py
Normal file
@@ -0,0 +1,531 @@
|
||||
import os
|
||||
from time import monotonic
|
||||
|
||||
from textual import events, work
|
||||
from textual.containers import Grid
|
||||
from textual.worker import Worker, WorkerState
|
||||
from textual.widgets import Button, Footer, Header, Static
|
||||
|
||||
from ffx.metadata_editor import apply_metadata_edits
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
|
||||
from .i18n import t
|
||||
from .confirm_screen import ConfirmScreen
|
||||
from .media_workflow_screen_base import MediaWorkflowScreenBase
|
||||
from .screen_support import build_screen_log_pane, localized_column_width
|
||||
from .tag_delete_screen import TagDeleteScreen
|
||||
from .tag_details_screen import TagDetailsScreen
|
||||
from .track_details_screen import TrackDetailsScreen
|
||||
|
||||
from .helper import LogLevel
|
||||
|
||||
|
||||
class MediaEditScreen(MediaWorkflowScreenBase):
|
||||
|
||||
GRID_COLUMN_LABEL_MIN = 12
|
||||
GRID_COLUMN_2 = 20
|
||||
GRID_COLUMN_3 = 25
|
||||
GRID_COLUMN_4 = "4fr"
|
||||
GRID_COLUMN_5 = 12
|
||||
GRID_COLUMN_6 = "5fr"
|
||||
|
||||
CSS = f"""
|
||||
|
||||
Grid {{
|
||||
grid-size: 6 10;
|
||||
grid-rows: 2 2 2 8 2 2 8 2 8 2 2;
|
||||
grid-columns: {GRID_COLUMN_LABEL_MIN} {GRID_COLUMN_2} {GRID_COLUMN_3} {GRID_COLUMN_4} {GRID_COLUMN_5} {GRID_COLUMN_6};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 120;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}}
|
||||
|
||||
DataTable .datatable--cursor {{
|
||||
background: darkorange;
|
||||
color: black;
|
||||
}}
|
||||
|
||||
DataTable .datatable--header {{
|
||||
background: steelblue;
|
||||
color: white;
|
||||
}}
|
||||
|
||||
Input {{
|
||||
border: none;
|
||||
}}
|
||||
Button {{
|
||||
border: none;
|
||||
}}
|
||||
|
||||
DataTable {{
|
||||
min-height: 24;
|
||||
width: 100%;
|
||||
}}
|
||||
|
||||
.two {{
|
||||
column-span: 2;
|
||||
}}
|
||||
.three {{
|
||||
column-span: 3;
|
||||
}}
|
||||
.four {{
|
||||
column-span: 4;
|
||||
}}
|
||||
.five {{
|
||||
column-span: 5;
|
||||
}}
|
||||
|
||||
#differences-table {{
|
||||
row-span: 10;
|
||||
}}
|
||||
|
||||
#file_label {{
|
||||
width: 100%;
|
||||
}}
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _grid_columns_spec(cls, label_column_width: int | None = None) -> str:
|
||||
return " ".join(
|
||||
[
|
||||
str(
|
||||
cls.GRID_COLUMN_LABEL_MIN
|
||||
if label_column_width is None
|
||||
else int(label_column_width)
|
||||
),
|
||||
str(cls.GRID_COLUMN_2),
|
||||
str(cls.GRID_COLUMN_3),
|
||||
str(cls.GRID_COLUMN_4),
|
||||
str(cls.GRID_COLUMN_5),
|
||||
str(cls.GRID_COLUMN_6),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
COMMAND_NAME = "edit"
|
||||
EDIT_MODE = True
|
||||
DIFFERENCES_COLUMN_LABEL = "Planned Changes (file->edited output)"
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
("q", "quit_screen", t("Quit")),
|
||||
("a", "apply_changes", t("Apply")),
|
||||
("r", "revert_changes", t("Revert")),
|
||||
]
|
||||
|
||||
def compose(self):
|
||||
self._build_media_tags_table()
|
||||
self._build_tracks_table()
|
||||
self._build_differences_table()
|
||||
|
||||
yield Header()
|
||||
|
||||
with Grid(id="main_grid"):
|
||||
|
||||
# Row 1
|
||||
yield Static(t("File"))
|
||||
yield Static(self._mediaFilename, id="file_label", classes="three", markup=False)
|
||||
yield Static(" ")
|
||||
yield self.differencesTable
|
||||
|
||||
# Row 2
|
||||
yield Static(" ")
|
||||
yield Button(t("Cleanup"), id="cleanup_toggle_button")
|
||||
yield Button(t("Normalize"), id="normalize_toggle_button")
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
# Row 3
|
||||
yield Static(t("Media Tags"))
|
||||
yield Button(t("Add"), id="button_add_tag")
|
||||
yield Button(t("Edit"), id="button_edit_tag")
|
||||
yield Button(t("Delete"), id="button_delete_tag")
|
||||
yield Static(" ")
|
||||
|
||||
# Row 4
|
||||
yield Static(" ")
|
||||
yield self.mediaTagsTable
|
||||
yield Static(" ")
|
||||
|
||||
# Row 5
|
||||
yield Static("", classes="five")
|
||||
|
||||
# Row 6
|
||||
yield Static(t("Streams"))
|
||||
yield Button(t("Edit"), id="button_edit_track")
|
||||
yield Button(t("Set Default"), id="select_default_button")
|
||||
yield Button(t("Set Forced"), id="select_forced_button")
|
||||
yield Static(" ")
|
||||
|
||||
# Row 7
|
||||
yield Static(" ")
|
||||
yield self.tracksTable
|
||||
yield Static(" ")
|
||||
|
||||
# Row 8
|
||||
yield Static("", classes="five")
|
||||
|
||||
# Row 9
|
||||
yield Static(" ")
|
||||
yield Button(t("Apply"), id="apply_button")
|
||||
yield Button(t("Revert"), id="revert_button")
|
||||
yield Button(t("Quit"), id="quit_button")
|
||||
yield Static(" ")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
self._update_grid_layout()
|
||||
self.updateMediaTags()
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
self.updateToggleButtons()
|
||||
self._applyChangesWorker = None
|
||||
|
||||
def on_screen_resume(self, _event: events.ScreenResume) -> None:
|
||||
if not hasattr(self, "tracksTable"):
|
||||
return
|
||||
|
||||
self.refreshAfterDraftChange()
|
||||
self.updateToggleButtons()
|
||||
|
||||
def _update_grid_layout(self) -> None:
|
||||
leftColumnWidth = max(
|
||||
localized_column_width(t("File"), self.GRID_COLUMN_LABEL_MIN),
|
||||
localized_column_width(t("Media Tags"), self.GRID_COLUMN_LABEL_MIN),
|
||||
localized_column_width(t("Streams"), self.GRID_COLUMN_LABEL_MIN),
|
||||
)
|
||||
grid = self.query_one("#main_grid", Grid)
|
||||
grid.styles.grid_columns = self._grid_columns_spec(leftColumnWidth)
|
||||
|
||||
def action_back(self):
|
||||
self.action_quit_screen()
|
||||
|
||||
def setMessage(self, message: str):
|
||||
self._messageText = str(message)
|
||||
if self._messageText:
|
||||
self.notify(self._messageText)
|
||||
|
||||
|
||||
def workerLoggingHandler(self,
|
||||
message: str,
|
||||
level: LogLevel = LogLevel.INFO) -> None:
|
||||
|
||||
if level == LogLevel.DEBUG:
|
||||
self.context["logger"].debug(str(message))
|
||||
elif level == LogLevel.INFO:
|
||||
self.context["logger"].info(str(message))
|
||||
elif level == LogLevel.WARNING:
|
||||
self.context["logger"].warning(str(message))
|
||||
elif level == LogLevel.ERROR:
|
||||
self.context["logger"].error(str(message))
|
||||
elif level == LogLevel.CRITICAL:
|
||||
self.context["logger"].critical(str(message))
|
||||
else:
|
||||
raise Exception(f"Undefined Logging Level (msg={message})")
|
||||
|
||||
|
||||
def _report_apply_timings(self, applyResult: dict, reloadSeconds: float = 0.0) -> None:
|
||||
timings = dict(applyResult.get("timings", {}))
|
||||
ffmpegSeconds = float(timings.get("ffmpeg_seconds", 0.0))
|
||||
replaceSeconds = float(timings.get("replace_seconds", 0.0))
|
||||
writeSeconds = float(timings.get("write_seconds", ffmpegSeconds + replaceSeconds))
|
||||
reloadSeconds = float(reloadSeconds)
|
||||
totalSeconds = writeSeconds + reloadSeconds
|
||||
|
||||
timingSummary = (
|
||||
f"ffx edit timings: ffmpeg={ffmpegSeconds:.2f}s "
|
||||
+ f"replace={replaceSeconds:.2f}s "
|
||||
+ f"reload={reloadSeconds:.2f}s "
|
||||
+ f"total={totalSeconds:.2f}s"
|
||||
)
|
||||
self.context["logger"].info(timingSummary)
|
||||
|
||||
def updateToggleButtons(self):
|
||||
self._set_toggle_button_state(
|
||||
"#cleanup_toggle_button",
|
||||
t("Cleanup"),
|
||||
self._applyCleanup,
|
||||
)
|
||||
self._set_toggle_button_state(
|
||||
"#normalize_toggle_button",
|
||||
t("Normalize"),
|
||||
self._applyNormalization,
|
||||
)
|
||||
|
||||
def _set_toggle_button_state(self, selector: str, label: str, enabled: bool):
|
||||
try:
|
||||
button = self.query_one(selector, Button)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
button.label = label
|
||||
button.styles.color = "black" if enabled else "white"
|
||||
button.styles.background = "darkorange" if enabled else "black"
|
||||
|
||||
def refreshAfterDraftChange(self):
|
||||
self.updateMediaTags()
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "select_default_button":
|
||||
if self.setSelectedTrackDefault():
|
||||
self.refreshAfterDraftChange()
|
||||
|
||||
if event.button.id == "select_forced_button":
|
||||
if self.setSelectedTrackForced():
|
||||
self.refreshAfterDraftChange()
|
||||
|
||||
if event.button.id == "button_add_tag":
|
||||
self.app.push_screen(TagDetailsScreen(), self.handle_update_media_tag)
|
||||
|
||||
if event.button.id == "button_edit_tag":
|
||||
selectedTag = self.getSelectedMediaTag()
|
||||
if selectedTag is not None:
|
||||
self.app.push_screen(
|
||||
TagDetailsScreen(key=selectedTag[0], value=selectedTag[1]),
|
||||
self.handle_update_media_tag,
|
||||
)
|
||||
|
||||
if event.button.id == "button_delete_tag":
|
||||
selectedTag = self.getSelectedMediaTag()
|
||||
if selectedTag is not None:
|
||||
self.app.push_screen(
|
||||
TagDeleteScreen(key=selectedTag[0], value=selectedTag[1]),
|
||||
self.handle_delete_media_tag,
|
||||
)
|
||||
|
||||
if event.button.id == "button_edit_track":
|
||||
self.action_edit_selected_track()
|
||||
|
||||
if event.button.id == "cleanup_toggle_button":
|
||||
self.action_toggle_cleanup()
|
||||
|
||||
if event.button.id == "normalize_toggle_button":
|
||||
self.action_toggle_normalization()
|
||||
|
||||
if event.button.id == "apply_button":
|
||||
self.action_apply_changes()
|
||||
|
||||
if event.button.id == "revert_button":
|
||||
self.action_revert_changes()
|
||||
|
||||
if event.button.id == "quit_button":
|
||||
self.action_quit_screen()
|
||||
|
||||
def action_edit_selected_track(self):
|
||||
selectedTrack = self.getSelectedTrackDescriptor()
|
||||
if selectedTrack is None:
|
||||
self.setMessage(t("Select a stream first."))
|
||||
return
|
||||
|
||||
self.app.push_screen(
|
||||
TrackDetailsScreen(
|
||||
trackDescriptor=selectedTrack,
|
||||
patternLabel=os.path.basename(self._mediaFilename),
|
||||
siblingTrackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(),
|
||||
metadata_only=True,
|
||||
),
|
||||
self.handle_edit_track,
|
||||
)
|
||||
|
||||
def action_toggle_cleanup(self):
|
||||
self.setApplyCleanup(not self._applyCleanup)
|
||||
self.updateToggleButtons()
|
||||
self.updateMediaTags()
|
||||
self.updateDifferences()
|
||||
self.setMessage(
|
||||
t("Cleanup enabled.") if self._applyCleanup else t("Cleanup disabled.")
|
||||
)
|
||||
|
||||
def action_toggle_normalization(self):
|
||||
self.setApplyNormalization(not self._applyNormalization)
|
||||
self.updateToggleButtons()
|
||||
self.updateTracks()
|
||||
self.updateDifferences()
|
||||
self.setMessage(
|
||||
t("Normalization enabled.")
|
||||
if self._applyNormalization
|
||||
else t("Normalization disabled.")
|
||||
)
|
||||
|
||||
def handle_update_media_tag(self, tag):
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
self._sourceMediaDescriptor.getTags()[str(tag[0])] = str(tag[1])
|
||||
self.setMessage(t("Updated media tag {tag!r}.", tag=tag[0]))
|
||||
self.refreshAfterDraftChange()
|
||||
|
||||
def handle_delete_media_tag(self, tag):
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
self._sourceMediaDescriptor.getTags().pop(str(tag[0]), None)
|
||||
self.setMessage(t("Deleted media tag {tag!r}.", tag=tag[0]))
|
||||
self.refreshAfterDraftChange()
|
||||
|
||||
def handle_edit_track(self, trackDescriptor: TrackDescriptor):
|
||||
if trackDescriptor is None:
|
||||
return
|
||||
|
||||
nextSourceMediaDescriptor = self._sourceMediaDescriptor.clone(context=self.context)
|
||||
updatedTracks = nextSourceMediaDescriptor.getTrackDescriptors()
|
||||
replacementTrack = trackDescriptor.clone(context=self.context)
|
||||
replaced = False
|
||||
|
||||
for trackIndex, currentTrack in enumerate(updatedTracks):
|
||||
sameSourceTrack = (
|
||||
currentTrack.getSourceIndex() == replacementTrack.getSourceIndex()
|
||||
and currentTrack.getType() == replacementTrack.getType()
|
||||
)
|
||||
sameVisibleTrack = (
|
||||
currentTrack.getIndex() == replacementTrack.getIndex()
|
||||
and currentTrack.getSubIndex() == replacementTrack.getSubIndex()
|
||||
)
|
||||
if sameSourceTrack or sameVisibleTrack:
|
||||
updatedTracks[trackIndex] = replacementTrack
|
||||
replaced = True
|
||||
break
|
||||
|
||||
if not replaced:
|
||||
self.setMessage(t("Unable to update selected stream."))
|
||||
return
|
||||
|
||||
self._sourceMediaDescriptor = nextSourceMediaDescriptor
|
||||
self.setMessage(
|
||||
t(
|
||||
"Updated stream #{index} ({track_type}).",
|
||||
index=replacementTrack.getIndex(),
|
||||
track_type=t(replacementTrack.getType().label()),
|
||||
)
|
||||
)
|
||||
self.refreshAfterDraftChange()
|
||||
|
||||
def action_apply_changes(self):
|
||||
if not self.hasPendingChanges():
|
||||
self.setMessage(t("No changes to apply."))
|
||||
return
|
||||
|
||||
if self._applyChangesWorker is not None and self._applyChangesWorker.is_running:
|
||||
self.setMessage(t("Apply already running."))
|
||||
return
|
||||
|
||||
self.context["logger"].info(
|
||||
t("Starting metadata apply for {filename}.", filename=self._mediaFilename)
|
||||
)
|
||||
self._applyChangesWorker = self.run_apply_changes_worker()
|
||||
|
||||
@work(
|
||||
thread=True,
|
||||
exclusive=True,
|
||||
group="media-edit-apply",
|
||||
exit_on_error=False,
|
||||
)
|
||||
def run_apply_changes_worker(self):
|
||||
return apply_metadata_edits(
|
||||
self.context,
|
||||
self._mediaFilename,
|
||||
self._baselineMediaDescriptor,
|
||||
self._sourceMediaDescriptor,
|
||||
loggingHandler = self.workerLoggingHandler,
|
||||
)
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
if event.worker is not self._applyChangesWorker:
|
||||
return
|
||||
|
||||
if event.state == WorkerState.ERROR:
|
||||
error = event.worker.error
|
||||
if error is not None:
|
||||
self.context["logger"].error(
|
||||
"Failed to apply metadata edits for %s",
|
||||
self._mediaFilename,
|
||||
exc_info=(type(error), error, error.__traceback__),
|
||||
)
|
||||
self.setMessage(t("Apply failed: {error}", error=error))
|
||||
self._applyChangesWorker = None
|
||||
return
|
||||
|
||||
if event.state != WorkerState.SUCCESS:
|
||||
return
|
||||
|
||||
applyResult = event.worker.result or {}
|
||||
|
||||
if applyResult.get("dry_run", False):
|
||||
self._report_apply_timings(applyResult, reloadSeconds=0.0)
|
||||
self.context["logger"].info(
|
||||
t(
|
||||
"Dry-run prepared temporary output {target_path}.",
|
||||
target_path=applyResult["target_path"],
|
||||
),
|
||||
)
|
||||
self.setMessage(
|
||||
t(
|
||||
"Dry-run: would rewrite via temporary file {target_path}",
|
||||
target_path=applyResult["target_path"],
|
||||
)
|
||||
)
|
||||
self._applyChangesWorker = None
|
||||
return
|
||||
|
||||
reloadStart = monotonic()
|
||||
self.context["logger"].info(t("Reloading file after metadata write."))
|
||||
self.reloadProperties(reset_draft=True)
|
||||
self.refreshAfterDraftChange()
|
||||
reloadSeconds = monotonic() - reloadStart
|
||||
self._report_apply_timings(applyResult, reloadSeconds=reloadSeconds)
|
||||
self.context["logger"].info(t("Changes applied and file reloaded."))
|
||||
self.setMessage(t("Changes applied and file reloaded."))
|
||||
self._applyChangesWorker = None
|
||||
|
||||
def action_revert_changes(self):
|
||||
if not self.hasPendingChanges():
|
||||
self.setMessage(t("No changes to revert."))
|
||||
return
|
||||
|
||||
self.app.push_screen(
|
||||
ConfirmScreen(
|
||||
t("Discard pending metadata changes and reload the file state?"),
|
||||
confirm_label=t("Discard"),
|
||||
cancel_label=t("Keep Editing"),
|
||||
),
|
||||
self.handle_revert_confirmation,
|
||||
)
|
||||
|
||||
def handle_revert_confirmation(self, confirmed):
|
||||
if not confirmed:
|
||||
self.setMessage(t("Keeping pending changes."))
|
||||
return
|
||||
|
||||
self.reloadProperties(reset_draft=True)
|
||||
self.refreshAfterDraftChange()
|
||||
self.setMessage(t("Reverted pending changes."))
|
||||
|
||||
def action_quit_screen(self):
|
||||
if self.hasPendingChanges():
|
||||
self.app.push_screen(
|
||||
ConfirmScreen(
|
||||
t("Discard pending metadata changes and quit?"),
|
||||
confirm_label=t("Discard"),
|
||||
cancel_label=t("Stay"),
|
||||
),
|
||||
self.handle_quit_confirmation,
|
||||
)
|
||||
return
|
||||
|
||||
self.app.exit()
|
||||
|
||||
def handle_quit_confirmation(self, confirmed):
|
||||
if confirmed:
|
||||
self.app.exit()
|
||||
else:
|
||||
self.setMessage(t("Continuing edit session."))
|
||||
452
src/ffx/media_workflow_screen_base.py
Normal file
452
src/ffx/media_workflow_screen_base.py
Normal file
@@ -0,0 +1,452 @@
|
||||
import os
|
||||
|
||||
import click
|
||||
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import DataTable
|
||||
from textual.widgets._data_table import CellDoesNotExist
|
||||
|
||||
from ffx.attachment_format import AttachmentFormat
|
||||
from ffx.audio_layout import AudioLayout
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
|
||||
from ffx.iso_language import IsoLanguage
|
||||
from ffx.media_descriptor import MediaDescriptor
|
||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
from .i18n import t
|
||||
from .screen_support import add_auto_table_column, build_screen_bootstrap, populate_tag_table
|
||||
|
||||
|
||||
class MediaWorkflowScreenBase(Screen):
|
||||
|
||||
|
||||
TRACKS_TABLE_INDEX_COLUMN_LABEL = "Index"
|
||||
TRACKS_TABLE_TYPE_COLUMN_LABEL = "Type"
|
||||
TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL = "SubIndex"
|
||||
TRACKS_TABLE_CODEC_COLUMN_LABEL = "Codec"
|
||||
TRACKS_TABLE_LAYOUT_COLUMN_LABEL = "Layout"
|
||||
TRACKS_TABLE_LANGUAGE_COLUMN_LABEL = "Language"
|
||||
TRACKS_TABLE_TITLE_COLUMN_LABEL = "Title"
|
||||
TRACKS_TABLE_DEFAULT_COLUMN_LABEL = "Default"
|
||||
TRACKS_TABLE_FORCED_COLUMN_LABEL = "Forced"
|
||||
|
||||
DIFFERENCES_COLUMN_LABEL = "Differences"
|
||||
COMMAND_NAME = ""
|
||||
EDIT_MODE = False
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
bootstrap = build_screen_bootstrap(self.app.getContext())
|
||||
self.context = bootstrap.context
|
||||
|
||||
self._applyCleanup = False
|
||||
self._applyNormalization = bool(self.context.get("apply_metadata_normalization", True))
|
||||
self._removeGlobalKeys = []
|
||||
self._ignoreGlobalKeys = []
|
||||
self._apply_bootstrap_settings(bootstrap)
|
||||
|
||||
command = self.context.get("command")
|
||||
if command != self.COMMAND_NAME:
|
||||
raise click.ClickException(
|
||||
f"{type(self).__name__}.__init__(): Can only perform command '{self.COMMAND_NAME}'"
|
||||
)
|
||||
|
||||
arguments = self.context.get("arguments", {})
|
||||
self._mediaFilename = arguments.get("filename", "")
|
||||
if not self._mediaFilename:
|
||||
raise click.ClickException(
|
||||
f"{type(self).__name__}.__init__(): Argument 'filename' is required"
|
||||
)
|
||||
if not os.path.isfile(self._mediaFilename):
|
||||
raise click.ClickException(
|
||||
f"{type(self).__name__}.__init__(): Media file {self._mediaFilename} does not exist"
|
||||
)
|
||||
|
||||
self._baselineMediaDescriptor = None
|
||||
self._sourceMediaDescriptor = None
|
||||
self._targetMediaDescriptor = None
|
||||
self._currentPattern = None
|
||||
self._mediaChangeSetObj = {}
|
||||
self._messageText = ""
|
||||
self._trackRowData: dict[object, TrackDescriptor] = {}
|
||||
self._sourceMediaTagRowData: dict[object, tuple[str, str]] = {}
|
||||
|
||||
self.reloadProperties(reset_draft=True)
|
||||
|
||||
def _apply_bootstrap_settings(self, bootstrap) -> None:
|
||||
self._applyCleanup = bootstrap.apply_cleanup
|
||||
self._removeGlobalKeys = bootstrap.remove_global_keys
|
||||
self._ignoreGlobalKeys = bootstrap.ignore_global_keys
|
||||
|
||||
def refreshCleanupSettings(self) -> None:
|
||||
self._apply_bootstrap_settings(build_screen_bootstrap(self.context))
|
||||
|
||||
def setApplyCleanup(self, enabled: bool) -> None:
|
||||
self.context["apply_metadata_cleanup"] = bool(enabled)
|
||||
self.refreshCleanupSettings()
|
||||
|
||||
def refreshNormalizationSettings(self) -> None:
|
||||
self._applyNormalization = bool(
|
||||
self.context.get("apply_metadata_normalization", True)
|
||||
)
|
||||
|
||||
def setApplyNormalization(self, enabled: bool) -> None:
|
||||
self.context["apply_metadata_normalization"] = bool(enabled)
|
||||
self.refreshNormalizationSettings()
|
||||
|
||||
def _build_media_tags_table(self):
|
||||
self.mediaTagsTable = DataTable(classes="three")
|
||||
add_auto_table_column(self.mediaTagsTable, t("Key"))
|
||||
add_auto_table_column(self.mediaTagsTable, t("Value"))
|
||||
self.mediaTagsTable.cursor_type = "row"
|
||||
|
||||
def _build_tracks_table(self):
|
||||
self.tracksTable = DataTable(classes="three")
|
||||
self._configure_tracks_table_columns()
|
||||
self.tracksTable.cursor_type = "row"
|
||||
|
||||
def _configure_tracks_table_columns(self):
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_INDEX_COLUMN_LABEL))
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_TYPE_COLUMN_LABEL))
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL))
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_CODEC_COLUMN_LABEL))
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_LAYOUT_COLUMN_LABEL))
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_LANGUAGE_COLUMN_LABEL))
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_TITLE_COLUMN_LABEL))
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_DEFAULT_COLUMN_LABEL))
|
||||
add_auto_table_column(self.tracksTable, t(self.TRACKS_TABLE_FORCED_COLUMN_LABEL))
|
||||
|
||||
def _build_differences_table(self):
|
||||
self.differencesTable = DataTable(id="differences-table")
|
||||
add_auto_table_column(self.differencesTable, t(self.DIFFERENCES_COLUMN_LABEL))
|
||||
self.differencesTable.cursor_type = "row"
|
||||
|
||||
def _track_codec_cell_value(self, trackDescriptor: TrackDescriptor) -> str:
|
||||
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||
attachmentFormat = trackDescriptor.getAttachmentFormat()
|
||||
if attachmentFormat == AttachmentFormat.UNKNOWN:
|
||||
return attachmentFormat.identifier()
|
||||
return attachmentFormat.label()
|
||||
return trackDescriptor.getFormatDescriptor().label()
|
||||
|
||||
def _track_language_cell_value(self, trackDescriptor: TrackDescriptor) -> str:
|
||||
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||
return " "
|
||||
return trackDescriptor.getLanguage().label()
|
||||
|
||||
def _track_disposition_cell_value(
|
||||
self,
|
||||
trackDescriptor: TrackDescriptor,
|
||||
disposition: TrackDisposition,
|
||||
) -> str:
|
||||
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||
return " "
|
||||
return (
|
||||
t("Yes")
|
||||
if disposition in trackDescriptor.getDispositionSet()
|
||||
else t("No")
|
||||
)
|
||||
|
||||
def reloadProperties(self, reset_draft: bool = True):
|
||||
self._mediaFileProperties = FileProperties(self.context, self._mediaFilename)
|
||||
probedMediaDescriptor = self._mediaFileProperties.getMediaDescriptor()
|
||||
|
||||
if self.EDIT_MODE:
|
||||
self._baselineMediaDescriptor = probedMediaDescriptor
|
||||
if reset_draft or self._sourceMediaDescriptor is None:
|
||||
self._sourceMediaDescriptor = probedMediaDescriptor.clone(context=self.context)
|
||||
self._targetMediaDescriptor = self._sourceMediaDescriptor
|
||||
self._currentPattern = None
|
||||
else:
|
||||
self._baselineMediaDescriptor = probedMediaDescriptor
|
||||
self._sourceMediaDescriptor = probedMediaDescriptor
|
||||
self._currentPattern = self._mediaFileProperties.getPattern()
|
||||
self._targetMediaDescriptor = self._resolve_target_media_descriptor(
|
||||
self._currentPattern,
|
||||
self._sourceMediaDescriptor,
|
||||
)
|
||||
|
||||
self.rebuildChangeSet()
|
||||
|
||||
def rebuildChangeSet(self):
|
||||
try:
|
||||
if self.EDIT_MODE:
|
||||
mdcs = MediaDescriptorChangeSet(
|
||||
self.context,
|
||||
self._sourceMediaDescriptor,
|
||||
self._baselineMediaDescriptor,
|
||||
)
|
||||
else:
|
||||
if self._targetMediaDescriptor is None:
|
||||
self._mediaChangeSetObj = {}
|
||||
return
|
||||
mdcs = MediaDescriptorChangeSet(
|
||||
self.context,
|
||||
self._targetMediaDescriptor,
|
||||
self._sourceMediaDescriptor,
|
||||
)
|
||||
|
||||
self._mediaChangeSetObj = mdcs.getChangeSetObj()
|
||||
except ValueError:
|
||||
self._mediaChangeSetObj = {}
|
||||
|
||||
def hasPendingChanges(self) -> bool:
|
||||
return bool(self._mediaChangeSetObj)
|
||||
|
||||
def getDisplayedMediaDescriptor(self) -> MediaDescriptor | None:
|
||||
return self._sourceMediaDescriptor
|
||||
|
||||
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
|
||||
return self.getSelectedTrackDescriptor()
|
||||
|
||||
def _resolve_target_media_descriptor(self, currentPattern, sourceMediaDescriptor):
|
||||
if currentPattern is None:
|
||||
return None
|
||||
|
||||
targetMediaDescriptor = currentPattern.getMediaDescriptor(self.context)
|
||||
styledAssDetector = getattr(
|
||||
sourceMediaDescriptor,
|
||||
"hasStyledAssSubtitlesWithFontAttachments",
|
||||
None,
|
||||
)
|
||||
if callable(styledAssDetector) and styledAssDetector():
|
||||
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
|
||||
sourceMediaDescriptor,
|
||||
AttachmentFormat.TTF,
|
||||
context=self.context,
|
||||
)
|
||||
|
||||
return targetMediaDescriptor
|
||||
|
||||
def updateMediaTags(self):
|
||||
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
|
||||
self._sourceMediaTagRowData = populate_tag_table(
|
||||
self.mediaTagsTable,
|
||||
displayedMediaDescriptor.getTags() if displayedMediaDescriptor is not None else {},
|
||||
ignore_keys=self._ignoreGlobalKeys,
|
||||
remove_keys=self._removeGlobalKeys,
|
||||
)
|
||||
|
||||
def updateTracks(self):
|
||||
self.tracksTable.clear(columns=True)
|
||||
self._configure_tracks_table_columns()
|
||||
self._trackRowData = {}
|
||||
|
||||
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
|
||||
trackDescriptorList = (
|
||||
displayedMediaDescriptor.getTrackDescriptors()
|
||||
if displayedMediaDescriptor is not None
|
||||
else []
|
||||
)
|
||||
typeCounter = {}
|
||||
applyNormalization = bool(getattr(self, "_applyNormalization", False))
|
||||
|
||||
for trackDescriptor in trackDescriptorList:
|
||||
trackType = trackDescriptor.getType()
|
||||
if trackType not in typeCounter:
|
||||
typeCounter[trackType] = 0
|
||||
|
||||
dispositionSet = trackDescriptor.getDispositionSet()
|
||||
audioLayout = trackDescriptor.getAudioLayout()
|
||||
trackTitle = trackDescriptor.getTitle()
|
||||
if (
|
||||
applyNormalization
|
||||
and not str(trackTitle).strip()
|
||||
and trackType in (TrackType.VIDEO, TrackType.AUDIO, TrackType.SUBTITLE)
|
||||
):
|
||||
trackLanguage = trackDescriptor.getLanguage()
|
||||
if trackLanguage != IsoLanguage.UNDEFINED:
|
||||
trackTitle = trackLanguage.label()
|
||||
row = (
|
||||
trackDescriptor.getIndex(),
|
||||
t(trackType.label()),
|
||||
typeCounter[trackType],
|
||||
self._track_codec_cell_value(trackDescriptor),
|
||||
t(audioLayout.label())
|
||||
if trackType == TrackType.AUDIO
|
||||
and audioLayout != AudioLayout.LAYOUT_UNDEFINED
|
||||
else " ",
|
||||
self._track_language_cell_value(trackDescriptor),
|
||||
trackTitle,
|
||||
self._track_disposition_cell_value(
|
||||
trackDescriptor,
|
||||
TrackDisposition.DEFAULT,
|
||||
),
|
||||
self._track_disposition_cell_value(
|
||||
trackDescriptor,
|
||||
TrackDisposition.FORCED,
|
||||
),
|
||||
)
|
||||
|
||||
row_key = self.tracksTable.add_row(*map(str, row))
|
||||
self._trackRowData[row_key] = trackDescriptor
|
||||
typeCounter[trackType] += 1
|
||||
|
||||
def updateDifferences(self):
|
||||
self.rebuildChangeSet()
|
||||
self.differencesTable.clear()
|
||||
|
||||
if not self.EDIT_MODE and self._currentPattern is None:
|
||||
return
|
||||
|
||||
targetDescriptor = (
|
||||
self._sourceMediaDescriptor
|
||||
if self.EDIT_MODE
|
||||
else self._targetMediaDescriptor
|
||||
)
|
||||
targetTrackDescriptorsByIndex = {
|
||||
trackDescriptor.getIndex(): trackDescriptor
|
||||
for trackDescriptor in (
|
||||
targetDescriptor.getTrackDescriptors()
|
||||
if targetDescriptor is not None
|
||||
else []
|
||||
)
|
||||
}
|
||||
|
||||
tagDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {})
|
||||
for tagKey, tagValue in tagDifferences.get(DIFF_ADDED_KEY, {}).items():
|
||||
if tagKey not in self._ignoreGlobalKeys:
|
||||
self.differencesTable.add_row(
|
||||
t("add media tag: key='{key}' value='{value}'", key=tagKey, value=tagValue)
|
||||
)
|
||||
|
||||
for tagKey, tagValue in tagDifferences.get(DIFF_REMOVED_KEY, {}).items():
|
||||
if tagKey in self._ignoreGlobalKeys:
|
||||
continue
|
||||
if not self.EDIT_MODE and tagKey in self._removeGlobalKeys:
|
||||
continue
|
||||
self.differencesTable.add_row(
|
||||
t("remove media tag: key='{key}' value='{value}'", key=tagKey, value=tagValue)
|
||||
)
|
||||
|
||||
for tagKey, tagValue in tagDifferences.get(DIFF_CHANGED_KEY, {}).items():
|
||||
if tagKey not in self._ignoreGlobalKeys:
|
||||
self.differencesTable.add_row(
|
||||
t("change media tag: key='{key}' value='{value}'", key=tagKey, value=tagValue)
|
||||
)
|
||||
|
||||
trackDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TRACKS_KEY, {})
|
||||
|
||||
for trackDescriptor in trackDifferences.get(DIFF_ADDED_KEY, {}).values():
|
||||
self.differencesTable.add_row(
|
||||
t(
|
||||
"add {track_type} track: index={index} lang={language}",
|
||||
track_type=t(trackDescriptor.getType().label()),
|
||||
index=trackDescriptor.getIndex(),
|
||||
language=trackDescriptor.getLanguage().threeLetter(),
|
||||
)
|
||||
)
|
||||
|
||||
for trackIndex in trackDifferences.get(DIFF_REMOVED_KEY, {}).keys():
|
||||
self.differencesTable.add_row(t("remove stream #{index}", index=trackIndex))
|
||||
|
||||
for trackIndex, trackDiffObj in trackDifferences.get(DIFF_CHANGED_KEY, {}).items():
|
||||
targetTrackDescriptor = targetTrackDescriptorsByIndex.get(trackIndex)
|
||||
if targetTrackDescriptor is None:
|
||||
continue
|
||||
|
||||
tagsDiff = trackDiffObj.get(MediaDescriptorChangeSet.TAGS_KEY, {})
|
||||
for tagKey, tagValue in tagsDiff.get(DIFF_REMOVED_KEY, {}).items():
|
||||
self.differencesTable.add_row(
|
||||
t(
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove key={key} value={value}",
|
||||
index=targetTrackDescriptor.getIndex(),
|
||||
track_type=t(targetTrackDescriptor.getType().label()),
|
||||
sub_index=targetTrackDescriptor.getSubIndex(),
|
||||
key=tagKey,
|
||||
value=tagValue,
|
||||
)
|
||||
)
|
||||
for tagKey, tagValue in tagsDiff.get(DIFF_ADDED_KEY, {}).items():
|
||||
self.differencesTable.add_row(
|
||||
t(
|
||||
"change stream #{index} ({track_type}:{sub_index}) add key={key} value={value}",
|
||||
index=targetTrackDescriptor.getIndex(),
|
||||
track_type=t(targetTrackDescriptor.getType().label()),
|
||||
sub_index=targetTrackDescriptor.getSubIndex(),
|
||||
key=tagKey,
|
||||
value=tagValue,
|
||||
)
|
||||
)
|
||||
for tagKey, tagValue in tagsDiff.get(DIFF_CHANGED_KEY, {}).items():
|
||||
self.differencesTable.add_row(
|
||||
t(
|
||||
"change stream #{index} ({track_type}:{sub_index}) change key={key} value={value}",
|
||||
index=targetTrackDescriptor.getIndex(),
|
||||
track_type=t(targetTrackDescriptor.getType().label()),
|
||||
sub_index=targetTrackDescriptor.getSubIndex(),
|
||||
key=tagKey,
|
||||
value=tagValue,
|
||||
)
|
||||
)
|
||||
|
||||
dispositionDiff = trackDiffObj.get(MediaDescriptorChangeSet.DISPOSITION_SET_KEY, {})
|
||||
for addedDisposition in dispositionDiff.get(DIFF_ADDED_KEY, set()):
|
||||
self.differencesTable.add_row(
|
||||
t(
|
||||
"change stream #{index} ({track_type}:{sub_index}) add disposition={disposition}",
|
||||
index=targetTrackDescriptor.getIndex(),
|
||||
track_type=t(targetTrackDescriptor.getType().label()),
|
||||
sub_index=targetTrackDescriptor.getSubIndex(),
|
||||
disposition=t(addedDisposition.label()),
|
||||
)
|
||||
)
|
||||
for removedDisposition in dispositionDiff.get(DIFF_REMOVED_KEY, set()):
|
||||
self.differencesTable.add_row(
|
||||
t(
|
||||
"change stream #{index} ({track_type}:{sub_index}) remove disposition={disposition}",
|
||||
index=targetTrackDescriptor.getIndex(),
|
||||
track_type=t(targetTrackDescriptor.getType().label()),
|
||||
sub_index=targetTrackDescriptor.getSubIndex(),
|
||||
disposition=t(removedDisposition.label()),
|
||||
)
|
||||
)
|
||||
|
||||
def getSelectedMediaTag(self):
|
||||
try:
|
||||
row_key, _ = self.mediaTagsTable.coordinate_to_cell_key(
|
||||
self.mediaTagsTable.cursor_coordinate
|
||||
)
|
||||
if row_key is not None:
|
||||
return self._sourceMediaTagRowData.get(row_key)
|
||||
return None
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
|
||||
def getSelectedTrackDescriptor(self):
|
||||
try:
|
||||
row_key, _ = self.tracksTable.coordinate_to_cell_key(
|
||||
self.tracksTable.cursor_coordinate
|
||||
)
|
||||
if row_key is not None:
|
||||
return self._trackRowData.get(row_key)
|
||||
return None
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
|
||||
def setSelectedTrackDefault(self):
|
||||
selectedTrackDescriptor = self.getTrackEditSourceDescriptor()
|
||||
if selectedTrackDescriptor is None:
|
||||
return False
|
||||
|
||||
self._sourceMediaDescriptor.setDefaultSubTrack(
|
||||
selectedTrackDescriptor.getType(),
|
||||
selectedTrackDescriptor.getSubIndex(),
|
||||
)
|
||||
return True
|
||||
|
||||
def setSelectedTrackForced(self):
|
||||
selectedTrackDescriptor = self.getTrackEditSourceDescriptor()
|
||||
if selectedTrackDescriptor is None:
|
||||
return False
|
||||
|
||||
self._sourceMediaDescriptor.setForcedSubTrack(
|
||||
selectedTrackDescriptor.getType(),
|
||||
selectedTrackDescriptor.getSubIndex(),
|
||||
)
|
||||
return True
|
||||
177
src/ffx/metadata_editor.py
Normal file
177
src/ffx/metadata_editor.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
import os
|
||||
import tempfile
|
||||
from time import monotonic
|
||||
|
||||
from .constants import (
|
||||
DEFAULT_AC3_BANDWIDTH,
|
||||
DEFAULT_DTS_BANDWIDTH,
|
||||
DEFAULT_STEREO_BANDWIDTH,
|
||||
FFMPEG_COMMAND_TOKENS,
|
||||
)
|
||||
from .media_descriptor import MediaDescriptor
|
||||
from .media_descriptor_change_set import MediaDescriptorChangeSet
|
||||
from .process import executeProcess, formatCommandSequence
|
||||
from .video_encoder import VideoEncoder
|
||||
|
||||
from .helper import LogLevel
|
||||
|
||||
|
||||
def create_temporary_output_path(source_path: str) -> str:
|
||||
sourceDirectory = os.path.dirname(os.path.abspath(source_path)) or "."
|
||||
sourceBasename = os.path.basename(source_path)
|
||||
sourceStem, sourceExtension = os.path.splitext(sourceBasename)
|
||||
|
||||
descriptor, temporaryPath = tempfile.mkstemp(
|
||||
prefix=f".{sourceStem}.ffx-edit-",
|
||||
suffix=sourceExtension or ".tmp",
|
||||
dir=sourceDirectory,
|
||||
)
|
||||
os.close(descriptor)
|
||||
os.unlink(temporaryPath)
|
||||
|
||||
return temporaryPath
|
||||
|
||||
|
||||
def build_metadata_edit_context(context: dict) -> dict:
|
||||
editContext = dict(context)
|
||||
editContext["video_encoder"] = VideoEncoder.COPY
|
||||
editContext["perform_cut"] = False
|
||||
editContext["no_signature"] = bool(editContext.get("no_signature", True))
|
||||
editContext["resource_limits"] = dict(editContext.get("resource_limits", {}))
|
||||
editContext["bitrates"] = dict(
|
||||
editContext.get(
|
||||
"bitrates",
|
||||
{
|
||||
"stereo": f"{DEFAULT_STEREO_BANDWIDTH}k",
|
||||
"ac3": f"{DEFAULT_AC3_BANDWIDTH}k",
|
||||
"dts": f"{DEFAULT_DTS_BANDWIDTH}k",
|
||||
},
|
||||
)
|
||||
)
|
||||
editContext["encoding_metadata_tags"] = {}
|
||||
return editContext
|
||||
|
||||
|
||||
def build_metadata_edit_command(
|
||||
context: dict,
|
||||
source_path: str,
|
||||
target_path: str,
|
||||
baseline_descriptor: MediaDescriptor,
|
||||
draft_descriptor: MediaDescriptor,
|
||||
) -> list[str]:
|
||||
changeSet = MediaDescriptorChangeSet(context, draft_descriptor, baseline_descriptor)
|
||||
|
||||
return (
|
||||
list(FFMPEG_COMMAND_TOKENS)
|
||||
+ ["-i", source_path, "-map", "0", "-c", "copy"]
|
||||
+ changeSet.generateMetadataTokens()
|
||||
+ changeSet.generateDispositionTokens()
|
||||
+ [target_path]
|
||||
)
|
||||
|
||||
|
||||
def notify_ffmpeg_invocation(
|
||||
context: dict,
|
||||
command_sequence: list[str],
|
||||
*,
|
||||
loggingHandler = None,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
loggingCallback = loggingHandler or context.get("logging_handler")
|
||||
if not callable(loggingCallback):
|
||||
return
|
||||
|
||||
verbosity = int(context.get("verbosity", 0) or 0)
|
||||
if verbosity > 0:
|
||||
if dry_run:
|
||||
loggingCallback(f"ffmpeg dry-run: {formatCommandSequence(command_sequence)}", level = LogLevel.DEBUG)
|
||||
else:
|
||||
loggingCallback(f"ffmpeg: {formatCommandSequence(command_sequence)}", level = LogLevel.DEBUG)
|
||||
return
|
||||
|
||||
loggingCallback("ffmpeg dry-run prepared.") if dry_run else loggingCallback(
|
||||
"ffmpeg metadata write started."
|
||||
)
|
||||
|
||||
|
||||
def apply_metadata_edits(
|
||||
context: dict,
|
||||
source_path: str,
|
||||
baseline_descriptor: MediaDescriptor,
|
||||
draft_descriptor: MediaDescriptor,
|
||||
*,
|
||||
loggingHandler = None,
|
||||
) -> dict[str, object]:
|
||||
|
||||
temporaryOutputPath = create_temporary_output_path(source_path)
|
||||
|
||||
editContext = build_metadata_edit_context(context)
|
||||
|
||||
commandSequence = build_metadata_edit_command(
|
||||
editContext,
|
||||
source_path,
|
||||
temporaryOutputPath,
|
||||
baseline_descriptor,
|
||||
draft_descriptor,
|
||||
)
|
||||
|
||||
ffmpegSeconds = 0.0
|
||||
replaceSeconds = 0.0
|
||||
|
||||
try:
|
||||
|
||||
if editContext.get("dry_run", False):
|
||||
|
||||
notify_ffmpeg_invocation(
|
||||
editContext,
|
||||
commandSequence,
|
||||
loggingHandler = loggingHandler,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"applied": False,
|
||||
"dry_run": True,
|
||||
"target_path": temporaryOutputPath,
|
||||
"command_sequence": commandSequence,
|
||||
"timings": {
|
||||
"ffmpeg_seconds": ffmpegSeconds,
|
||||
"replace_seconds": replaceSeconds,
|
||||
"write_seconds": ffmpegSeconds + replaceSeconds,
|
||||
},
|
||||
}
|
||||
|
||||
notify_ffmpeg_invocation(editContext,
|
||||
commandSequence,
|
||||
loggingHandler = loggingHandler)
|
||||
|
||||
ffmpegStart = monotonic()
|
||||
_out, err, rc = executeProcess(commandSequence, context=editContext)
|
||||
ffmpegSeconds = monotonic() - ffmpegStart
|
||||
|
||||
if rc:
|
||||
raise click.ClickException(f"ffmpeg edit failed: rc={rc} error={err}")
|
||||
|
||||
replaceStart = monotonic()
|
||||
os.replace(temporaryOutputPath, source_path)
|
||||
replaceSeconds = monotonic() - replaceStart
|
||||
|
||||
return {
|
||||
"applied": True,
|
||||
"dry_run": False,
|
||||
"target_path": source_path,
|
||||
"command_sequence": commandSequence,
|
||||
"timings": {
|
||||
"ffmpeg_seconds": ffmpegSeconds,
|
||||
"replace_seconds": replaceSeconds,
|
||||
"write_seconds": ffmpegSeconds + replaceSeconds,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception:
|
||||
if os.path.exists(temporaryOutputPath):
|
||||
os.remove(temporaryOutputPath)
|
||||
raise
|
||||
@@ -1,47 +0,0 @@
|
||||
import os, sys, importlib, inspect, glob, re
|
||||
|
||||
from ffx.configuration_controller import ConfigurationController
|
||||
from ffx.database import databaseContext
|
||||
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
class Conversion():
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._context = {}
|
||||
self._context['config'] = ConfigurationController()
|
||||
|
||||
self._context['database'] = databaseContext(databasePath=self._context['config'].getDatabaseFilePath())
|
||||
|
||||
self.__databaseSession: sessionmaker = self._context['database']['session']
|
||||
self.__databaseEngine: Engine = self._context['database']['engine']
|
||||
|
||||
|
||||
@staticmethod
|
||||
def list():
|
||||
|
||||
basePath = os.path.dirname(__file__)
|
||||
|
||||
filenamePattern = re.compile("conversion_([0-9]+)_([0-9]+)\\.py")
|
||||
|
||||
filenameList = [os.path.basename(fp) for fp in glob.glob(f"{ basePath }/*.py") if fp != __file__]
|
||||
|
||||
versionTupleList = [(fm.group(1), fm.group(2)) for fn in filenameList if (fm := filenamePattern.search(fn))]
|
||||
|
||||
return versionTupleList
|
||||
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(versionFrom, versionTo):
|
||||
importlib.import_module(f"ffx.model.conversions.conversion_{ versionFrom }_{ versionTo }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.model.conversions.conversion_{ versionFrom }_{ versionTo }"]):
|
||||
#HINT: Excluding DispositionCombination as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'Conversion' and name.startswith('Conversion'):
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def getAllClassReferences():
|
||||
return [Conversion.getClassReference(verFrom, verTo) for verFrom, verTo in Conversion.list()]
|
||||
@@ -1,17 +0,0 @@
|
||||
import os, sys, importlib, inspect, glob, re
|
||||
|
||||
from .conversion import Conversion
|
||||
|
||||
|
||||
class Conversion_2_3(Conversion):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def applyConversion(self):
|
||||
|
||||
s = self.__databaseSession()
|
||||
e = self.__databaseEngine
|
||||
|
||||
with e.connect() as c:
|
||||
c.execute("ALTER TABLE user ADD COLUMN email VARCHAR(255)")
|
||||
@@ -1,7 +0,0 @@
|
||||
import os, sys, importlib, inspect, glob, re
|
||||
|
||||
from .conversion import Conversion
|
||||
|
||||
|
||||
class Conversion_3_4(Conversion):
|
||||
pass
|
||||
82
src/ffx/model/migration/__init__.py
Normal file
82
src/ffx/model/migration/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import importlib
|
||||
import importlib.util
|
||||
|
||||
|
||||
class DatabaseVersionException(Exception):
|
||||
def __init__(self, errorMessage):
|
||||
super().__init__(errorMessage)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationStep:
|
||||
versionFrom: int
|
||||
versionTo: int
|
||||
moduleName: str
|
||||
modulePresent: bool
|
||||
|
||||
|
||||
def getMigrationStepModuleName(versionFrom: int, versionTo: int) -> str:
|
||||
return f"ffx.model.migration.step_{int(versionFrom)}_{int(versionTo)}"
|
||||
|
||||
|
||||
def migrationStepModuleExists(versionFrom: int, versionTo: int) -> bool:
|
||||
moduleName = getMigrationStepModuleName(versionFrom, versionTo)
|
||||
|
||||
try:
|
||||
return importlib.util.find_spec(moduleName) is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def getMigrationPlan(currentVersion: int, targetVersion: int) -> list[MigrationStep]:
|
||||
version = int(currentVersion)
|
||||
target = int(targetVersion)
|
||||
migrationPlan = []
|
||||
|
||||
while version < target:
|
||||
nextVersion = version + 1
|
||||
migrationPlan.append(
|
||||
MigrationStep(
|
||||
versionFrom=version,
|
||||
versionTo=nextVersion,
|
||||
moduleName=getMigrationStepModuleName(version, nextVersion),
|
||||
modulePresent=migrationStepModuleExists(version, nextVersion),
|
||||
)
|
||||
)
|
||||
version = nextVersion
|
||||
|
||||
return migrationPlan
|
||||
|
||||
|
||||
def loadMigrationStep(versionFrom: int, versionTo: int):
|
||||
moduleName = getMigrationStepModuleName(versionFrom, versionTo)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(moduleName)
|
||||
except ModuleNotFoundError as ex:
|
||||
if ex.name == moduleName:
|
||||
raise DatabaseVersionException(
|
||||
f"No migration path from database version {versionFrom} to {versionTo}"
|
||||
) from ex
|
||||
raise
|
||||
|
||||
migrationStep = getattr(module, "applyMigration", None)
|
||||
if migrationStep is None:
|
||||
raise DatabaseVersionException(
|
||||
f"Migration module {moduleName} does not define applyMigration()"
|
||||
)
|
||||
|
||||
return migrationStep
|
||||
|
||||
|
||||
def migrateDatabase(databaseContext, currentVersion: int, targetVersion: int, setDatabaseVersion):
|
||||
for migrationStepInfo in getMigrationPlan(currentVersion, targetVersion):
|
||||
migrationStep = loadMigrationStep(
|
||||
migrationStepInfo.versionFrom,
|
||||
migrationStepInfo.versionTo,
|
||||
)
|
||||
migrationStep(databaseContext)
|
||||
setDatabaseVersion(databaseContext, migrationStepInfo.versionTo)
|
||||
84
src/ffx/model/migration/step_2_3.py
Normal file
84
src/ffx/model/migration/step_2_3.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
|
||||
def applyMigration(databaseContext):
|
||||
engine = databaseContext['engine']
|
||||
inspector = inspect(engine)
|
||||
shiftedSeasonColumns = {
|
||||
column['name']
|
||||
for column in inspector.get_columns('shifted_seasons')
|
||||
}
|
||||
showColumns = {
|
||||
column['name']
|
||||
for column in inspector.get_columns('shows')
|
||||
}
|
||||
|
||||
with engine.begin() as connection:
|
||||
if 'pattern_id' not in shiftedSeasonColumns:
|
||||
connection.execute(text("PRAGMA foreign_keys=OFF"))
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE shifted_seasons_v3 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
show_id INTEGER,
|
||||
pattern_id INTEGER,
|
||||
original_season INTEGER,
|
||||
first_episode INTEGER DEFAULT -1,
|
||||
last_episode INTEGER DEFAULT -1,
|
||||
season_offset INTEGER DEFAULT 0,
|
||||
episode_offset INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(pattern_id) REFERENCES patterns(id) ON DELETE CASCADE,
|
||||
CHECK (
|
||||
(show_id IS NOT NULL AND pattern_id IS NULL)
|
||||
OR (show_id IS NULL AND pattern_id IS NOT NULL)
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO shifted_seasons_v3 (
|
||||
id,
|
||||
show_id,
|
||||
pattern_id,
|
||||
original_season,
|
||||
first_episode,
|
||||
last_episode,
|
||||
season_offset,
|
||||
episode_offset
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
show_id,
|
||||
NULL,
|
||||
original_season,
|
||||
first_episode,
|
||||
last_episode,
|
||||
season_offset,
|
||||
episode_offset
|
||||
FROM shifted_seasons
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(text("DROP TABLE shifted_seasons"))
|
||||
connection.execute(text("ALTER TABLE shifted_seasons_v3 RENAME TO shifted_seasons"))
|
||||
connection.execute(
|
||||
text("CREATE INDEX ix_shifted_seasons_show_id ON shifted_seasons(show_id)")
|
||||
)
|
||||
connection.execute(
|
||||
text("CREATE INDEX ix_shifted_seasons_pattern_id ON shifted_seasons(pattern_id)")
|
||||
)
|
||||
connection.execute(text("PRAGMA foreign_keys=ON"))
|
||||
|
||||
if 'quality' not in showColumns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
|
||||
)
|
||||
if 'notes' not in showColumns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
|
||||
)
|
||||
@@ -7,6 +7,7 @@ from .show import Base, Show
|
||||
|
||||
from ffx.media_descriptor import MediaDescriptor
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
|
||||
class Pattern(Base):
|
||||
@@ -35,6 +36,7 @@ class Pattern(Base):
|
||||
tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
|
||||
media_tags = relationship('MediaTag', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
shifted_seasons = relationship('ShiftedSeason', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
|
||||
quality = Column(Integer, default=0)
|
||||
|
||||
@@ -75,6 +77,8 @@ class Pattern(Base):
|
||||
subIndexCounter = {}
|
||||
for track in self.tracks:
|
||||
trackType = track.getType()
|
||||
if trackType == TrackType.ATTACHMENT:
|
||||
continue
|
||||
if not trackType in subIndexCounter.keys():
|
||||
subIndexCounter[trackType] = 0
|
||||
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType]))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import click
|
||||
|
||||
from sqlalchemy import Column, Integer, ForeignKey
|
||||
from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, Integer
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .show import Base, Show
|
||||
@@ -9,6 +9,14 @@ from .show import Base, Show
|
||||
class ShiftedSeason(Base):
|
||||
|
||||
__tablename__ = 'shifted_seasons'
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(show_id IS NOT NULL AND pattern_id IS NULL) OR (show_id IS NULL AND pattern_id IS NOT NULL)",
|
||||
name="ck_shifted_seasons_single_owner",
|
||||
),
|
||||
Index("ix_shifted_seasons_show_id", "show_id"),
|
||||
Index("ix_shifted_seasons_pattern_id", "pattern_id"),
|
||||
)
|
||||
|
||||
# v1.x
|
||||
id = Column(Integer, primary_key=True)
|
||||
@@ -19,9 +27,12 @@ class ShiftedSeason(Base):
|
||||
# pattern: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
# v1.x
|
||||
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"))
|
||||
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"), nullable=True)
|
||||
show = relationship(Show, back_populates='shifted_seasons', lazy='joined')
|
||||
|
||||
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"), nullable=True)
|
||||
pattern = relationship('Pattern', back_populates='shifted_seasons', lazy='joined')
|
||||
|
||||
# v2.0
|
||||
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
|
||||
# show: Mapped["Show"] = relationship(back_populates="patterns")
|
||||
@@ -39,6 +50,12 @@ class ShiftedSeason(Base):
|
||||
def getId(self):
|
||||
return self.id
|
||||
|
||||
def getShowId(self):
|
||||
return self.show_id
|
||||
|
||||
def getPatternId(self):
|
||||
return self.pattern_id
|
||||
|
||||
|
||||
def getOriginalSeason(self):
|
||||
return self.original_season
|
||||
@@ -61,6 +78,8 @@ class ShiftedSeason(Base):
|
||||
|
||||
shiftedSeasonObj = {}
|
||||
|
||||
shiftedSeasonObj['show_id'] = self.getShowId()
|
||||
shiftedSeasonObj['pattern_id'] = self.getPatternId()
|
||||
shiftedSeasonObj['original_season'] = self.getOriginalSeason()
|
||||
shiftedSeasonObj['first_episode'] = self.getFirstEpisode()
|
||||
shiftedSeasonObj['last_episode'] = self.getLastEpisode()
|
||||
@@ -68,4 +87,3 @@ class ShiftedSeason(Base):
|
||||
shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset()
|
||||
|
||||
return shiftedSeasonObj
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# from typing import List
|
||||
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship, declarative_base, sessionmaker
|
||||
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
@@ -45,6 +45,8 @@ class Show(Base):
|
||||
index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS)
|
||||
indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS)
|
||||
indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS)
|
||||
quality = Column(Integer, default=0)
|
||||
notes = Column(Text, default='')
|
||||
|
||||
|
||||
def getDescriptor(self, context):
|
||||
@@ -58,5 +60,7 @@ class Show(Base):
|
||||
kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] = int(self.index_episode_digits)
|
||||
kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] = int(self.indicator_season_digits)
|
||||
kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.indicator_episode_digits)
|
||||
kwargs[ShowDescriptor.QUALITY_KEY] = int(self.quality or 0)
|
||||
kwargs[ShowDescriptor.NOTES_KEY] = str(self.notes or '')
|
||||
|
||||
return ShowDescriptor(**kwargs)
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship, declarative_base, sessionmaker
|
||||
|
||||
from .show import Base
|
||||
|
||||
from ffx.attachment_format import AttachmentFormat
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
from ffx.iso_language import IsoLanguage
|
||||
@@ -132,9 +133,16 @@ class Track(Base):
|
||||
|
||||
if trackType in [t.label() for t in TrackType]:
|
||||
|
||||
if trackType == TrackType.ATTACHMENT.label():
|
||||
storedFormatIdentifier = AttachmentFormat.identifyFfprobeStream(streamObj).identifier()
|
||||
else:
|
||||
storedFormatIdentifier = TrackCodec.identify(
|
||||
streamObj.get(TrackDescriptor.FFPROBE_CODEC_KEY)
|
||||
).identifier()
|
||||
|
||||
return cls(pattern_id = patternId,
|
||||
track_type = trackType,
|
||||
codec_name = streamObj[TrackDescriptor.FFPROBE_CODEC_NAME_KEY],
|
||||
codec_name = storedFormatIdentifier,
|
||||
disposition_flags = sum([2**t.index() for (k,v) in streamObj[TrackDescriptor.FFPROBE_DISPOSITION_KEY].items()
|
||||
if v and (t := TrackDisposition.find(k)) is not None]),
|
||||
audio_layout = AudioLayout.identify(streamObj))
|
||||
@@ -153,8 +161,20 @@ class Track(Base):
|
||||
return TrackType.fromIndex(self.track_type)
|
||||
|
||||
def getCodec(self) -> TrackCodec:
|
||||
if self.getType() == TrackType.ATTACHMENT:
|
||||
return TrackCodec.UNKNOWN
|
||||
return TrackCodec.identify(self.codec_name)
|
||||
|
||||
def getAttachmentFormat(self) -> AttachmentFormat:
|
||||
if self.getType() != TrackType.ATTACHMENT:
|
||||
return AttachmentFormat.UNKNOWN
|
||||
return AttachmentFormat.identify(self.codec_name)
|
||||
|
||||
def getFormatDescriptor(self):
|
||||
if self.getType() == TrackType.ATTACHMENT:
|
||||
return self.getAttachmentFormat()
|
||||
return self.getCodec()
|
||||
|
||||
def getIndex(self):
|
||||
return int(self.index) if self.index is not None else -1
|
||||
|
||||
@@ -206,6 +226,9 @@ class Track(Base):
|
||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex
|
||||
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType()
|
||||
if self.getType() == TrackType.ATTACHMENT:
|
||||
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.getAttachmentFormat()
|
||||
else:
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec()
|
||||
|
||||
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = self.getDispositionSet()
|
||||
|
||||
@@ -8,6 +8,7 @@ from ffx.model.track import Track
|
||||
from ffx.model.track_tag import TrackTag
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
|
||||
class DuplicatePatternMatchError(click.ClickException):
|
||||
@@ -86,12 +87,16 @@ class PatternController:
|
||||
)
|
||||
|
||||
normalized_descriptors = []
|
||||
filtered_attachments = False
|
||||
for trackDescriptor in trackDescriptors:
|
||||
if type(trackDescriptor) is not TrackDescriptor:
|
||||
raise TypeError(
|
||||
"PatternController: All track descriptors are required to be of type TrackDescriptor"
|
||||
)
|
||||
normalized_descriptors.append(trackDescriptor)
|
||||
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||
filtered_attachments = True
|
||||
continue
|
||||
normalized_descriptors.append(trackDescriptor.clone())
|
||||
|
||||
if not normalized_descriptors:
|
||||
raise InvalidPatternSchemaError(
|
||||
@@ -102,6 +107,10 @@ class PatternController:
|
||||
normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
|
||||
)
|
||||
|
||||
if filtered_attachments:
|
||||
for index, descriptor in enumerate(normalized_descriptors):
|
||||
descriptor.setIndex(index)
|
||||
|
||||
index_set = {descriptor.getIndex() for descriptor in normalized_descriptors}
|
||||
expected_indexes = set(range(len(normalized_descriptors)))
|
||||
if index_set != expected_indexes:
|
||||
@@ -134,7 +143,7 @@ class PatternController:
|
||||
def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track:
|
||||
track = Track(
|
||||
track_type=int(trackDescriptor.getType().index()),
|
||||
codec_name=str(trackDescriptor.getCodec().identifier()),
|
||||
codec_name=str(trackDescriptor.getFormatDescriptor().identifier()),
|
||||
index=int(trackDescriptor.getIndex()),
|
||||
source_index=int(trackDescriptor.getSourceIndex()),
|
||||
disposition_flags=int(
|
||||
@@ -170,7 +179,7 @@ class PatternController:
|
||||
pattern.tracks.append(self._build_track_row(trackDescriptor))
|
||||
|
||||
def _validate_persisted_pattern(self, pattern: Pattern):
|
||||
if not pattern.tracks:
|
||||
if not any(track.getType() != TrackType.ATTACHMENT for track in pattern.tracks):
|
||||
raise InvalidPatternSchemaError(
|
||||
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
|
||||
)
|
||||
|
||||
@@ -4,8 +4,10 @@ from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button
|
||||
from textual.containers import Grid
|
||||
|
||||
from .i18n import t
|
||||
from .show_controller import ShowController
|
||||
from .pattern_controller import PatternController
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
from ffx.model.pattern import Pattern
|
||||
|
||||
@@ -13,15 +15,22 @@ from ffx.model.pattern import Pattern
|
||||
# Screen[dict[int, str, int]]
|
||||
class PatternDeleteScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 2;
|
||||
grid-rows: 2 auto;
|
||||
grid-columns: 30 330;
|
||||
grid-columns: 18 5fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 90;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -59,6 +68,10 @@ class PatternDeleteScreen(Screen):
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
if self.__showDescriptor:
|
||||
self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")
|
||||
if not self.__pattern is None:
|
||||
@@ -70,24 +83,31 @@ class PatternDeleteScreen(Screen):
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
# Row 1
|
||||
yield Static(t("Are you sure to delete the following filename pattern?"), id="toplabel", classes="two")
|
||||
|
||||
yield Static("Are you sure to delete the following filename pattern?", id="toplabel", classes="two")
|
||||
|
||||
# Row 2
|
||||
yield Static("", classes="two")
|
||||
|
||||
yield Static("Pattern")
|
||||
# Row 3
|
||||
yield Static(t("Pattern"))
|
||||
yield Static("", id="patternlabel")
|
||||
|
||||
# Row 4
|
||||
yield Static("", classes="two")
|
||||
|
||||
yield Static("from show")
|
||||
# Row 5
|
||||
yield Static(t("from show"))
|
||||
yield Static("", id="showlabel")
|
||||
|
||||
# Row 6
|
||||
yield Static("", classes="two")
|
||||
|
||||
yield Button("Delete", id="delete_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 7
|
||||
yield Button(t("Delete"), id="delete_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
@@ -109,3 +129,5 @@ class PatternDeleteScreen(Screen):
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import click, re
|
||||
from typing import List
|
||||
|
||||
from textual import events
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button, Input, DataTable, TextArea
|
||||
from textual.containers import Grid
|
||||
@@ -9,10 +10,19 @@ from ffx.model.pattern import Pattern
|
||||
|
||||
from .track_details_screen import TrackDetailsScreen
|
||||
from .track_delete_screen import TrackDeleteScreen
|
||||
from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
|
||||
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
|
||||
|
||||
from .tag_details_screen import TagDetailsScreen
|
||||
from .tag_delete_screen import TagDeleteScreen
|
||||
from .screen_support import build_screen_bootstrap, build_screen_controllers
|
||||
from .screen_support import (
|
||||
add_auto_table_column,
|
||||
build_screen_bootstrap,
|
||||
build_screen_controllers,
|
||||
build_screen_log_pane,
|
||||
go_back_or_exit,
|
||||
populate_tag_table,
|
||||
)
|
||||
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
@@ -24,22 +34,29 @@ from textual.widgets._data_table import CellDoesNotExist
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.iso_language import IsoLanguage
|
||||
from ffx.audio_layout import AudioLayout
|
||||
|
||||
from ffx.helper import formatRichColor, removeRichColor
|
||||
from ffx.model.shifted_season import ShiftedSeason
|
||||
from .i18n import t
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
class PatternDetailsScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 7 17;
|
||||
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 2 2;
|
||||
grid-columns: 25 25 25 25 25 25 25;
|
||||
grid-size: 7 20;
|
||||
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 8 2 2 2 2;
|
||||
grid-columns: 18 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 140;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -51,6 +68,7 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
DataTable {
|
||||
min-height: 6;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
DataTable .datatable--cursor {
|
||||
@@ -70,6 +88,9 @@ class PatternDetailsScreen(Screen):
|
||||
.three {
|
||||
column-span: 3;
|
||||
}
|
||||
.two {
|
||||
column-span: 2;
|
||||
}
|
||||
|
||||
.four {
|
||||
column-span: 4;
|
||||
@@ -96,7 +117,7 @@ class PatternDetailsScreen(Screen):
|
||||
}
|
||||
|
||||
.yellow {
|
||||
tint: yellow 40%;
|
||||
color: yellow;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -115,21 +136,27 @@ class PatternDetailsScreen(Screen):
|
||||
show=True,
|
||||
track=True,
|
||||
tag=True,
|
||||
shifted_season=True,
|
||||
)
|
||||
self.__pc = controllers['pattern']
|
||||
self.__sc = controllers['show']
|
||||
self.__tc = controllers['track']
|
||||
self.__tac = controllers['tag']
|
||||
self.__ssc = controllers['shifted_season']
|
||||
|
||||
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None
|
||||
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
|
||||
self.__draftTracks : List[TrackDescriptor] = []
|
||||
self.__draftTags : dict[str, str] = {}
|
||||
self.__trackRowData: dict[object, TrackDescriptor] = {}
|
||||
self.__tagRowData: dict[object, tuple[str, str]] = {}
|
||||
self.__shiftedSeasonRowData: dict[object, dict[str, int | None]] = {}
|
||||
|
||||
|
||||
def updateTracks(self):
|
||||
|
||||
self.tracksTable.clear()
|
||||
self.__trackRowData = {}
|
||||
|
||||
tracks = self.getCurrentTrackDescriptors()
|
||||
|
||||
@@ -149,18 +176,19 @@ class PatternDetailsScreen(Screen):
|
||||
audioLayout = td.getAudioLayout()
|
||||
|
||||
row = (td.getIndex(),
|
||||
trackType.label(),
|
||||
t(trackType.label()),
|
||||
typeCounter[trackType],
|
||||
td.getCodec().label(),
|
||||
audioLayout.label() if trackType == TrackType.AUDIO
|
||||
td.getFormatDescriptor().label(),
|
||||
t(audioLayout.label()) if trackType == TrackType.AUDIO
|
||||
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
|
||||
trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ',
|
||||
td.getTitle(),
|
||||
'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No',
|
||||
'Yes' if TrackDisposition.FORCED in dispoSet else 'No',
|
||||
t('Yes') if TrackDisposition.DEFAULT in dispoSet else t('No'),
|
||||
t('Yes') if TrackDisposition.FORCED in dispoSet else t('No'),
|
||||
td.getSourceIndex())
|
||||
|
||||
self.tracksTable.add_row(*map(str, row))
|
||||
row_key = self.tracksTable.add_row(*map(str, row))
|
||||
self.__trackRowData[row_key] = td
|
||||
|
||||
typeCounter[trackType] += 1
|
||||
|
||||
@@ -238,31 +266,75 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
|
||||
def updateTags(self):
|
||||
|
||||
self.tagsTable.clear()
|
||||
|
||||
tags = (
|
||||
self.__tac.findAllMediaTags(self.__pattern.getId())
|
||||
if self.__pattern is not None
|
||||
else self.__draftTags
|
||||
)
|
||||
|
||||
for tagKey, tagValue in tags.items():
|
||||
self.__tagRowData = populate_tag_table(
|
||||
self.tagsTable,
|
||||
tags,
|
||||
ignore_keys=self.__ignoreGlobalKeys,
|
||||
remove_keys=self.__removeGlobalKeys,
|
||||
)
|
||||
|
||||
textColor = None
|
||||
if tagKey in self.__ignoreGlobalKeys:
|
||||
textColor = 'blue'
|
||||
if tagKey in self.__removeGlobalKeys:
|
||||
textColor = 'red'
|
||||
def updateShiftedSeasons(self):
|
||||
|
||||
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
|
||||
self.tagsTable.add_row(*map(str, row))
|
||||
self.shiftedSeasonsTable.clear()
|
||||
self.__shiftedSeasonRowData = {}
|
||||
|
||||
if self.__pattern is None:
|
||||
return
|
||||
|
||||
shiftedSeason: ShiftedSeason
|
||||
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(patternId=self.__pattern.getId()):
|
||||
shiftedSeasonObj = shiftedSeason.getObj()
|
||||
shiftedSeasonObj['id'] = shiftedSeason.getId()
|
||||
|
||||
firstEpisode = shiftedSeasonObj['first_episode']
|
||||
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
|
||||
|
||||
lastEpisode = shiftedSeasonObj['last_episode']
|
||||
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
|
||||
|
||||
row = (
|
||||
shiftedSeasonObj['original_season'],
|
||||
firstEpisodeStr,
|
||||
lastEpisodeStr,
|
||||
shiftedSeasonObj['season_offset'],
|
||||
shiftedSeasonObj['episode_offset'],
|
||||
)
|
||||
|
||||
row_key = self.shiftedSeasonsTable.add_row(*map(str, row))
|
||||
self.__shiftedSeasonRowData[row_key] = shiftedSeasonObj
|
||||
|
||||
def getSelectedShiftedSeasonObjFromInput(self):
|
||||
|
||||
shiftedSeasonObj = {}
|
||||
|
||||
try:
|
||||
row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(
|
||||
self.shiftedSeasonsTable.cursor_coordinate
|
||||
)
|
||||
|
||||
if row_key is not None:
|
||||
shiftedSeasonObj = dict(self.__shiftedSeasonRowData.get(row_key, {}))
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
|
||||
return shiftedSeasonObj
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
if not self.__showDescriptor is None:
|
||||
self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")
|
||||
self.updateShowQualityHint()
|
||||
|
||||
if self.__pattern is not None:
|
||||
|
||||
@@ -276,6 +348,18 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
self.updateTags()
|
||||
self.updateTracks()
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
def on_screen_resume(self, _event: events.ScreenResume) -> None:
|
||||
if not hasattr(self, "tracksTable") or not hasattr(self, "tagsTable"):
|
||||
return
|
||||
|
||||
self.updateShowQualityHint()
|
||||
self.updateTags()
|
||||
self.updateTracks()
|
||||
|
||||
if self.__pattern is not None and hasattr(self, "shiftedSeasonsTable"):
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
def compose(self):
|
||||
|
||||
@@ -283,116 +367,167 @@ class PatternDetailsScreen(Screen):
|
||||
self.tagsTable = DataTable(classes="seven")
|
||||
|
||||
# Define the columns with headers
|
||||
self.column_key_tag_key = self.tagsTable.add_column("Key", width=50)
|
||||
self.column_key_tag_value = self.tagsTable.add_column("Value", width=100)
|
||||
self.column_key_tag_key = add_auto_table_column(self.tagsTable, t("Key"))
|
||||
self.column_key_tag_value = add_auto_table_column(self.tagsTable, t("Value"))
|
||||
|
||||
self.tagsTable.cursor_type = 'row'
|
||||
|
||||
|
||||
self.tracksTable = DataTable(id="tracks_table", classes="seven")
|
||||
|
||||
self.column_key_track_index = self.tracksTable.add_column("Index", width=5)
|
||||
self.column_key_track_type = self.tracksTable.add_column("Type", width=10)
|
||||
self.column_key_track_sub_index = self.tracksTable.add_column("SubIndex", width=8)
|
||||
self.column_key_track_codec = self.tracksTable.add_column("Codec", width=10)
|
||||
self.column_key_track_audio_layout = self.tracksTable.add_column("Layout", width=10)
|
||||
self.column_key_track_language = self.tracksTable.add_column("Language", width=15)
|
||||
self.column_key_track_title = self.tracksTable.add_column("Title", width=48)
|
||||
self.column_key_track_default = self.tracksTable.add_column("Default", width=8)
|
||||
self.column_key_track_forced = self.tracksTable.add_column("Forced", width=8)
|
||||
self.column_key_track_source_index = self.tracksTable.add_column("SrcIndex", width=8)
|
||||
self.column_key_track_index = add_auto_table_column(self.tracksTable, t("Index"))
|
||||
self.column_key_track_type = add_auto_table_column(self.tracksTable, t("Type"))
|
||||
self.column_key_track_sub_index = add_auto_table_column(self.tracksTable, t("SubIndex"))
|
||||
self.column_key_track_codec = add_auto_table_column(self.tracksTable, t("Codec"))
|
||||
self.column_key_track_audio_layout = add_auto_table_column(self.tracksTable, t("Layout"))
|
||||
self.column_key_track_language = add_auto_table_column(self.tracksTable, t("Language"))
|
||||
self.column_key_track_title = add_auto_table_column(self.tracksTable, t("Title"))
|
||||
self.column_key_track_default = add_auto_table_column(self.tracksTable, t("Default"))
|
||||
self.column_key_track_forced = add_auto_table_column(self.tracksTable, t("Forced"))
|
||||
self.column_key_track_source_index = add_auto_table_column(self.tracksTable, t("SrcIndex"))
|
||||
|
||||
self.tracksTable.cursor_type = 'row'
|
||||
|
||||
self.shiftedSeasonsTable = DataTable(classes="seven")
|
||||
|
||||
self.column_key_original_season = add_auto_table_column(self.shiftedSeasonsTable, t("Source Season"))
|
||||
self.column_key_first_episode = add_auto_table_column(self.shiftedSeasonsTable, t("First Episode"))
|
||||
self.column_key_last_episode = add_auto_table_column(self.shiftedSeasonsTable, t("Last Episode"))
|
||||
self.column_key_season_offset = add_auto_table_column(self.shiftedSeasonsTable, t("Season Offset"))
|
||||
self.column_key_episode_offset = add_auto_table_column(self.shiftedSeasonsTable, t("Episode Offset"))
|
||||
|
||||
self.shiftedSeasonsTable.cursor_type = 'row'
|
||||
|
||||
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
|
||||
# 1
|
||||
yield Static("Edit filename pattern" if self.__pattern is not None else "New filename pattern", id="toplabel")
|
||||
# Row 1
|
||||
yield Static(t("Edit filename pattern") if self.__pattern is not None else t("New filename pattern"), id="toplabel")
|
||||
yield Input(type="text", id="pattern_input", classes="six")
|
||||
|
||||
# 2
|
||||
yield Static("from show")
|
||||
# Row 2
|
||||
yield Static(t("from show"))
|
||||
yield Static("", id="showlabel", classes="five")
|
||||
yield Button("Substitute pattern", id="pattern_button")
|
||||
yield Button(t("Substitute pattern"), id="pattern_button")
|
||||
|
||||
# 3
|
||||
# Row 3
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
|
||||
|
||||
# 4
|
||||
yield Static("Quality")
|
||||
# Row 4
|
||||
yield Static(t("Quality"))
|
||||
yield Input(type="integer", id="quality_input")
|
||||
yield Static(' ', classes="five")
|
||||
yield Static(" ")
|
||||
yield Static("", id="show_quality_hint", classes="two yellow")
|
||||
yield Static(' ', classes="two")
|
||||
|
||||
|
||||
# 5
|
||||
# Row 5
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
|
||||
# 6
|
||||
yield Static("Notes")
|
||||
# Row 6
|
||||
yield Static(t("Notes"))
|
||||
yield Static(" ", classes="six")
|
||||
|
||||
# 7
|
||||
# Row 7
|
||||
yield TextArea(id="notes_textarea", classes="four_box seven")
|
||||
|
||||
|
||||
# 8
|
||||
# Row 8
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 9
|
||||
yield Static("Media Tags")
|
||||
yield Button("Add", id="button_add_tag")
|
||||
yield Button("Edit", id="button_edit_tag")
|
||||
yield Button("Delete", id="button_delete_tag")
|
||||
# Row 9
|
||||
yield Static(t("Numbering Mapping"))
|
||||
if self.__pattern is not None:
|
||||
yield Button(t("Add"), id="button_add_shifted_season")
|
||||
yield Button(t("Edit"), id="button_edit_shifted_season")
|
||||
yield Button(t("Delete"), id="button_delete_shifted_season")
|
||||
else:
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
# 10
|
||||
# Row 10
|
||||
yield self.shiftedSeasonsTable
|
||||
|
||||
# Row 11
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# Row 12
|
||||
yield Static(t("Media Tags"))
|
||||
yield Button(t("Add"), id="button_add_tag")
|
||||
yield Button(t("Edit"), id="button_edit_tag")
|
||||
yield Button(t("Delete"), id="button_delete_tag")
|
||||
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
# Row 13
|
||||
yield self.tagsTable
|
||||
|
||||
# 11
|
||||
# Row 14
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 12
|
||||
yield Static("Streams")
|
||||
yield Button("Add", id="button_add_track")
|
||||
yield Button("Edit", id="button_edit_track")
|
||||
yield Button("Delete", id="button_delete_track")
|
||||
# Row 15
|
||||
yield Static(t("Streams"))
|
||||
yield Button(t("Add"), id="button_add_track")
|
||||
yield Button(t("Edit"), id="button_edit_track")
|
||||
yield Button(t("Delete"), id="button_delete_track")
|
||||
|
||||
yield Static(" ")
|
||||
yield Button("Up", id="button_track_up")
|
||||
yield Button("Down", id="button_track_down")
|
||||
yield Button(t("Up"), id="button_track_up")
|
||||
yield Button(t("Down"), id="button_track_down")
|
||||
|
||||
# 13
|
||||
# Row 16
|
||||
yield self.tracksTable
|
||||
|
||||
# 14
|
||||
# Row 17
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 15
|
||||
# Row 18
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 16
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 19
|
||||
yield Button(t("Save"), id="save_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 17
|
||||
# Row 20
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
def getPatternFromInput(self):
|
||||
return str(self.query_one("#pattern_input", Input).value)
|
||||
|
||||
def getShowQualityHintText(self):
|
||||
if self.__showDescriptor is None:
|
||||
return ""
|
||||
|
||||
showQuality = int(self.__showDescriptor.getQuality() or 0)
|
||||
if showQuality <= 0:
|
||||
return ""
|
||||
|
||||
patternQuality = int(getattr(self.__pattern, "quality", 0) or 0)
|
||||
if patternQuality > 0:
|
||||
return ""
|
||||
|
||||
return f"{t('Show')}: {showQuality}"
|
||||
|
||||
def updateShowQualityHint(self):
|
||||
self.query_one("#show_quality_hint", Static).update(self.getShowQualityHintText())
|
||||
|
||||
def getQualityFromInput(self):
|
||||
try:
|
||||
return int(self.query_one("#quality_input", Input).value)
|
||||
@@ -410,15 +545,7 @@ class PatternDetailsScreen(Screen):
|
||||
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_track_data = self.tracksTable.get_row(row_key)
|
||||
|
||||
trackIndex = int(selected_track_data[0])
|
||||
trackSubIndex = int(selected_track_data[2])
|
||||
|
||||
for trackDescriptor in self.getCurrentTrackDescriptors():
|
||||
if (trackDescriptor.getIndex() == trackIndex
|
||||
and trackDescriptor.getSubIndex() == trackSubIndex):
|
||||
return trackDescriptor
|
||||
return self.__trackRowData.get(row_key)
|
||||
|
||||
return None
|
||||
|
||||
@@ -436,12 +563,7 @@ class PatternDetailsScreen(Screen):
|
||||
row_key, col_key = self.tagsTable.coordinate_to_cell_key(self.tagsTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_tag_data = self.tagsTable.get_row(row_key)
|
||||
|
||||
tagKey = removeRichColor(selected_tag_data[0])
|
||||
tagValue = removeRichColor(selected_tag_data[1])
|
||||
|
||||
return tagKey, tagValue
|
||||
return self.__tagRowData.get(row_key)
|
||||
|
||||
else:
|
||||
return None
|
||||
@@ -486,6 +608,35 @@ class PatternDetailsScreen(Screen):
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
if event.button.id == "button_add_shifted_season":
|
||||
if self.__pattern is not None:
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDetailsScreen(patternId=self.__pattern.getId()),
|
||||
self.handle_update_shifted_season,
|
||||
)
|
||||
|
||||
if event.button.id == "button_edit_shifted_season":
|
||||
selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput()
|
||||
if 'id' in selectedShiftedSeasonObj.keys():
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDetailsScreen(
|
||||
patternId=self.__pattern.getId(),
|
||||
shiftedSeasonId=selectedShiftedSeasonObj['id'],
|
||||
),
|
||||
self.handle_update_shifted_season,
|
||||
)
|
||||
|
||||
if event.button.id == "button_delete_shifted_season":
|
||||
selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput()
|
||||
if 'id' in selectedShiftedSeasonObj.keys():
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDeleteScreen(
|
||||
patternId=self.__pattern.getId(),
|
||||
shiftedSeasonId=selectedShiftedSeasonObj['id'],
|
||||
),
|
||||
self.handle_delete_shifted_season,
|
||||
)
|
||||
|
||||
|
||||
numTracks = len(self.getCurrentTrackDescriptors())
|
||||
|
||||
@@ -654,3 +805,12 @@ class PatternDetailsScreen(Screen):
|
||||
self.updateTags()
|
||||
else:
|
||||
raise click.ClickException('tag delete failed')
|
||||
|
||||
def handle_update_shifted_season(self, screenResult):
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
def handle_delete_shifted_season(self, screenResult):
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
from typing import Iterable, List
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Iterable, List
|
||||
|
||||
from .logging_utils import get_ffx_logger
|
||||
|
||||
@@ -118,6 +121,8 @@ def executeProcess(
|
||||
directory: str = None,
|
||||
context: dict = None,
|
||||
timeoutSeconds: float = None,
|
||||
stdoutLineHandler: Callable[[str], bool] | None = None,
|
||||
stderrLineHandler: Callable[[str], bool] | None = None,
|
||||
):
|
||||
|
||||
logger = context['logger'] if context is not None and 'logger' in context else get_ffx_logger()
|
||||
@@ -131,6 +136,16 @@ def executeProcess(
|
||||
formatCommandSequence(wrappedCommandSequence),
|
||||
)
|
||||
|
||||
if stdoutLineHandler is not None or stderrLineHandler is not None:
|
||||
return executeStreamingProcess(
|
||||
wrappedCommandSequence,
|
||||
directory=directory,
|
||||
logger=logger,
|
||||
timeoutSeconds=timeoutSeconds,
|
||||
stdoutLineHandler=stdoutLineHandler,
|
||||
stderrLineHandler=stderrLineHandler,
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
wrappedCommandSequence,
|
||||
@@ -167,3 +182,162 @@ def executeProcess(
|
||||
)
|
||||
|
||||
return completed.stdout, completed.stderr, completed.returncode
|
||||
|
||||
|
||||
def terminateProcess(process: subprocess.Popen, *, killAfterSeconds: float = 1.0) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
if hasattr(os, "killpg"):
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
else:
|
||||
process.terminate()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + killAfterSeconds
|
||||
while process.poll() is None and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
if process.poll() is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
if hasattr(os, "killpg"):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
|
||||
def readProcessStream(
|
||||
stream,
|
||||
outputParts: list[str],
|
||||
lineHandler: Callable[[str], bool] | None,
|
||||
stopRequested: threading.Event,
|
||||
logger,
|
||||
) -> None:
|
||||
try:
|
||||
for line in iter(stream.readline, ''):
|
||||
outputParts.append(line)
|
||||
|
||||
if lineHandler is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
if lineHandler(line):
|
||||
stopRequested.set()
|
||||
except Exception:
|
||||
logger.exception("Process line handler raised an exception")
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
|
||||
def executeStreamingProcess(
|
||||
commandSequence: List[str],
|
||||
*,
|
||||
directory: str = None,
|
||||
logger = None,
|
||||
timeoutSeconds: float = None,
|
||||
stdoutLineHandler: Callable[[str], bool] | None = None,
|
||||
stderrLineHandler: Callable[[str], bool] | None = None,
|
||||
):
|
||||
logger = logger or get_ffx_logger()
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
commandSequence,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=directory,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError as ex:
|
||||
error = (
|
||||
"Command not found while running "
|
||||
+ f"{formatCommandSequence(commandSequence)}: {ex.filename or ex}"
|
||||
)
|
||||
logger.error(error)
|
||||
return '', error, COMMAND_NOT_FOUND_RETURN_CODE
|
||||
|
||||
stdoutParts: list[str] = []
|
||||
stderrParts: list[str] = []
|
||||
stopRequested = threading.Event()
|
||||
timedOut = False
|
||||
|
||||
stdoutThread = threading.Thread(
|
||||
target=readProcessStream,
|
||||
args=(
|
||||
process.stdout,
|
||||
stdoutParts,
|
||||
stdoutLineHandler,
|
||||
stopRequested,
|
||||
logger,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
stderrThread = threading.Thread(
|
||||
target=readProcessStream,
|
||||
args=(
|
||||
process.stderr,
|
||||
stderrParts,
|
||||
stderrLineHandler,
|
||||
stopRequested,
|
||||
logger,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
stdoutThread.start()
|
||||
stderrThread.start()
|
||||
|
||||
deadline = (
|
||||
time.monotonic() + float(timeoutSeconds)
|
||||
if timeoutSeconds is not None
|
||||
else None
|
||||
)
|
||||
terminationRequested = False
|
||||
|
||||
while process.poll() is None:
|
||||
if stopRequested.is_set():
|
||||
terminationRequested = True
|
||||
terminateProcess(process)
|
||||
break
|
||||
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
timedOut = True
|
||||
terminationRequested = True
|
||||
terminateProcess(process)
|
||||
break
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
returnCode = process.wait()
|
||||
stdoutThread.join()
|
||||
stderrThread.join()
|
||||
|
||||
stdout = ''.join(stdoutParts)
|
||||
stderr = ''.join(stderrParts)
|
||||
|
||||
if timedOut:
|
||||
error = (
|
||||
f"Command timed out after {timeoutSeconds} seconds while running "
|
||||
+ formatCommandSequence(commandSequence)
|
||||
)
|
||||
if stderr:
|
||||
error = f"{error}\n{stderr}"
|
||||
logger.error(error)
|
||||
return stdout, error, COMMAND_TIMED_OUT_RETURN_CODE
|
||||
|
||||
if returnCode != 0 and not terminationRequested:
|
||||
logger.warning(
|
||||
"executeProcess() rc=%s command=%s",
|
||||
returnCode,
|
||||
formatCommandSequence(commandSequence),
|
||||
)
|
||||
|
||||
return stdout, stderr, returnCode
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import weakref
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.measure import measure_renderables
|
||||
from rich.text import Text
|
||||
from textual import events
|
||||
from textual.widgets import Collapsible, RichLog, Static
|
||||
|
||||
from .helper import formatRichColor
|
||||
from .i18n import t
|
||||
from .pattern_controller import PatternController
|
||||
from .show_controller import ShowController
|
||||
from .shifted_season_controller import ShiftedSeasonController
|
||||
@@ -10,11 +21,162 @@ from .tmdb_controller import TmdbController
|
||||
from .track_controller import TrackController
|
||||
|
||||
|
||||
SCREEN_LOG_PANE_ID = "screen_log_pane"
|
||||
SCREEN_LOG_VIEW_ID = "screen_log_view"
|
||||
SCREEN_LOG_RESIZE_HANDLE_ID = "screen_log_resize_handle"
|
||||
SCREEN_LOG_HANDLER_NAME = "ffx-screen-log"
|
||||
SCREEN_LOG_DEFAULT_HEIGHT = 8
|
||||
SCREEN_LOG_MIN_HEIGHT = 4
|
||||
SCREEN_LOG_COMPONENT_WIDTH = 16
|
||||
SCREEN_LOG_LEVEL_WIDTH = 8
|
||||
|
||||
_SCREEN_LOG_PANE_ENABLED = False
|
||||
|
||||
|
||||
class ScreenLogHandler(logging.Handler):
|
||||
"""Mirror logger output into the active screen log pane when available."""
|
||||
|
||||
def __init__(self, app) -> None:
|
||||
super().__init__(level=logging.DEBUG)
|
||||
self.set_name(SCREEN_LOG_HANDLER_NAME)
|
||||
self.set_app(app)
|
||||
|
||||
def set_app(self, app) -> None:
|
||||
self._app_ref = weakref.ref(app) if app is not None else lambda: None
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
app = self._app_ref()
|
||||
if app is None:
|
||||
return
|
||||
|
||||
try:
|
||||
message = str(self.format(record)).strip()
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
return
|
||||
|
||||
if not message:
|
||||
return
|
||||
|
||||
try:
|
||||
app.call_from_thread(write_screen_log, app.screen, message)
|
||||
except RuntimeError:
|
||||
write_screen_log(app.screen, message)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
class ScreenLogResizeHandle(Static):
|
||||
DEFAULT_CSS = """
|
||||
ScreenLogResizeHandle {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
content-align: center middle;
|
||||
color: $text-muted;
|
||||
background: $panel-lighten-1;
|
||||
}
|
||||
|
||||
ScreenLogResizeHandle:hover {
|
||||
color: $text;
|
||||
background: $panel-lighten-2;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(" drag to resize ", id=SCREEN_LOG_RESIZE_HANDLE_ID)
|
||||
self._drag_active = False
|
||||
self._drag_origin_screen_y = 0
|
||||
self._drag_origin_height = SCREEN_LOG_DEFAULT_HEIGHT
|
||||
|
||||
def _get_log_pane(self):
|
||||
return self.parent.parent if self.parent is not None else None
|
||||
|
||||
def on_mouse_down(self, event: events.MouseDown) -> None:
|
||||
if event.button != 1:
|
||||
return
|
||||
|
||||
log_pane = self._get_log_pane()
|
||||
if log_pane is None:
|
||||
return
|
||||
|
||||
self._drag_active = True
|
||||
self._drag_origin_screen_y = event.screen_y
|
||||
self._drag_origin_height = log_pane.get_log_height()
|
||||
self.capture_mouse()
|
||||
event.stop()
|
||||
|
||||
def on_mouse_move(self, event: events.MouseMove) -> None:
|
||||
if not self._drag_active:
|
||||
return
|
||||
|
||||
log_pane = self._get_log_pane()
|
||||
if log_pane is None:
|
||||
return
|
||||
|
||||
next_height = self._drag_origin_height + (
|
||||
self._drag_origin_screen_y - event.screen_y
|
||||
)
|
||||
log_pane.set_log_height(next_height)
|
||||
event.stop()
|
||||
|
||||
def on_mouse_up(self, event: events.MouseUp) -> None:
|
||||
if not self._drag_active:
|
||||
return
|
||||
|
||||
self._drag_active = False
|
||||
self.release_mouse()
|
||||
event.stop()
|
||||
|
||||
|
||||
class ResizableScreenLogPane(Collapsible):
|
||||
def __init__(self) -> None:
|
||||
self._log_view = RichLog(
|
||||
id=SCREEN_LOG_VIEW_ID,
|
||||
wrap=True,
|
||||
markup=False,
|
||||
highlight=False,
|
||||
auto_scroll=True,
|
||||
)
|
||||
self._log_height = SCREEN_LOG_DEFAULT_HEIGHT
|
||||
self._apply_log_height()
|
||||
|
||||
super().__init__(
|
||||
ScreenLogResizeHandle(),
|
||||
self._log_view,
|
||||
title=t("Log"),
|
||||
collapsed=True,
|
||||
id=SCREEN_LOG_PANE_ID,
|
||||
)
|
||||
self.styles.width = "100%"
|
||||
|
||||
def _apply_log_height(self) -> None:
|
||||
self._log_view.styles.height = self._log_height
|
||||
self._log_view.styles.width = "100%"
|
||||
|
||||
def get_log_height(self) -> int:
|
||||
return int(self._log_height)
|
||||
|
||||
def set_log_height(self, height: int) -> None:
|
||||
next_height = max(SCREEN_LOG_MIN_HEIGHT, int(height))
|
||||
|
||||
try:
|
||||
available_height = int(self.app.size.height) - 8
|
||||
except Exception:
|
||||
available_height = next_height
|
||||
|
||||
if available_height > 0:
|
||||
next_height = min(next_height, available_height)
|
||||
|
||||
self._log_height = next_height
|
||||
self._apply_log_height()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScreenBootstrap:
|
||||
context: dict
|
||||
configuration_data: dict
|
||||
signature_tags: dict
|
||||
apply_cleanup: bool
|
||||
remove_global_keys: list
|
||||
ignore_global_keys: list
|
||||
remove_track_keys: list
|
||||
@@ -25,18 +187,62 @@ def build_screen_bootstrap(context: dict) -> ScreenBootstrap:
|
||||
configurationData = context['config'].getData()
|
||||
metadataConfiguration = configurationData.get('metadata', {})
|
||||
streamMetadataConfiguration = metadataConfiguration.get('streams', {})
|
||||
applyCleanup = bool(context.get('apply_metadata_cleanup', True))
|
||||
|
||||
return ScreenBootstrap(
|
||||
context=context,
|
||||
configuration_data=configurationData,
|
||||
signature_tags=metadataConfiguration.get('signature', {}),
|
||||
remove_global_keys=metadataConfiguration.get('remove', []),
|
||||
apply_cleanup=applyCleanup,
|
||||
remove_global_keys=metadataConfiguration.get('remove', []) if applyCleanup else [],
|
||||
ignore_global_keys=metadataConfiguration.get('ignore', []),
|
||||
remove_track_keys=streamMetadataConfiguration.get('remove', []),
|
||||
remove_track_keys=streamMetadataConfiguration.get('remove', []) if applyCleanup else [],
|
||||
ignore_track_keys=streamMetadataConfiguration.get('ignore', []),
|
||||
)
|
||||
|
||||
|
||||
def set_screen_log_pane_enabled(enabled: bool) -> None:
|
||||
global _SCREEN_LOG_PANE_ENABLED
|
||||
_SCREEN_LOG_PANE_ENABLED = bool(enabled)
|
||||
|
||||
|
||||
def is_screen_log_pane_enabled() -> bool:
|
||||
return bool(_SCREEN_LOG_PANE_ENABLED)
|
||||
|
||||
|
||||
def configure_screen_log_handler(logger, app, *, enabled: bool):
|
||||
if logger is None:
|
||||
return None
|
||||
|
||||
screen_log_handler = next(
|
||||
(handler for handler in logger.handlers if handler.get_name() == SCREEN_LOG_HANDLER_NAME),
|
||||
None,
|
||||
)
|
||||
|
||||
if not enabled:
|
||||
if screen_log_handler is not None:
|
||||
logger.removeHandler(screen_log_handler)
|
||||
screen_log_handler.close()
|
||||
return None
|
||||
|
||||
if screen_log_handler is None:
|
||||
screen_log_handler = ScreenLogHandler(app)
|
||||
logger.addHandler(screen_log_handler)
|
||||
elif isinstance(screen_log_handler, ScreenLogHandler):
|
||||
screen_log_handler.set_app(app)
|
||||
|
||||
screen_log_handler.setLevel(logging.DEBUG)
|
||||
screen_log_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
f"%(name)-{SCREEN_LOG_COMPONENT_WIDTH}s "
|
||||
+ f"%(levelname)-{SCREEN_LOG_LEVEL_WIDTH}s "
|
||||
+ "%(asctime)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
return screen_log_handler
|
||||
|
||||
|
||||
def build_screen_controllers(
|
||||
context: dict,
|
||||
*,
|
||||
@@ -63,3 +269,125 @@ def build_screen_controllers(
|
||||
controllers['shifted_season'] = ShiftedSeasonController(context=context)
|
||||
|
||||
return controllers
|
||||
|
||||
|
||||
def populate_tag_table(
|
||||
table,
|
||||
tags: Mapping[str, object],
|
||||
*,
|
||||
ignore_keys: list[str],
|
||||
remove_keys: list[str],
|
||||
) -> dict[object, tuple[str, str]]:
|
||||
"""Render display rows while keeping raw tag data addressable by row key."""
|
||||
|
||||
table.clear()
|
||||
|
||||
row_data: dict[object, tuple[str, str]] = {}
|
||||
for tag_key, tag_value in tags.items():
|
||||
raw_key = str(tag_key)
|
||||
raw_value = str(tag_value)
|
||||
|
||||
text_color = None
|
||||
if raw_key in ignore_keys:
|
||||
text_color = "blue"
|
||||
if raw_key in remove_keys:
|
||||
text_color = "red"
|
||||
|
||||
row_key = table.add_row(
|
||||
str(formatRichColor(raw_key, text_color)),
|
||||
str(formatRichColor(raw_value, text_color)),
|
||||
)
|
||||
row_data[row_key] = (raw_key, raw_value)
|
||||
|
||||
return row_data
|
||||
|
||||
|
||||
def localized_column_width(label: str, minimum: int, *, padding: int = 2) -> int:
|
||||
"""Ensure translated table headers fit within their visible column width."""
|
||||
|
||||
text = str(label)
|
||||
return max(
|
||||
int(minimum),
|
||||
len(text) + int(padding),
|
||||
int(cell_len(text)) + int(padding),
|
||||
)
|
||||
|
||||
|
||||
def add_auto_table_column(table, label, *, key=None, default=None):
|
||||
"""Add a DataTable column that sizes itself from header and cell content."""
|
||||
|
||||
return table.add_column(label, key=key, default=default)
|
||||
|
||||
|
||||
def update_table_column_label(table, column_key, label) -> None:
|
||||
"""Update a column label and keep auto-width columns in sync with it."""
|
||||
|
||||
column = table.columns.get(column_key)
|
||||
if column is None:
|
||||
return
|
||||
|
||||
text_label = Text.from_markup(label) if isinstance(label, str) else label
|
||||
column.label = text_label
|
||||
|
||||
if column.auto_width:
|
||||
measured = measure_renderables(
|
||||
table.app.console,
|
||||
table.app.console.options,
|
||||
[text_label],
|
||||
).maximum
|
||||
column.content_width = max(column.content_width, measured)
|
||||
|
||||
table.refresh()
|
||||
|
||||
|
||||
def build_screen_log_pane() -> ResizableScreenLogPane | Static:
|
||||
"""Create a shared collapsible log pane for screen-local diagnostics."""
|
||||
|
||||
if not is_screen_log_pane_enabled():
|
||||
hidden = Static("", id=f"{SCREEN_LOG_PANE_ID}_disabled")
|
||||
hidden.display = False
|
||||
return hidden
|
||||
|
||||
return ResizableScreenLogPane()
|
||||
|
||||
|
||||
def toggle_screen_log_pane(screen) -> bool:
|
||||
"""Toggle the current screen log pane when present."""
|
||||
|
||||
try:
|
||||
logPane = screen.query_one(f"#{SCREEN_LOG_PANE_ID}", Collapsible)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
logPane.collapsed = not bool(logPane.collapsed)
|
||||
return True
|
||||
|
||||
|
||||
def write_screen_log(screen, message: str) -> bool:
|
||||
"""Append a line to the current screen log pane when present."""
|
||||
|
||||
if message is None:
|
||||
return False
|
||||
|
||||
text = str(message).strip()
|
||||
if not text:
|
||||
return False
|
||||
|
||||
try:
|
||||
logView = screen.query_one(f"#{SCREEN_LOG_VIEW_ID}", RichLog)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
logView.write(text)
|
||||
return True
|
||||
|
||||
|
||||
def go_back_or_exit(screen) -> None:
|
||||
"""Pop the current screen when possible, otherwise exit the app."""
|
||||
|
||||
screen_stack = getattr(screen.app, "screen_stack", ())
|
||||
if len(screen_stack) > 2:
|
||||
screen.app.pop_screen()
|
||||
return
|
||||
|
||||
screen.app.exit()
|
||||
|
||||
@@ -2,11 +2,30 @@ from textual.app import ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer, Placeholder
|
||||
|
||||
from .i18n import t
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
|
||||
class SettingsScreen(Screen):
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
context = self.app.getContext()
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Placeholder("Settings Screen")
|
||||
# Row 1
|
||||
yield Placeholder(t("Settings Screen"))
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
@@ -6,225 +6,445 @@ from ffx.model.shifted_season import ShiftedSeason
|
||||
class EpisodeOrderException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RangeOverlapException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ShiftedSeasonController():
|
||||
class ShiftedSeasonOwnerException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ShiftedSeasonController:
|
||||
|
||||
def __init__(self, context):
|
||||
|
||||
self.context = context
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
|
||||
def checkShiftedSeason(self, showId: int, shiftedSeasonObj: dict, shiftedSeasonId: int = 0):
|
||||
"""
|
||||
Check if for a particula season
|
||||
def _resolve_owner(self, showId=None, patternId=None):
|
||||
hasShow = showId is not None
|
||||
hasPattern = patternId is not None
|
||||
|
||||
shiftedSeasonId
|
||||
if hasShow == hasPattern:
|
||||
raise ShiftedSeasonOwnerException(
|
||||
"ShiftedSeason rules require exactly one owner: either showId or patternId."
|
||||
)
|
||||
|
||||
if hasShow:
|
||||
if type(showId) is not int:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument showId is required to be of type int"
|
||||
)
|
||||
return {
|
||||
'show_id': int(showId),
|
||||
'pattern_id': None,
|
||||
'label': f"show #{int(showId)}",
|
||||
}
|
||||
|
||||
if type(patternId) is not int:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument patternId is required to be of type int"
|
||||
)
|
||||
return {
|
||||
'show_id': None,
|
||||
'pattern_id': int(patternId),
|
||||
'label': f"pattern #{int(patternId)}",
|
||||
}
|
||||
|
||||
def _apply_owner_filter(self, query, owner):
|
||||
if owner['pattern_id'] is not None:
|
||||
return query.filter(ShiftedSeason.pattern_id == owner['pattern_id'])
|
||||
return query.filter(ShiftedSeason.show_id == owner['show_id'])
|
||||
|
||||
def _normalize_shifted_season_fields(self, shiftedSeasonObj: dict):
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument shiftedSeasonObj is required to be of type dict"
|
||||
)
|
||||
|
||||
fields = {
|
||||
'original_season': int(shiftedSeasonObj['original_season']),
|
||||
'first_episode': int(shiftedSeasonObj['first_episode']),
|
||||
'last_episode': int(shiftedSeasonObj['last_episode']),
|
||||
'season_offset': int(shiftedSeasonObj['season_offset']),
|
||||
'episode_offset': int(shiftedSeasonObj['episode_offset']),
|
||||
}
|
||||
|
||||
firstEpisode = fields['first_episode']
|
||||
lastEpisode = fields['last_episode']
|
||||
if firstEpisode != -1 and lastEpisode != -1 and lastEpisode < firstEpisode:
|
||||
raise EpisodeOrderException(
|
||||
"ShiftedSeason last_episode must be greater than or equal to first_episode."
|
||||
)
|
||||
|
||||
return fields
|
||||
|
||||
def _ranges_overlap(self, firstEpisodeA, lastEpisodeA, firstEpisodeB, lastEpisodeB):
|
||||
startA = float('-inf') if int(firstEpisodeA) == -1 else int(firstEpisodeA)
|
||||
endA = float('inf') if int(lastEpisodeA) == -1 else int(lastEpisodeA)
|
||||
startB = float('-inf') if int(firstEpisodeB) == -1 else int(firstEpisodeB)
|
||||
endB = float('inf') if int(lastEpisodeB) == -1 else int(lastEpisodeB)
|
||||
return startA <= endB and startB <= endA
|
||||
|
||||
def _ordered_query(self, session, owner):
|
||||
q = self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||
return q.order_by(
|
||||
ShiftedSeason.original_season.asc(),
|
||||
ShiftedSeason.first_episode.asc(),
|
||||
ShiftedSeason.last_episode.asc(),
|
||||
ShiftedSeason.id.asc(),
|
||||
)
|
||||
|
||||
def _find_matching_rule(self, session, owner, season: int, episode: int):
|
||||
for shiftedSeasonEntry in self._ordered_query(session, owner).all():
|
||||
if (
|
||||
season == shiftedSeasonEntry.getOriginalSeason()
|
||||
and (
|
||||
shiftedSeasonEntry.getFirstEpisode() == -1
|
||||
or episode >= shiftedSeasonEntry.getFirstEpisode()
|
||||
)
|
||||
and (
|
||||
shiftedSeasonEntry.getLastEpisode() == -1
|
||||
or episode <= shiftedSeasonEntry.getLastEpisode()
|
||||
)
|
||||
):
|
||||
return shiftedSeasonEntry
|
||||
return None
|
||||
|
||||
def checkShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
shiftedSeasonObj: dict | None = None,
|
||||
shiftedSeasonId: int = 0,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
"""
|
||||
Check whether a shifted-season rule is valid within one owner scope.
|
||||
"""
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
session = self.Session()
|
||||
|
||||
originalSeason = shiftedSeasonObj['original_season']
|
||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
||||
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
q = self._ordered_query(session, owner)
|
||||
if shiftedSeasonId:
|
||||
q = q.filter(ShiftedSeason.id != int(shiftedSeasonId))
|
||||
|
||||
siblingShiftedSeason: ShiftedSeason
|
||||
for siblingShiftedSeason in q.all():
|
||||
if fields['original_season'] != siblingShiftedSeason.getOriginalSeason():
|
||||
continue
|
||||
|
||||
siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason
|
||||
siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode()
|
||||
siblingLastEpisode = siblingShiftedSeason.getLastEpisode()
|
||||
|
||||
if (originalSeason == siblingOriginalSeason
|
||||
and lastEpisode >= siblingFirstEpisode
|
||||
and siblingLastEpisode >= firstEpisode):
|
||||
|
||||
if self._ranges_overlap(
|
||||
fields['first_episode'],
|
||||
fields['last_episode'],
|
||||
siblingShiftedSeason.getFirstEpisode(),
|
||||
siblingShiftedSeason.getLastEpisode(),
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except (EpisodeOrderException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.checkShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def addShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
shiftedSeasonObj: dict | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
|
||||
def addShiftedSeason(self, showId: int, shiftedSeasonObj: dict):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument showId is required to be of type int")
|
||||
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict")
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
|
||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
||||
if not self.checkShiftedSeason(
|
||||
showId=owner['show_id'],
|
||||
patternId=owner['pattern_id'],
|
||||
shiftedSeasonObj=fields,
|
||||
):
|
||||
raise RangeOverlapException(
|
||||
f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}."
|
||||
)
|
||||
|
||||
if lastEpisode < firstEpisode:
|
||||
raise EpisodeOrderException()
|
||||
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
|
||||
shiftedSeason = ShiftedSeason(show_id = int(showId),
|
||||
original_season = int(shiftedSeasonObj['original_season']),
|
||||
first_episode = firstEpisode,
|
||||
last_episode = lastEpisode,
|
||||
season_offset = int(shiftedSeasonObj['season_offset']),
|
||||
episode_offset = int(shiftedSeasonObj['episode_offset']))
|
||||
s.add(shiftedSeason)
|
||||
s.commit()
|
||||
session = self.Session()
|
||||
shiftedSeason = ShiftedSeason(
|
||||
show_id=owner['show_id'],
|
||||
pattern_id=owner['pattern_id'],
|
||||
original_season=fields['original_season'],
|
||||
first_episode=fields['first_episode'],
|
||||
last_episode=fields['last_episode'],
|
||||
season_offset=fields['season_offset'],
|
||||
episode_offset=fields['episode_offset'],
|
||||
)
|
||||
session.add(shiftedSeason)
|
||||
session.commit()
|
||||
return shiftedSeason.getId()
|
||||
|
||||
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
session = self.Session()
|
||||
|
||||
shiftedSeason = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
||||
shiftedSeason = (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
if shiftedSeason is not None:
|
||||
|
||||
shiftedSeason.original_season = int(shiftedSeasonObj['original_season'])
|
||||
shiftedSeason.first_episode = int(shiftedSeasonObj['first_episode'])
|
||||
shiftedSeason.last_episode = int(shiftedSeasonObj['last_episode'])
|
||||
shiftedSeason.season_offset = int(shiftedSeasonObj['season_offset'])
|
||||
shiftedSeason.episode_offset = int(shiftedSeasonObj['episode_offset'])
|
||||
|
||||
s.commit()
|
||||
return True
|
||||
|
||||
else:
|
||||
if shiftedSeason is None:
|
||||
return False
|
||||
|
||||
owner = self._resolve_owner(
|
||||
showId=shiftedSeason.getShowId(),
|
||||
patternId=shiftedSeason.getPatternId(),
|
||||
)
|
||||
if not self.checkShiftedSeason(
|
||||
showId=owner['show_id'],
|
||||
patternId=owner['pattern_id'],
|
||||
shiftedSeasonObj=fields,
|
||||
shiftedSeasonId=shiftedSeasonId,
|
||||
):
|
||||
raise RangeOverlapException(
|
||||
f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}."
|
||||
)
|
||||
|
||||
shiftedSeason.original_season = fields['original_season']
|
||||
shiftedSeason.first_episode = fields['first_episode']
|
||||
shiftedSeason.last_episode = fields['last_episode']
|
||||
shiftedSeason.season_offset = fields['season_offset']
|
||||
shiftedSeason.episode_offset = fields['episode_offset']
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
|
||||
def findShiftedSeason(self, showId: int, originalSeason: int, firstEpisode: int, lastEpisode: int):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
def findShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
originalSeason: int | None = None,
|
||||
firstEpisode: int | None = None,
|
||||
lastEpisode: int | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
|
||||
if type(originalSeason) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int"
|
||||
)
|
||||
|
||||
if type(firstEpisode) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int"
|
||||
)
|
||||
|
||||
if type(lastEpisode) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
shiftedSeason = s.query(ShiftedSeason).filter(
|
||||
ShiftedSeason.show_id == int(showId),
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
session = self.Session()
|
||||
shiftedSeason = (
|
||||
self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||
.filter(
|
||||
ShiftedSeason.original_season == int(originalSeason),
|
||||
ShiftedSeason.first_episode == int(firstEpisode),
|
||||
ShiftedSeason.last_episode == int(lastEpisode),
|
||||
).first()
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
return shiftedSeason.getId() if shiftedSeason is not None else None
|
||||
|
||||
except ShiftedSeasonOwnerException as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.findShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.findShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def getShiftedSeasonSiblings(self, showId: int):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeasonSiblings(): Argument shiftedSeasonId is required to be of type int")
|
||||
def getShiftedSeasonSiblings(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
session = None
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
|
||||
return q.all()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
session = self.Session()
|
||||
return self._ordered_query(session, owner).all()
|
||||
|
||||
except ShiftedSeasonOwnerException as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.getShiftedSeasonSiblings(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.getShiftedSeasonSiblings(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def getShiftedSeason(self, shiftedSeasonId: int):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
return s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
||||
session = self.Session()
|
||||
return (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def deleteShiftedSeason(self, shiftedSeasonId):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
shiftedSeason = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
||||
session = self.Session()
|
||||
shiftedSeason = (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
if shiftedSeason is not None:
|
||||
|
||||
#DAFUQ: https://stackoverflow.com/a/19245058
|
||||
# q.delete()
|
||||
s.delete(shiftedSeason)
|
||||
|
||||
s.commit()
|
||||
session.delete(shiftedSeason)
|
||||
session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def shiftSeason(self, showId, season, episode, patternId=None):
|
||||
if season == -1 or episode == -1:
|
||||
return season, episode
|
||||
|
||||
def shiftSeason(self, showId, season, episode):
|
||||
shiftedSeason, shiftedEpisode, sourceLabel = self.resolveShiftSeason(
|
||||
showId,
|
||||
season,
|
||||
episode,
|
||||
patternId=patternId,
|
||||
)
|
||||
|
||||
shiftedSeasonEntry: ShiftedSeason
|
||||
for shiftedSeasonEntry in self.getShiftedSeasonSiblings(showId):
|
||||
|
||||
if (season == shiftedSeasonEntry.getOriginalSeason()
|
||||
and (shiftedSeasonEntry.getFirstEpisode() == -1 or episode >= shiftedSeasonEntry.getFirstEpisode())
|
||||
and (shiftedSeasonEntry.getLastEpisode() == -1 or episode <= shiftedSeasonEntry.getLastEpisode())):
|
||||
|
||||
shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset()
|
||||
shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset()
|
||||
|
||||
self.context['logger'].info(f"Shifting season: {season} episode: {episode} "
|
||||
+f"-> season: {shiftedSeason} episode: {shiftedEpisode}")
|
||||
if shiftedSeason != season or shiftedEpisode != episode:
|
||||
self.context['logger'].info(
|
||||
f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
|
||||
)
|
||||
|
||||
return shiftedSeason, shiftedEpisode
|
||||
|
||||
return season, episode
|
||||
def resolveShiftSeason(self, showId, season, episode, patternId=None):
|
||||
if season == -1 or episode == -1:
|
||||
return season, episode, "unrecognized"
|
||||
|
||||
session = None
|
||||
try:
|
||||
session = self.Session()
|
||||
activeShift = None
|
||||
|
||||
if patternId is not None:
|
||||
activeShift = self._find_matching_rule(
|
||||
session,
|
||||
self._resolve_owner(patternId=patternId),
|
||||
season=int(season),
|
||||
episode=int(episode),
|
||||
)
|
||||
|
||||
if activeShift is None and showId is not None and showId != -1:
|
||||
activeShift = self._find_matching_rule(
|
||||
session,
|
||||
self._resolve_owner(showId=showId),
|
||||
season=int(season),
|
||||
episode=int(episode),
|
||||
)
|
||||
|
||||
if activeShift is None:
|
||||
shiftedSeason = season
|
||||
shiftedEpisode = episode
|
||||
sourceLabel = "default"
|
||||
else:
|
||||
shiftedSeason = season + activeShift.getSeasonOffset()
|
||||
shiftedEpisode = episode + activeShift.getEpisodeOffset()
|
||||
sourceLabel = (
|
||||
"pattern"
|
||||
if activeShift.getPatternId() is not None
|
||||
else "show"
|
||||
)
|
||||
return shiftedSeason, shiftedEpisode, sourceLabel
|
||||
|
||||
except ShiftedSeasonOwnerException as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.shiftSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
@@ -4,7 +4,9 @@ from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button
|
||||
from textual.containers import Grid
|
||||
|
||||
from .i18n import t
|
||||
from .shifted_season_controller import ShiftedSeasonController
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
from ffx.model.shifted_season import ShiftedSeason
|
||||
|
||||
@@ -12,15 +14,22 @@ from ffx.model.shifted_season import ShiftedSeason
|
||||
# Screen[dict[int, str, int]]
|
||||
class ShiftedSeasonDeleteScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 2;
|
||||
grid-rows: 2 auto;
|
||||
grid-columns: 30 330;
|
||||
grid-columns: 18 5fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 90;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -43,7 +52,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, showId = None, shiftedSeasonId = None):
|
||||
def __init__(self, showId = None, patternId = None, shiftedSeasonId = None):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
@@ -52,14 +61,23 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||
|
||||
self._showId = showId
|
||||
self._patternId = patternId
|
||||
self.__shiftedSeasonId = shiftedSeasonId
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
||||
|
||||
self.query_one("#static_show_id", Static).update(str(self._showId))
|
||||
ownerLabel = (
|
||||
t("pattern #{id}", id=self._patternId)
|
||||
if self._patternId is not None
|
||||
else t("show #{id}", id=self._showId)
|
||||
)
|
||||
self.query_one("#static_owner", Static).update(ownerLabel)
|
||||
self.query_one("#static_original_season", Static).update(str(shiftedSeason.getOriginalSeason()))
|
||||
self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode()))
|
||||
self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode()))
|
||||
@@ -72,36 +90,47 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
# Row 1
|
||||
yield Static(t("Are you sure to delete the following shifted season?"), id="toplabel", classes="two")
|
||||
|
||||
yield Static("Are you sure to delete the following shifted season?", id="toplabel", classes="two")
|
||||
|
||||
# Row 2
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
yield Static("from show")
|
||||
yield Static(" ", id="static_show_id")
|
||||
# Row 3
|
||||
yield Static(t("from"))
|
||||
yield Static(" ", id="static_owner")
|
||||
|
||||
# Row 4
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
yield Static("Original season")
|
||||
# Row 5
|
||||
yield Static(t("Source Season"))
|
||||
yield Static(" ", id="static_original_season")
|
||||
|
||||
yield Static("First episode")
|
||||
# Row 6
|
||||
yield Static(t("First episode"))
|
||||
yield Static(" ", id="static_first_episode")
|
||||
|
||||
yield Static("Last episode")
|
||||
# Row 7
|
||||
yield Static(t("Last episode"))
|
||||
yield Static(" ", id="static_last_episode")
|
||||
|
||||
yield Static("Season offset")
|
||||
# Row 8
|
||||
yield Static(t("Season Offset"))
|
||||
yield Static(" ", id="static_season_offset")
|
||||
|
||||
yield Static("Episode offset")
|
||||
# Row 9
|
||||
yield Static(t("Episode offset"))
|
||||
yield Static(" ", id="static_episode_offset")
|
||||
|
||||
# Row 10
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
yield Button("Delete", id="delete_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 11
|
||||
yield Button(t("Delete"), id="delete_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
@@ -123,3 +152,5 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
@@ -4,7 +4,9 @@ from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button, Input
|
||||
from textual.containers import Grid
|
||||
|
||||
from .i18n import t
|
||||
from .shifted_season_controller import ShiftedSeasonController
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
from ffx.model.shifted_season import ShiftedSeason
|
||||
|
||||
@@ -12,15 +14,22 @@ from ffx.model.shifted_season import ShiftedSeason
|
||||
# Screen[dict[int, str, int]]
|
||||
class ShiftedSeasonDetailsScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 3 10;
|
||||
grid-rows: 2 2 2 2 2 2 2 2 2 2;
|
||||
grid-columns: 40 40 40;
|
||||
grid-columns: 20 1fr 1fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 80;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -81,7 +90,7 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, showId = None, shiftedSeasonId = None):
|
||||
def __init__(self, showId = None, patternId = None, shiftedSeasonId = None):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
@@ -90,10 +99,19 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||
|
||||
self.__showId = showId
|
||||
self.__patternId = patternId
|
||||
self.__shiftedSeasonId = shiftedSeasonId
|
||||
|
||||
def _owner_kwargs(self):
|
||||
if self.__patternId is not None:
|
||||
return {'patternId': self.__patternId}
|
||||
return {'showId': self.__showId}
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
if self.__shiftedSeasonId is not None:
|
||||
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
||||
|
||||
@@ -119,43 +137,48 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
|
||||
with Grid():
|
||||
|
||||
# 1
|
||||
yield Static("Edit shifted season" if self.__shiftedSeasonId is not None else "New shifted season", id="toplabel", classes="three")
|
||||
# Row 1
|
||||
yield Static(
|
||||
t("Edit shifted season") if self.__shiftedSeasonId is not None else t("New shifted season"),
|
||||
id="toplabel",
|
||||
classes="three",
|
||||
)
|
||||
|
||||
# 2
|
||||
# Row 2
|
||||
yield Static(" ", classes="three")
|
||||
|
||||
# 3
|
||||
yield Static("Original season")
|
||||
# Row 3
|
||||
yield Static(t("Source Season"))
|
||||
yield Input(id="input_original_season", classes="two")
|
||||
|
||||
# 4
|
||||
yield Static("First Episode")
|
||||
# Row 4
|
||||
yield Static(t("First Episode"))
|
||||
yield Input(id="input_first_episode", classes="two")
|
||||
|
||||
# 5
|
||||
yield Static("Last Episode")
|
||||
# Row 5
|
||||
yield Static(t("Last Episode"))
|
||||
yield Input(id="input_last_episode", classes="two")
|
||||
|
||||
# 6
|
||||
yield Static("Season offset")
|
||||
# Row 6
|
||||
yield Static(t("Season Offset"))
|
||||
yield Input(id="input_season_offset", classes="two")
|
||||
|
||||
# 7
|
||||
yield Static("Episode offset")
|
||||
# Row 7
|
||||
yield Static(t("Episode offset"))
|
||||
yield Input(id="input_episode_offset", classes="two")
|
||||
|
||||
# 8
|
||||
# Row 8
|
||||
yield Static(" ", classes="three")
|
||||
|
||||
# 9
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 9
|
||||
yield Button(t("Save"), id="save_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
yield Static(" ")
|
||||
|
||||
# 10
|
||||
# Row 10
|
||||
yield Static(" ", classes="three")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
@@ -190,6 +213,9 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
|
||||
return shiftedSeasonObj
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
|
||||
# Event handler for button press
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
@@ -203,8 +229,11 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
|
||||
if self.__shiftedSeasonId is not None:
|
||||
|
||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj,
|
||||
shiftedSeasonId = self.__shiftedSeasonId):
|
||||
if self.__ssc.checkShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
shiftedSeasonId=self.__shiftedSeasonId,
|
||||
**self._owner_kwargs(),
|
||||
):
|
||||
if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj):
|
||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||
else:
|
||||
@@ -212,8 +241,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
self.app.pop_screen()
|
||||
|
||||
else:
|
||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj):
|
||||
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(self.__showId, shiftedSeasonObj)
|
||||
if self.__ssc.checkShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
**self._owner_kwargs(),
|
||||
):
|
||||
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
**self._owner_kwargs(),
|
||||
)
|
||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,9 @@ class ShowController():
|
||||
index_season_digits = showDescriptor.getIndexSeasonDigits(),
|
||||
index_episode_digits = showDescriptor.getIndexEpisodeDigits(),
|
||||
indicator_season_digits = showDescriptor.getIndicatorSeasonDigits(),
|
||||
indicator_episode_digits = showDescriptor.getIndicatorEpisodeDigits())
|
||||
indicator_episode_digits = showDescriptor.getIndicatorEpisodeDigits(),
|
||||
quality = showDescriptor.getQuality(),
|
||||
notes = showDescriptor.getNotes())
|
||||
|
||||
s.add(show)
|
||||
s.commit()
|
||||
@@ -88,6 +90,12 @@ class ShowController():
|
||||
if currentShow.indicator_episode_digits != int(showDescriptor.getIndicatorEpisodeDigits()):
|
||||
currentShow.indicator_episode_digits = int(showDescriptor.getIndicatorEpisodeDigits())
|
||||
changed = True
|
||||
if int(currentShow.quality or 0) != int(showDescriptor.getQuality()):
|
||||
currentShow.quality = int(showDescriptor.getQuality())
|
||||
changed = True
|
||||
if str(currentShow.notes or '') != str(showDescriptor.getNotes()):
|
||||
currentShow.notes = str(showDescriptor.getNotes())
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
s.commit()
|
||||
|
||||
@@ -2,20 +2,29 @@ from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button
|
||||
from textual.containers import Grid
|
||||
|
||||
from .i18n import t
|
||||
from .show_controller import ShowController
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
class ShowDeleteScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 2;
|
||||
grid-rows: 2 auto;
|
||||
grid-columns: 30 auto;
|
||||
grid-columns: 18 4fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 80;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -59,22 +68,28 @@ class ShowDeleteScreen(Screen):
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
# Row 1
|
||||
yield Static(t("Are you sure to delete the following show?"), id="toplabel", classes="two")
|
||||
|
||||
yield Static("Are you sure to delete the following show?", id="toplabel", classes="two")
|
||||
|
||||
# Row 2
|
||||
yield Static("", classes="two")
|
||||
|
||||
# Row 3
|
||||
yield Static("", id="showlabel")
|
||||
yield Static("")
|
||||
|
||||
# Row 4
|
||||
yield Static("", classes="two")
|
||||
|
||||
# Row 5
|
||||
yield Static("", classes="two")
|
||||
|
||||
yield Button("Delete", id="delete_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 6
|
||||
yield Button(t("Delete"), id="delete_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
@@ -93,3 +108,13 @@ class ShowDeleteScreen(Screen):
|
||||
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
@@ -21,6 +21,8 @@ class ShowDescriptor():
|
||||
INDEX_EPISODE_DIGITS_KEY = 'index_episode_digits'
|
||||
INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_digits'
|
||||
INDICATOR_EPISODE_DIGITS_KEY = 'indicator_episode_digits'
|
||||
QUALITY_KEY = 'quality'
|
||||
NOTES_KEY = 'notes'
|
||||
|
||||
DEFAULT_INDEX_SEASON_DIGITS = DEFAULT_SHOW_INDEX_SEASON_DIGITS
|
||||
DEFAULT_INDEX_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS
|
||||
@@ -124,6 +126,20 @@ class ShowDescriptor():
|
||||
else:
|
||||
self.__indicatorEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||
|
||||
if ShowDescriptor.QUALITY_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.QUALITY_KEY]) is not int:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.QUALITY_KEY} is required to be of type int")
|
||||
self.__quality = kwargs[ShowDescriptor.QUALITY_KEY]
|
||||
else:
|
||||
self.__quality = 0
|
||||
|
||||
if ShowDescriptor.NOTES_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.NOTES_KEY]) is not str:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.NOTES_KEY} is required to be of type str")
|
||||
self.__notes = kwargs[ShowDescriptor.NOTES_KEY]
|
||||
else:
|
||||
self.__notes = ''
|
||||
|
||||
|
||||
def getId(self):
|
||||
return self.__showId
|
||||
@@ -140,6 +156,10 @@ class ShowDescriptor():
|
||||
return self.__indicatorSeasonDigits
|
||||
def getIndicatorEpisodeDigits(self):
|
||||
return self.__indicatorEpisodeDigits
|
||||
def getQuality(self):
|
||||
return self.__quality
|
||||
def getNotes(self):
|
||||
return self.__notes
|
||||
|
||||
def getFilenamePrefix(self):
|
||||
return f"{self.__showName} ({str(self.__showYear)})"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button, DataTable, Input
|
||||
from textual.widgets import Header, Footer, Static, Button, DataTable, Input, TextArea
|
||||
from textual.containers import Grid
|
||||
from textual.widgets._data_table import CellDoesNotExist
|
||||
|
||||
@@ -16,7 +16,14 @@ from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
|
||||
from ffx.model.shifted_season import ShiftedSeason
|
||||
|
||||
from .helper import filterFilename
|
||||
from .screen_support import build_screen_bootstrap, build_screen_controllers
|
||||
from .i18n import t
|
||||
from .screen_support import (
|
||||
add_auto_table_column,
|
||||
build_screen_bootstrap,
|
||||
build_screen_controllers,
|
||||
build_screen_log_pane,
|
||||
go_back_or_exit,
|
||||
)
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
@@ -25,12 +32,15 @@ class ShowDetailsScreen(Screen):
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 5 16;
|
||||
grid-rows: 2 2 2 2 2 2 2 2 2 2 2 9 2 9 2 2;
|
||||
grid-columns: 30 30 30 30 30;
|
||||
grid-size: 5 19;
|
||||
grid-rows: 2 2 2 2 2 2 6 2 2 2 2 2 2 2 9 2 9 2 2;
|
||||
grid-columns: 25 20 20 20 1fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 110;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -43,6 +53,7 @@ class ShowDetailsScreen(Screen):
|
||||
DataTable {
|
||||
column-span: 2;
|
||||
min-height: 8;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
DataTable .datatable--cursor {
|
||||
@@ -77,12 +88,17 @@ class ShowDetailsScreen(Screen):
|
||||
height: 100%;
|
||||
border: solid green;
|
||||
}
|
||||
|
||||
.note_box {
|
||||
min-height: 6;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("a", "add_pattern", "Add Pattern"),
|
||||
("e", "edit_pattern", "Edit Pattern"),
|
||||
("r", "remove_pattern", "Remove Pattern"),
|
||||
("escape", "back", t("Back")),
|
||||
("a", "add_pattern", t("Add Pattern")),
|
||||
("e", "edit_pattern", t("Edit Pattern")),
|
||||
("r", "remove_pattern", t("Remove Pattern")),
|
||||
]
|
||||
|
||||
def __init__(self, showId = None):
|
||||
@@ -104,12 +120,45 @@ class ShowDetailsScreen(Screen):
|
||||
self.__ssc = controllers['shifted_season']
|
||||
|
||||
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
|
||||
self.__patternRowData: dict[object, dict[str, object]] = {}
|
||||
self.__shiftedSeasonRowData: dict[object, dict[str, int | None]] = {}
|
||||
|
||||
|
||||
def _add_pattern_row(self, *, pattern_id: int | None, pattern_text: str):
|
||||
row_key = self.patternTable.add_row(str(pattern_text))
|
||||
self.__patternRowData[row_key] = {
|
||||
'id': pattern_id,
|
||||
'show_id': self.__showDescriptor.getId() if self.__showDescriptor is not None else None,
|
||||
'pattern': str(pattern_text),
|
||||
}
|
||||
return row_key
|
||||
|
||||
|
||||
def _add_shifted_season_row(self, shifted_season_obj: dict[str, int | None]):
|
||||
firstEpisode = shifted_season_obj['first_episode']
|
||||
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
|
||||
|
||||
lastEpisode = shifted_season_obj['last_episode']
|
||||
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
|
||||
|
||||
row = (
|
||||
shifted_season_obj['original_season'],
|
||||
firstEpisodeStr,
|
||||
lastEpisodeStr,
|
||||
shifted_season_obj['season_offset'],
|
||||
shifted_season_obj['episode_offset'],
|
||||
)
|
||||
|
||||
row_key = self.shiftedSeasonsTable.add_row(*map(str, row))
|
||||
self.__shiftedSeasonRowData[row_key] = dict(shifted_season_obj)
|
||||
return row_key
|
||||
|
||||
|
||||
|
||||
def updateShiftedSeasons(self):
|
||||
|
||||
self.shiftedSeasonsTable.clear()
|
||||
self.__shiftedSeasonRowData = {}
|
||||
|
||||
if not self.__showDescriptor is None:
|
||||
|
||||
@@ -119,25 +168,16 @@ class ShowDetailsScreen(Screen):
|
||||
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(showId=showId):
|
||||
|
||||
shiftedSeasonObj = shiftedSeason.getObj()
|
||||
|
||||
firstEpisode = shiftedSeasonObj['first_episode']
|
||||
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
|
||||
|
||||
lastEpisode = shiftedSeasonObj['last_episode']
|
||||
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
|
||||
|
||||
row = (shiftedSeasonObj['original_season'],
|
||||
firstEpisodeStr,
|
||||
lastEpisodeStr,
|
||||
shiftedSeasonObj['season_offset'],
|
||||
shiftedSeasonObj['episode_offset'])
|
||||
|
||||
self.shiftedSeasonsTable.add_row(*map(str, row))
|
||||
shiftedSeasonObj['id'] = shiftedSeason.getId()
|
||||
self._add_shifted_season_row(shiftedSeasonObj)
|
||||
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
if self.__showDescriptor is not None:
|
||||
|
||||
showId = int(self.__showDescriptor.getId())
|
||||
@@ -150,12 +190,18 @@ class ShowDetailsScreen(Screen):
|
||||
self.query_one("#index_episode_digits_input", Input).value = str(self.__showDescriptor.getIndexEpisodeDigits())
|
||||
self.query_one("#indicator_season_digits_input", Input).value = str(self.__showDescriptor.getIndicatorSeasonDigits())
|
||||
self.query_one("#indicator_episode_digits_input", Input).value = str(self.__showDescriptor.getIndicatorEpisodeDigits())
|
||||
if self.__showDescriptor.getQuality():
|
||||
self.query_one("#quality_input", Input).value = str(self.__showDescriptor.getQuality())
|
||||
if self.__showDescriptor.getNotes():
|
||||
self.query_one("#notes_textarea", TextArea).text = str(self.__showDescriptor.getNotes())
|
||||
|
||||
|
||||
#raise click.ClickException(f"show_id {showId}")
|
||||
for pattern in self.__pc.getPatternsForShow(showId):
|
||||
row = (pattern.getPattern(),)
|
||||
self.patternTable.add_row(*map(str, row))
|
||||
self._add_pattern_row(
|
||||
pattern_id=pattern.getId(),
|
||||
pattern_text=pattern.getPattern(),
|
||||
)
|
||||
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
@@ -187,10 +233,7 @@ class ShowDetailsScreen(Screen):
|
||||
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.patternTable.get_row(row_key)
|
||||
|
||||
selectedPattern['show_id'] = self.__showDescriptor.getId()
|
||||
selectedPattern['pattern'] = str(selected_row_data[0])
|
||||
selectedPattern = dict(self.__patternRowData.get(row_key, {}))
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -209,25 +252,7 @@ class ShowDetailsScreen(Screen):
|
||||
row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(self.shiftedSeasonsTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.shiftedSeasonsTable.get_row(row_key)
|
||||
|
||||
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
|
||||
shiftedSeasonObj['first_episode'] = int(selected_row_data[1]) if selected_row_data[1].isnumeric() else -1
|
||||
shiftedSeasonObj['last_episode'] = int(selected_row_data[2]) if selected_row_data[2].isnumeric() else -1
|
||||
shiftedSeasonObj['season_offset'] = int(selected_row_data[3]) if selected_row_data[3].isnumeric() else 0
|
||||
shiftedSeasonObj['episode_offset'] = int(selected_row_data[4]) if selected_row_data[4].isnumeric() else 0
|
||||
|
||||
|
||||
if self.__showDescriptor is not None:
|
||||
|
||||
showId = int(self.__showDescriptor.getId())
|
||||
|
||||
shiftedSeasonId = self.__ssc.findShiftedSeason(showId,
|
||||
originalSeason=shiftedSeasonObj['original_season'],
|
||||
firstEpisode=shiftedSeasonObj['first_episode'],
|
||||
lastEpisode=shiftedSeasonObj['last_episode'])
|
||||
if shiftedSeasonId is not None:
|
||||
shiftedSeasonObj['id'] = shiftedSeasonId
|
||||
shiftedSeasonObj = dict(self.__shiftedSeasonRowData.get(row_key, {}))
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -241,9 +266,14 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
|
||||
def handle_add_pattern(self, screenResult):
|
||||
if screenResult is None:
|
||||
return
|
||||
|
||||
pattern = (screenResult['pattern'],)
|
||||
self.patternTable.add_row(*map(str, pattern))
|
||||
pattern_id = self.__pc.findPattern(screenResult)
|
||||
self._add_pattern_row(
|
||||
pattern_id=pattern_id,
|
||||
pattern_text=screenResult['pattern'],
|
||||
)
|
||||
|
||||
|
||||
def action_edit_pattern(self):
|
||||
@@ -251,8 +281,7 @@ class ShowDetailsScreen(Screen):
|
||||
selectedPatternDescriptor = self.getSelectedPatternDescriptor()
|
||||
|
||||
if selectedPatternDescriptor:
|
||||
|
||||
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor)
|
||||
selectedPatternId = selectedPatternDescriptor.get('id')
|
||||
|
||||
if selectedPatternId is None:
|
||||
raise click.ClickException(f"ShowDetailsScreen.action_edit_pattern(): Pattern to edit has no id")
|
||||
@@ -266,6 +295,8 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
|
||||
self.patternTable.update_cell(row_key, self.column_key_pattern, screenResult['pattern'])
|
||||
if row_key in self.__patternRowData:
|
||||
self.__patternRowData[row_key]['pattern'] = str(screenResult['pattern'])
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -277,7 +308,7 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
if selectedPatternDescriptor:
|
||||
|
||||
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor)
|
||||
selectedPatternId = selectedPatternDescriptor.get('id')
|
||||
|
||||
if selectedPatternId is None:
|
||||
raise click.ClickException(f"ShowDetailsScreen.action_remove_pattern(): Pattern to remove has no id")
|
||||
@@ -290,6 +321,7 @@ class ShowDetailsScreen(Screen):
|
||||
try:
|
||||
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
|
||||
self.patternTable.remove_row(row_key)
|
||||
self.__patternRowData.pop(row_key, None)
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -301,18 +333,18 @@ class ShowDetailsScreen(Screen):
|
||||
self.patternTable = DataTable(classes="five")
|
||||
|
||||
# Define the columns with headers
|
||||
self.column_key_pattern = self.patternTable.add_column("Pattern", width=150)
|
||||
self.column_key_pattern = add_auto_table_column(self.patternTable, t("Pattern"))
|
||||
|
||||
self.patternTable.cursor_type = 'row'
|
||||
|
||||
|
||||
self.shiftedSeasonsTable = DataTable(classes="five")
|
||||
|
||||
self.column_key_original_season = self.shiftedSeasonsTable.add_column("Original Season", width=30)
|
||||
self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=30)
|
||||
self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=30)
|
||||
self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=30)
|
||||
self.column_key_episode_offset = self.shiftedSeasonsTable.add_column("Episode Offset", width=30)
|
||||
self.column_key_original_season = add_auto_table_column(self.shiftedSeasonsTable, t("Source Season"))
|
||||
self.column_key_first_episode = add_auto_table_column(self.shiftedSeasonsTable, t("First Episode"))
|
||||
self.column_key_last_episode = add_auto_table_column(self.shiftedSeasonsTable, t("Last Episode"))
|
||||
self.column_key_season_offset = add_auto_table_column(self.shiftedSeasonsTable, t("Season Offset"))
|
||||
self.column_key_episode_offset = add_auto_table_column(self.shiftedSeasonsTable, t("Episode Offset"))
|
||||
|
||||
self.shiftedSeasonsTable.cursor_type = 'row'
|
||||
|
||||
@@ -321,76 +353,91 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
with Grid():
|
||||
|
||||
# 1
|
||||
yield Static("Show" if not self.__showDescriptor is None else "New Show", id="toplabel")
|
||||
yield Button("Identify", id="identify_button")
|
||||
# Row 1
|
||||
yield Static(t("Show") if not self.__showDescriptor is None else t("New Show"), id="toplabel")
|
||||
yield Button(t("Identify"), id="identify_button")
|
||||
yield Static(" ", classes="three")
|
||||
|
||||
# 2
|
||||
yield Static("ID")
|
||||
# Row 2
|
||||
yield Static(t("ID"))
|
||||
if not self.__showDescriptor is None:
|
||||
yield Static("", id="id_static", classes="four")
|
||||
else:
|
||||
yield Input(type="integer", id="id_input", classes="four")
|
||||
|
||||
# 3
|
||||
yield Static("Name")
|
||||
# Row 3
|
||||
yield Static(t("Name"))
|
||||
yield Input(type="text", id="name_input", classes="four")
|
||||
|
||||
# 4
|
||||
yield Static("Year")
|
||||
# Row 4
|
||||
yield Static(t("Year"))
|
||||
yield Input(type="integer", id="year_input", classes="four")
|
||||
|
||||
#5
|
||||
# Row 5
|
||||
yield Static(t("Quality"))
|
||||
yield Input(type="integer", id="quality_input", classes="four")
|
||||
|
||||
# Row 6
|
||||
yield Static(t("Notes"))
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
# Row 7
|
||||
yield TextArea(id="notes_textarea", classes="five note_box")
|
||||
|
||||
# Row 8
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
#6
|
||||
yield Static("Index Season Digits")
|
||||
# Row 9
|
||||
yield Static(t("Index Season Digits"))
|
||||
yield Input(type="integer", id="index_season_digits_input", classes="four")
|
||||
|
||||
#7
|
||||
yield Static("Index Episode Digits")
|
||||
# Row 10
|
||||
yield Static(t("Index Episode Digits"))
|
||||
yield Input(type="integer", id="index_episode_digits_input", classes="four")
|
||||
|
||||
#8
|
||||
yield Static("Indicator Season Digits")
|
||||
# Row 11
|
||||
yield Static(t("Indicator Season Digits"))
|
||||
yield Input(type="integer", id="indicator_season_digits_input", classes="four")
|
||||
|
||||
#9
|
||||
yield Static("Indicator Edisode Digits")
|
||||
# Row 12
|
||||
yield Static(t("Indicator Edisode Digits"))
|
||||
yield Input(type="integer", id="indicator_episode_digits_input", classes="four")
|
||||
|
||||
# 10
|
||||
# Row 13
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 11
|
||||
yield Static("Shifted seasons", classes="two")
|
||||
# Row 14
|
||||
yield Static(t("Numbering Mapping"))
|
||||
|
||||
if self.__showDescriptor is not None:
|
||||
yield Button("Add", id="button_add_shifted_season")
|
||||
yield Button("Edit", id="button_edit_shifted_season")
|
||||
yield Button("Delete", id="button_delete_shifted_season")
|
||||
yield Button(t("Add"), id="button_add_shifted_season")
|
||||
yield Button(t("Edit"), id="button_edit_shifted_season")
|
||||
yield Button(t("Delete"), id="button_delete_shifted_season")
|
||||
else:
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
# 12
|
||||
yield Static(" ")
|
||||
|
||||
# Row 15
|
||||
yield self.shiftedSeasonsTable
|
||||
|
||||
# 13
|
||||
yield Static("File patterns", classes="five")
|
||||
# 14
|
||||
# Row 16
|
||||
yield Static(t("File patterns"), classes="five")
|
||||
|
||||
# Row 17
|
||||
yield self.patternTable
|
||||
|
||||
# 15
|
||||
# Row 18
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 16
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 19
|
||||
yield Button(t("Save"), id="save_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
@@ -432,6 +479,11 @@ class ShowDetailsScreen(Screen):
|
||||
kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.query_one("#indicator_episode_digits_input", Input).value)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
kwargs[ShowDescriptor.QUALITY_KEY] = int(self.query_one("#quality_input", Input).value)
|
||||
except ValueError:
|
||||
pass
|
||||
kwargs[ShowDescriptor.NOTES_KEY] = str(self.query_one("#notes_textarea", TextArea).text)
|
||||
|
||||
return ShowDescriptor(**kwargs)
|
||||
|
||||
@@ -484,3 +536,6 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
def handle_delete_shifted_season(self, screenResult):
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, DataTable
|
||||
from textual.containers import Grid
|
||||
from rich.text import Text
|
||||
|
||||
from .i18n import t
|
||||
from .show_controller import ShowController
|
||||
from .screen_support import (
|
||||
add_auto_table_column,
|
||||
build_screen_log_pane,
|
||||
go_back_or_exit,
|
||||
update_table_column_label,
|
||||
)
|
||||
|
||||
from .show_details_screen import ShowDetailsScreen
|
||||
from .show_delete_screen import ShowDeleteScreen
|
||||
@@ -21,7 +29,10 @@ class ShowsScreen(Screen):
|
||||
grid-rows: 2 auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 80;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
DataTable .datatable--cursor {
|
||||
@@ -49,12 +60,17 @@ class ShowsScreen(Screen):
|
||||
height: 100%;
|
||||
border: solid green;
|
||||
}
|
||||
|
||||
DataTable {
|
||||
width: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("e", "edit_show", "Edit Show"),
|
||||
("n", "new_show", "New Show"),
|
||||
("d", "delete_show", "Delete Show"),
|
||||
("escape", "back", t("Back")),
|
||||
("e", "edit_show", t("Edit Show")),
|
||||
("n", "new_show", t("New Show")),
|
||||
("d", "delete_show", t("Delete Show")),
|
||||
]
|
||||
|
||||
|
||||
@@ -66,6 +82,78 @@ class ShowsScreen(Screen):
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
|
||||
self.__sc = ShowController(context = self.context)
|
||||
self.__showRowData: dict[object, ShowDescriptor] = {}
|
||||
self.__sortColumnKey = None
|
||||
self.__sortReverse = False
|
||||
self.__columnLabels: dict[object, str] = {}
|
||||
|
||||
|
||||
def _add_show_row(self, show_descriptor: ShowDescriptor):
|
||||
row_key = self.table.add_row(
|
||||
str(show_descriptor.getId()),
|
||||
str(show_descriptor.getName()),
|
||||
str(show_descriptor.getYear()),
|
||||
)
|
||||
self.__showRowData[row_key] = show_descriptor
|
||||
return row_key
|
||||
|
||||
def _get_selected_row_key(self):
|
||||
try:
|
||||
row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
|
||||
return row_key
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
|
||||
def _move_cursor_to_row_key(self, row_key):
|
||||
if row_key is None:
|
||||
return
|
||||
|
||||
try:
|
||||
row_index = int(self.table.get_row_index(row_key))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
self.table.move_cursor(row=row_index)
|
||||
|
||||
def _sort_key_for_column(self, column_key):
|
||||
if column_key == self.column_key_id:
|
||||
return lambda value: int(value)
|
||||
if column_key == self.column_key_year:
|
||||
return lambda value: int(value)
|
||||
if column_key == self.column_key_name:
|
||||
return lambda value: str(value).casefold()
|
||||
return None
|
||||
|
||||
def _update_header_labels(self):
|
||||
if not hasattr(self, "table"):
|
||||
return
|
||||
|
||||
arrow_up = "▴"
|
||||
arrow_down = "▾"
|
||||
|
||||
for column_key, base_label in self.__columnLabels.items():
|
||||
column = self.table.columns.get(column_key)
|
||||
if column is None:
|
||||
continue
|
||||
|
||||
label_text = base_label
|
||||
if column_key == self.__sortColumnKey:
|
||||
label_text = f"{base_label} {arrow_down if self.__sortReverse else arrow_up}"
|
||||
|
||||
update_table_column_label(self.table, column_key, Text(label_text))
|
||||
|
||||
def _apply_sort(self, *, preserve_row_key=None):
|
||||
if self.__sortColumnKey is None:
|
||||
self._update_header_labels()
|
||||
return
|
||||
|
||||
self.table.sort(
|
||||
self.__sortColumnKey,
|
||||
key=self._sort_key_for_column(self.__sortColumnKey),
|
||||
reverse=self.__sortReverse,
|
||||
)
|
||||
self._move_cursor_to_row_key(preserve_row_key)
|
||||
self._update_header_labels()
|
||||
|
||||
|
||||
def getSelectedShowId(self):
|
||||
@@ -76,13 +164,29 @@ class ShowsScreen(Screen):
|
||||
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.table.get_row(row_key)
|
||||
|
||||
return selected_row_data[0]
|
||||
selected_show = self.__showRowData.get(row_key)
|
||||
return selected_show.getId() if selected_show is not None else None
|
||||
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
|
||||
if event.data_table is not self.table:
|
||||
return
|
||||
|
||||
selected_row_key = self._get_selected_row_key()
|
||||
|
||||
if self.__sortColumnKey == event.column_key:
|
||||
self.__sortReverse = not self.__sortReverse
|
||||
else:
|
||||
self.__sortColumnKey = event.column_key
|
||||
self.__sortReverse = False
|
||||
|
||||
self._apply_sort(preserve_row_key=selected_row_key)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -90,9 +194,9 @@ class ShowsScreen(Screen):
|
||||
self.app.push_screen(ShowDetailsScreen(), self.handle_new_screen)
|
||||
|
||||
def handle_new_screen(self, screenResult):
|
||||
|
||||
show = (screenResult['id'], screenResult['name'], screenResult['year'])
|
||||
self.table.add_row(*map(str, show))
|
||||
if isinstance(screenResult, ShowDescriptor):
|
||||
row_key = self._add_show_row(screenResult)
|
||||
self._apply_sort(preserve_row_key=row_key)
|
||||
|
||||
|
||||
def action_edit_show(self):
|
||||
@@ -111,6 +215,8 @@ class ShowsScreen(Screen):
|
||||
|
||||
self.table.update_cell(row_key, self.column_key_name, showDescriptor.getName())
|
||||
self.table.update_cell(row_key, self.column_key_year, showDescriptor.getYear())
|
||||
self.__showRowData[row_key] = showDescriptor
|
||||
self._apply_sort(preserve_row_key=row_key)
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -131,15 +237,22 @@ class ShowsScreen(Screen):
|
||||
try:
|
||||
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
|
||||
self.table.remove_row(row_key)
|
||||
self.__showRowData.pop(row_key, None)
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
for show in self.__sc.getAllShows():
|
||||
row = (int(show.id), show.name, show.year) # Convert each element to a string before adding
|
||||
self.table.add_row(*map(str, row))
|
||||
self._add_show_row(show.getDescriptor(self.context))
|
||||
|
||||
self.__sortColumnKey = self.column_key_name
|
||||
self._apply_sort()
|
||||
|
||||
|
||||
def compose(self):
|
||||
@@ -148,21 +261,31 @@ class ShowsScreen(Screen):
|
||||
self.table = DataTable()
|
||||
|
||||
# Define the columns with headers
|
||||
self.column_key_id = self.table.add_column("ID", width=10)
|
||||
self.column_key_name = self.table.add_column("Name", width=50)
|
||||
self.column_key_year = self.table.add_column("Year", width=10)
|
||||
idLabel = t("ID")
|
||||
nameLabel = t("Name")
|
||||
yearLabel = t("Year")
|
||||
self.column_key_id = add_auto_table_column(self.table, idLabel)
|
||||
self.column_key_name = add_auto_table_column(self.table, nameLabel)
|
||||
self.column_key_year = add_auto_table_column(self.table, yearLabel)
|
||||
self.__columnLabels = {
|
||||
self.column_key_id: idLabel,
|
||||
self.column_key_name: nameLabel,
|
||||
self.column_key_year: yearLabel,
|
||||
}
|
||||
|
||||
self.table.cursor_type = 'row'
|
||||
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
# Row 1
|
||||
yield Static(t("Shows"), markup=False)
|
||||
|
||||
yield Static("Shows")
|
||||
|
||||
# Row 2
|
||||
yield self.table
|
||||
|
||||
f = Footer()
|
||||
f.description = "yolo"
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield f
|
||||
|
||||
@@ -2,19 +2,29 @@ from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button
|
||||
from textual.containers import Grid
|
||||
|
||||
from .i18n import t
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
class TagDeleteScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 4 9;
|
||||
grid-rows: 2 2 2 2 2 2 2 2 2;
|
||||
grid-columns: 30 30 30 30;
|
||||
grid-columns: 18 1fr 1fr 1fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 90;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -54,6 +64,9 @@ class TagDeleteScreen(Screen):
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
self.query_one("#keylabel", Static).update(str(self.__key))
|
||||
self.query_one("#valuelabel", Static).update(str(self.__value))
|
||||
|
||||
@@ -64,24 +77,25 @@ class TagDeleteScreen(Screen):
|
||||
|
||||
with Grid():
|
||||
|
||||
#1
|
||||
yield Static(f"Are you sure to delete this tag ?", id="toplabel", classes="five")
|
||||
# Row 1
|
||||
yield Static(t("Are you sure to delete this tag?"), id="toplabel", classes="five")
|
||||
|
||||
#2
|
||||
yield Static("Key")
|
||||
# Row 2
|
||||
yield Static(t("Key"))
|
||||
yield Static(" ", id="keylabel", classes="four")
|
||||
|
||||
#3
|
||||
yield Static("Value")
|
||||
# Row 3
|
||||
yield Static(t("Value"))
|
||||
yield Static(" ", id="valuelabel", classes="four")
|
||||
|
||||
#4
|
||||
# Row 4
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
#9
|
||||
yield Button("Delete", id="delete_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 5
|
||||
yield Button(t("Delete"), id="delete_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
@@ -96,3 +110,5 @@ class TagDeleteScreen(Screen):
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
@@ -2,19 +2,29 @@ from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button, Input
|
||||
from textual.containers import Grid
|
||||
|
||||
from .i18n import t
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
class TagDetailsScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 5 20;
|
||||
grid-rows: 2 2 2 2 2 3 2 2 2 2 2 6 2 2 6 2 2 2 2 6;
|
||||
grid-columns: 25 25 25 25 225;
|
||||
grid-columns: 18 1fr 1fr 1fr 5fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 100;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -77,6 +87,9 @@ class TagDetailsScreen(Screen):
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
if self.__key is not None:
|
||||
self.query_one("#key_input", Input).value = str(self.__key)
|
||||
|
||||
@@ -90,26 +103,28 @@ class TagDetailsScreen(Screen):
|
||||
|
||||
with Grid():
|
||||
|
||||
# 8
|
||||
yield Static("Key")
|
||||
# Row 1
|
||||
yield Static(t("Key"))
|
||||
yield Input(id="key_input", classes="four")
|
||||
|
||||
yield Static("Value")
|
||||
# Row 2
|
||||
yield Static(t("Value"))
|
||||
yield Input(id="value_input", classes="four")
|
||||
|
||||
# 17
|
||||
# Row 3
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 18
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 4
|
||||
yield Button(t("Save"), id="save_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
|
||||
# 19
|
||||
# Row 5
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 20
|
||||
# Row 6
|
||||
yield Static(" ", classes="five", id="messagestatic")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer(id="footer")
|
||||
|
||||
|
||||
@@ -120,6 +135,9 @@ class TagDetailsScreen(Screen):
|
||||
|
||||
return (tagKey, tagValue)
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
|
||||
# Event handler for button press
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
|
||||
@@ -3,20 +3,22 @@ from enum import Enum
|
||||
|
||||
class TrackCodec(Enum):
|
||||
|
||||
H265 = {'identifier': 'hevc', 'format': 'h265', 'extension': 'h265' ,'label': 'H.265'}
|
||||
VP9 = {'identifier': 'vp9', 'format': 'ivf', 'extension': 'ivf' , 'label': 'VP9'}
|
||||
H265 = {'identifier': 'hevc', 'format': None, 'extension': 'h265' ,'label': 'H.265'}
|
||||
H264 = {'identifier': 'h264', 'format': 'h264', 'extension': 'h264' ,'label': 'H.264'}
|
||||
MPEG4 = {'identifier': 'mpeg4', 'format': 'm4v', 'extension': 'm4v' ,'label': 'MPEG-4'}
|
||||
MPEG2 = {'identifier': 'mpeg2video', 'format': 'mpeg2video', 'extension': 'mpg' ,'label': 'MPEG-2'}
|
||||
|
||||
OPUS = {'identifier': 'opus', 'format': 'opus', 'extension': 'opus' , 'label': 'Opus'}
|
||||
AAC = {'identifier': 'aac', 'format': None, 'extension': 'aac' , 'label': 'AAC'}
|
||||
AC3 = {'identifier': 'ac3', 'format': 'ac3', 'extension': 'ac3' , 'label': 'AC3'}
|
||||
EAC3 = {'identifier': 'eac3', 'format': 'eac3', 'extension': 'eac3' , 'label': 'EAC3'}
|
||||
DTS = {'identifier': 'dts', 'format': 'dts', 'extension': 'dts' , 'label': 'DTS'}
|
||||
MP3 = {'identifier': 'mp3', 'format': 'mp3', 'extension': 'mp3' , 'label': 'MP3'}
|
||||
|
||||
WEBVTT = {'identifier': 'webvtt', 'format': 'webvtt', 'extension': 'vtt' , 'label': 'WebVTT'}
|
||||
SRT = {'identifier': 'subrip', 'format': 'srt', 'extension': 'srt' , 'label': 'SRT'}
|
||||
ASS = {'identifier': 'ass', 'format': 'ass', 'extension': 'ass' , 'label': 'ASS'}
|
||||
TTF = {'identifier': 'ttf', 'format': None, 'extension': 'ttf' , 'label': 'TTF'}
|
||||
PGS = {'identifier': 'hdmv_pgs_subtitle', 'format': 'sup', 'extension': 'sup' , 'label': 'PGS'}
|
||||
VOBSUB = {'identifier': 'dvd_subtitle', 'format': None, 'extension': 'mkv' , 'label': 'VobSub'}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ class TrackController():
|
||||
|
||||
|
||||
def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None):
|
||||
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||
return False
|
||||
|
||||
# option to override pattern id in case track descriptor has not set it
|
||||
patId = int(trackDescriptor.getPatternId() if patternId is None else patternId)
|
||||
@@ -43,7 +45,7 @@ class TrackController():
|
||||
s = self.Session()
|
||||
track = Track(pattern_id = patId,
|
||||
track_type = int(trackDescriptor.getType().index()),
|
||||
codec_name = str(trackDescriptor.getCodec().identifier()),
|
||||
codec_name = str(trackDescriptor.getFormatDescriptor().identifier()),
|
||||
index = int(trackDescriptor.getIndex()),
|
||||
source_index = int(trackDescriptor.getSourceIndex()),
|
||||
disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())),
|
||||
@@ -72,6 +74,8 @@ class TrackController():
|
||||
|
||||
if type(trackDescriptor) is not TrackDescriptor:
|
||||
raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor')
|
||||
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||
return False
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
@@ -82,7 +86,7 @@ class TrackController():
|
||||
track.index = int(trackDescriptor.getIndex())
|
||||
|
||||
track.track_type = int(trackDescriptor.getType().index())
|
||||
track.codec_name = str(trackDescriptor.getCodec().identifier())
|
||||
track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier())
|
||||
track.audio_layout = int(trackDescriptor.getAudioLayout().index())
|
||||
|
||||
track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet()))
|
||||
|
||||
@@ -5,20 +5,29 @@ from textual.widgets import Header, Footer, Static, Button
|
||||
from textual.containers import Grid
|
||||
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from .i18n import t
|
||||
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
class TrackDeleteScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 4 9;
|
||||
grid-rows: 2 2 2 2 2 2 2 2 2;
|
||||
grid-columns: 30 30 30 30;
|
||||
grid-columns: 18 1fr 1fr 1fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 90;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -58,6 +67,9 @@ class TrackDeleteScreen(Screen):
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
self.query_one("#subindexlabel", Static).update(str(self.__trackDescriptor.getSubIndex()))
|
||||
self.query_one("#patternlabel", Static).update(str(self.__trackDescriptor.getPatternId()))
|
||||
self.query_one("#languagelabel", Static).update(str(self.__trackDescriptor.getLanguage().label()))
|
||||
@@ -70,38 +82,46 @@ class TrackDeleteScreen(Screen):
|
||||
|
||||
with Grid():
|
||||
|
||||
#1
|
||||
yield Static(f"Are you sure to delete the following {self.__trackDescriptor.getType().label()} track?", id="toplabel", classes="four")
|
||||
# Row 1
|
||||
yield Static(
|
||||
t(
|
||||
"Are you sure to delete the following {track_type} track?",
|
||||
track_type=t(self.__trackDescriptor.getType().label()),
|
||||
),
|
||||
id="toplabel",
|
||||
classes="four",
|
||||
)
|
||||
|
||||
#2
|
||||
yield Static("sub index")
|
||||
# Row 2
|
||||
yield Static(t("sub index"))
|
||||
yield Static(" ", id="subindexlabel", classes="three")
|
||||
|
||||
#3
|
||||
yield Static("from pattern")
|
||||
# Row 3
|
||||
yield Static(t("from pattern"))
|
||||
yield Static(" ", id="patternlabel", classes="three")
|
||||
|
||||
#4
|
||||
# Row 4
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
#5
|
||||
yield Static("Language")
|
||||
# Row 5
|
||||
yield Static(t("Language"))
|
||||
yield Static(" ", id="languagelabel", classes="three")
|
||||
|
||||
#6
|
||||
yield Static("Title")
|
||||
# Row 6
|
||||
yield Static(t("Title"))
|
||||
yield Static(" ", id="titlelabel", classes="three")
|
||||
|
||||
#7
|
||||
# Row 7
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
#8
|
||||
# Row 8
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
#9
|
||||
yield Button("Delete", id="delete_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
# Row 9
|
||||
yield Button(t("Delete"), id="delete_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer()
|
||||
|
||||
|
||||
@@ -113,3 +133,6 @@ class TrackDeleteScreen(Screen):
|
||||
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Self
|
||||
|
||||
from .attachment_format import AttachmentFormat
|
||||
from .iso_language import IsoLanguage
|
||||
from .track_type import TrackType
|
||||
from .audio_layout import AudioLayout
|
||||
@@ -26,6 +27,7 @@ class TrackDescriptor:
|
||||
|
||||
TRACK_TYPE_KEY = "track_type"
|
||||
CODEC_KEY = "codec_name"
|
||||
ATTACHMENT_FORMAT_KEY = "attachment_format"
|
||||
AUDIO_LAYOUT_KEY = "audio_layout"
|
||||
|
||||
FFPROBE_INDEX_KEY = "index"
|
||||
@@ -110,15 +112,6 @@ class TrackDescriptor:
|
||||
else:
|
||||
self.__trackType = TrackType.UNKNOWN
|
||||
|
||||
if TrackDescriptor.CODEC_KEY in kwargs.keys():
|
||||
if type(kwargs[TrackDescriptor.CODEC_KEY]) is not TrackCodec:
|
||||
raise TypeError(
|
||||
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec"
|
||||
)
|
||||
self.__trackCodec = kwargs[TrackDescriptor.CODEC_KEY]
|
||||
else:
|
||||
self.__trackCodec = TrackCodec.UNKNOWN
|
||||
|
||||
if TrackDescriptor.TAGS_KEY in kwargs.keys():
|
||||
if type(kwargs[TrackDescriptor.TAGS_KEY]) is not dict:
|
||||
raise TypeError(
|
||||
@@ -151,6 +144,34 @@ class TrackDescriptor:
|
||||
else:
|
||||
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
|
||||
|
||||
self.__trackCodec = TrackCodec.UNKNOWN
|
||||
self.__attachmentFormat = AttachmentFormat.UNKNOWN
|
||||
|
||||
if self.__trackType == TrackType.ATTACHMENT:
|
||||
if TrackDescriptor.ATTACHMENT_FORMAT_KEY in kwargs.keys():
|
||||
if type(kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY]) is not AttachmentFormat:
|
||||
raise TypeError(
|
||||
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.ATTACHMENT_FORMAT_KEY} is required to be of type AttachmentFormat"
|
||||
)
|
||||
self.__attachmentFormat = kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY]
|
||||
elif TrackDescriptor.CODEC_KEY in kwargs.keys():
|
||||
legacyCodec = kwargs[TrackDescriptor.CODEC_KEY]
|
||||
if type(legacyCodec) is AttachmentFormat:
|
||||
self.__attachmentFormat = legacyCodec
|
||||
elif type(legacyCodec) is TrackCodec:
|
||||
self.__attachmentFormat = AttachmentFormat.fromTrackCodec(legacyCodec)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec for legacy attachment compatibility"
|
||||
)
|
||||
else:
|
||||
if TrackDescriptor.CODEC_KEY in kwargs.keys():
|
||||
if type(kwargs[TrackDescriptor.CODEC_KEY]) is not TrackCodec:
|
||||
raise TypeError(
|
||||
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec"
|
||||
)
|
||||
self.__trackCodec = kwargs[TrackDescriptor.CODEC_KEY]
|
||||
|
||||
@classmethod
|
||||
def fromFfprobe(cls, streamObj, subIndex: int = -1):
|
||||
"""Processes ffprobe stream data as array with elements according to the following example
|
||||
@@ -215,7 +236,12 @@ class TrackDescriptor:
|
||||
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = trackType
|
||||
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.identify(streamObj[TrackDescriptor.FFPROBE_CODEC_KEY])
|
||||
if trackType == TrackType.ATTACHMENT:
|
||||
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = AttachmentFormat.identifyFfprobeStream(streamObj)
|
||||
else:
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.identify(
|
||||
streamObj.get(TrackDescriptor.FFPROBE_CODEC_KEY)
|
||||
)
|
||||
|
||||
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = (
|
||||
{
|
||||
@@ -277,6 +303,14 @@ class TrackDescriptor:
|
||||
def getCodec(self) -> TrackCodec:
|
||||
return self.__trackCodec
|
||||
|
||||
def getAttachmentFormat(self) -> AttachmentFormat:
|
||||
return self.__attachmentFormat
|
||||
|
||||
def getFormatDescriptor(self):
|
||||
if self.__trackType == TrackType.ATTACHMENT:
|
||||
return self.__attachmentFormat
|
||||
return self.__trackCodec
|
||||
|
||||
def getLanguage(self):
|
||||
if "language" in self.__trackTags.keys():
|
||||
return IsoLanguage.findThreeLetter(self.__trackTags["language"])
|
||||
@@ -343,3 +377,29 @@ class TrackDescriptor:
|
||||
|
||||
def getExternalSourceFilePath(self):
|
||||
return self.__externalSourceFilePath
|
||||
|
||||
def clone(self, context: dict | None = None):
|
||||
kwargs = {
|
||||
TrackDescriptor.ID_KEY: int(self.__trackId),
|
||||
TrackDescriptor.PATTERN_ID_KEY: int(self.__patternId),
|
||||
TrackDescriptor.EXTERNAL_SOURCE_FILE_PATH_KEY: str(self.__externalSourceFilePath),
|
||||
TrackDescriptor.INDEX_KEY: int(self.__index),
|
||||
TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex),
|
||||
TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex),
|
||||
TrackDescriptor.TRACK_TYPE_KEY: self.__trackType,
|
||||
TrackDescriptor.TAGS_KEY: dict(self.__trackTags),
|
||||
TrackDescriptor.DISPOSITION_SET_KEY: set(self.__dispositionSet),
|
||||
TrackDescriptor.AUDIO_LAYOUT_KEY: self.__audioLayout,
|
||||
}
|
||||
|
||||
if self.__trackType == TrackType.ATTACHMENT:
|
||||
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.__attachmentFormat
|
||||
else:
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
|
||||
|
||||
if context is not None:
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = context
|
||||
elif self.__context:
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = self.__context
|
||||
|
||||
return TrackDescriptor(**kwargs)
|
||||
|
||||
@@ -5,6 +5,7 @@ from textual.widgets import Header, Footer, Static, Button, SelectionList, Selec
|
||||
from textual.containers import Grid
|
||||
from textual.widgets._data_table import CellDoesNotExist
|
||||
|
||||
from .attachment_format import AttachmentFormat
|
||||
from .audio_layout import AudioLayout
|
||||
from .iso_language import IsoLanguage
|
||||
from .tag_delete_screen import TagDeleteScreen
|
||||
@@ -13,21 +14,34 @@ from .track_codec import TrackCodec
|
||||
from .track_descriptor import TrackDescriptor
|
||||
from .track_disposition import TrackDisposition
|
||||
from .track_type import TrackType
|
||||
|
||||
from ffx.helper import formatRichColor, removeRichColor
|
||||
from .i18n import t
|
||||
from .screen_support import (
|
||||
add_auto_table_column,
|
||||
build_screen_bootstrap,
|
||||
build_screen_log_pane,
|
||||
go_back_or_exit,
|
||||
populate_tag_table,
|
||||
)
|
||||
|
||||
|
||||
class TrackDetailsScreen(Screen):
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "back", t("Back")),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 5 24;
|
||||
grid-rows: 2 2 2 2 2 3 3 2 2 3 2 2 2 2 2 6 2 2 6 2 2 2;
|
||||
grid-columns: 25 25 25 25 125;
|
||||
grid-columns: 18 1fr 1fr 1fr 4fr;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 115;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
@@ -46,6 +60,7 @@ class TrackDetailsScreen(Screen):
|
||||
|
||||
DataTable {
|
||||
min-height: 6;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
DataTable .datatable--cursor {
|
||||
@@ -95,31 +110,16 @@ class TrackDetailsScreen(Screen):
|
||||
trackType: TrackType = None,
|
||||
index=None,
|
||||
subIndex=None,
|
||||
metadata_only: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
bootstrap = build_screen_bootstrap(self.app.getContext())
|
||||
self.context = bootstrap.context
|
||||
|
||||
self.__configurationData = self.context["config"].getData()
|
||||
|
||||
metadataConfiguration = (
|
||||
self.__configurationData["metadata"]
|
||||
if "metadata" in self.__configurationData.keys()
|
||||
else {}
|
||||
)
|
||||
|
||||
self.__removeTrackKeys = (
|
||||
metadataConfiguration["streams"]["remove"]
|
||||
if "streams" in metadataConfiguration.keys()
|
||||
and "remove" in metadataConfiguration["streams"].keys()
|
||||
else []
|
||||
)
|
||||
self.__ignoreTrackKeys = (
|
||||
metadataConfiguration["streams"]["ignore"]
|
||||
if "streams" in metadataConfiguration.keys()
|
||||
and "ignore" in metadataConfiguration["streams"].keys()
|
||||
else []
|
||||
)
|
||||
self.__removeTrackKeys = bootstrap.remove_track_keys
|
||||
self.__ignoreTrackKeys = bootstrap.ignore_track_keys
|
||||
self.__tagRowData: dict[object, tuple[str, str]] = {}
|
||||
|
||||
self.__isNew = trackDescriptor is None
|
||||
self.__trackDescriptor = trackDescriptor
|
||||
@@ -134,17 +134,25 @@ class TrackDetailsScreen(Screen):
|
||||
)
|
||||
self.__patternLabel = str(patternLabel)
|
||||
self.__siblingTrackDescriptors = list(siblingTrackDescriptors or [])
|
||||
self.__metadataOnly = bool(metadata_only)
|
||||
self.__applyNormalization = bool(
|
||||
self.context.get("apply_metadata_normalization", True)
|
||||
)
|
||||
|
||||
if self.__isNew:
|
||||
self.__trackType = trackType
|
||||
self.__trackCodec = TrackCodec.UNKNOWN
|
||||
self.__attachmentFormat = AttachmentFormat.UNKNOWN
|
||||
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
|
||||
self.__index = index
|
||||
self.__subIndex = subIndex
|
||||
self.__draftTrackTags = {}
|
||||
initial_language = IsoLanguage.UNDEFINED
|
||||
initial_title = ""
|
||||
else:
|
||||
self.__trackType = trackDescriptor.getType()
|
||||
self.__trackCodec = trackDescriptor.getCodec()
|
||||
self.__attachmentFormat = trackDescriptor.getAttachmentFormat()
|
||||
self.__audioLayout = trackDescriptor.getAudioLayout()
|
||||
self.__index = trackDescriptor.getIndex()
|
||||
self.__subIndex = trackDescriptor.getSubIndex()
|
||||
@@ -153,6 +161,19 @@ class TrackDetailsScreen(Screen):
|
||||
for key, value in trackDescriptor.getTags().items()
|
||||
if key not in ("language", "title")
|
||||
}
|
||||
initial_language = trackDescriptor.getLanguage()
|
||||
initial_title = trackDescriptor.getTitle()
|
||||
|
||||
initialTitleEmpty = not str(initial_title).strip()
|
||||
self.__titleAutoManaged = bool(
|
||||
initialTitleEmpty
|
||||
and (
|
||||
initial_language == IsoLanguage.UNDEFINED
|
||||
or (self.__metadataOnly and self.__applyNormalization)
|
||||
)
|
||||
)
|
||||
self.__suppressTitleChanged = False
|
||||
self.__lastAutoTitle = ""
|
||||
|
||||
def _descriptor_refs_same_track(self, descriptor: TrackDescriptor) -> bool:
|
||||
if self.__trackDescriptor is None:
|
||||
@@ -166,21 +187,61 @@ class TrackDetailsScreen(Screen):
|
||||
)
|
||||
|
||||
def updateTags(self):
|
||||
self.__tagRowData = populate_tag_table(
|
||||
self.trackTagsTable,
|
||||
self.__draftTrackTags,
|
||||
ignore_keys=self.__ignoreTrackKeys,
|
||||
remove_keys=self.__removeTrackKeys,
|
||||
)
|
||||
|
||||
self.trackTagsTable.clear()
|
||||
@staticmethod
|
||||
def build_language_options():
|
||||
return [
|
||||
(language.label(), language)
|
||||
for language in sorted(
|
||||
[language for language in IsoLanguage if language != IsoLanguage.UNDEFINED],
|
||||
key=lambda language: language.label().casefold(),
|
||||
)
|
||||
]
|
||||
|
||||
for key, value in self.__draftTrackTags.items():
|
||||
textColor = None
|
||||
if key in self.__ignoreTrackKeys:
|
||||
textColor = "blue"
|
||||
if key in self.__removeTrackKeys:
|
||||
textColor = "red"
|
||||
@staticmethod
|
||||
def language_select_value(language):
|
||||
return Select.NULL if language == IsoLanguage.UNDEFINED else language
|
||||
|
||||
row = (formatRichColor(key, textColor), formatRichColor(value, textColor))
|
||||
self.trackTagsTable.add_row(*map(str, row))
|
||||
def _apply_auto_title_for_language(self, language: IsoLanguage):
|
||||
titleInput = self.query_one("#title_input", Input)
|
||||
autoTitle = "" if language == IsoLanguage.UNDEFINED else language.label()
|
||||
self.__suppressTitleChanged = True
|
||||
titleInput.value = autoTitle
|
||||
self.__suppressTitleChanged = False
|
||||
self.__lastAutoTitle = autoTitle
|
||||
|
||||
def _handle_language_selection_changed(self, language):
|
||||
if not self.__titleAutoManaged:
|
||||
return
|
||||
|
||||
if not isinstance(language, IsoLanguage):
|
||||
language = IsoLanguage.UNDEFINED
|
||||
|
||||
self._apply_auto_title_for_language(language)
|
||||
|
||||
def _handle_title_input_changed(self, titleValue: str):
|
||||
if self.__suppressTitleChanged or not self.__titleAutoManaged:
|
||||
return
|
||||
|
||||
language = self.query_one("#language_select", Select).value
|
||||
if not isinstance(language, IsoLanguage):
|
||||
language = IsoLanguage.UNDEFINED
|
||||
|
||||
expectedAutoTitle = "" if language == IsoLanguage.UNDEFINED else language.label()
|
||||
if str(titleValue) != expectedAutoTitle:
|
||||
self.__titleAutoManaged = False
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if getattr(self, 'context', {}).get('debug', False):
|
||||
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||
|
||||
self.query_one("#index_label", Static).update(
|
||||
str(self.__index) if self.__index is not None else "-"
|
||||
)
|
||||
@@ -190,9 +251,9 @@ class TrackDetailsScreen(Screen):
|
||||
self.query_one("#pattern_label", Static).update(self.__patternLabel)
|
||||
|
||||
if self.__trackType is not None:
|
||||
self.query_one("#type_select", Select).value = self.__trackType.label()
|
||||
self.query_one("#type_select", Select).value = self.__trackType
|
||||
|
||||
self.query_one("#audio_layout_select", Select).value = self.__audioLayout.label()
|
||||
self.query_one("#audio_layout_select", Select).value = self.__audioLayout
|
||||
|
||||
for disposition in TrackDisposition:
|
||||
|
||||
@@ -202,7 +263,7 @@ class TrackDetailsScreen(Screen):
|
||||
)
|
||||
|
||||
dispositionOption = (
|
||||
disposition.label(),
|
||||
t(disposition.label()),
|
||||
disposition.index(),
|
||||
dispositionIsSet,
|
||||
)
|
||||
@@ -211,101 +272,144 @@ class TrackDetailsScreen(Screen):
|
||||
)
|
||||
|
||||
if self.__trackDescriptor is not None:
|
||||
self.query_one("#language_select", Select).value = (
|
||||
self.__trackDescriptor.getLanguage().label()
|
||||
self.query_one("#language_select", Select).value = self.language_select_value(
|
||||
self.__trackDescriptor.getLanguage()
|
||||
)
|
||||
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
|
||||
if self.__titleAutoManaged and not self.__trackDescriptor.getTitle().strip():
|
||||
self._apply_auto_title_for_language(self.__trackDescriptor.getLanguage())
|
||||
self.updateTags()
|
||||
|
||||
if self.__metadataOnly:
|
||||
self.query_one("#type_select", Select).disabled = True
|
||||
self.query_one("#audio_layout_select", Select).disabled = True
|
||||
|
||||
def on_select_changed(self, event: Select.Changed) -> None:
|
||||
if event.select.id == "language_select":
|
||||
self._handle_language_selection_changed(event.value)
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
if event.input.id == "title_input":
|
||||
self._handle_title_input_changed(event.value)
|
||||
|
||||
def compose(self):
|
||||
|
||||
self.trackTagsTable = DataTable(classes="five")
|
||||
|
||||
self.column_key_track_tag_key = self.trackTagsTable.add_column("Key", width=50)
|
||||
self.column_key_track_tag_value = self.trackTagsTable.add_column("Value", width=100)
|
||||
self.column_key_track_tag_key = add_auto_table_column(self.trackTagsTable, t("Key"))
|
||||
self.column_key_track_tag_value = add_auto_table_column(self.trackTagsTable, t("Value"))
|
||||
|
||||
self.trackTagsTable.cursor_type = "row"
|
||||
|
||||
languages = [language.label() for language in IsoLanguage]
|
||||
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
|
||||
# Row 1
|
||||
yield Static(
|
||||
"New stream" if self.__isNew else "Edit stream",
|
||||
t("New stream") if self.__isNew else t("Edit stream"),
|
||||
id="toplabel",
|
||||
classes="five",
|
||||
)
|
||||
|
||||
yield Static("for pattern")
|
||||
# Row 2
|
||||
yield Static(t("for pattern"))
|
||||
yield Static("", id="pattern_label", classes="four", markup=False)
|
||||
|
||||
# Row 3
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
yield Static("Index / Subindex")
|
||||
# Row 4
|
||||
yield Static(t("Index / Subindex"))
|
||||
yield Static("", id="index_label", classes="two")
|
||||
yield Static("", id="subindex_label", classes="two")
|
||||
|
||||
# Row 5
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
yield Static("Type")
|
||||
yield Select.from_values(
|
||||
[trackType.label() for trackType in TrackType],
|
||||
# Row 6
|
||||
yield Static(t("Type"))
|
||||
yield Select(
|
||||
[(t(trackType.label()), trackType) for trackType in TrackType],
|
||||
classes="four",
|
||||
id="type_select",
|
||||
)
|
||||
|
||||
yield Static("Audio Layout")
|
||||
yield Select.from_values(
|
||||
[layout.label() for layout in AudioLayout],
|
||||
# Row 7
|
||||
yield Static(t("Audio Layout"))
|
||||
yield Select(
|
||||
[(t(layout.label()), layout) for layout in AudioLayout],
|
||||
classes="four",
|
||||
id="audio_layout_select",
|
||||
)
|
||||
|
||||
# Row 8
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# Row 9
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
yield Static("Language")
|
||||
yield Select.from_values(languages, classes="four", id="language_select")
|
||||
# Row 10
|
||||
yield Static(t("Language"))
|
||||
yield Select(
|
||||
self.build_language_options(),
|
||||
prompt=t("Select"),
|
||||
classes="four",
|
||||
id="language_select",
|
||||
)
|
||||
|
||||
# Row 11
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
yield Static("Title")
|
||||
# Row 12
|
||||
yield Static(t("Title"))
|
||||
yield Input(id="title_input", classes="four")
|
||||
|
||||
# Row 13
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# Row 14
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
yield Static("Stream tags")
|
||||
# Row 15
|
||||
yield Static(t("Stream tags"))
|
||||
yield Static(" ")
|
||||
yield Button("Add", id="button_add_stream_tag")
|
||||
yield Button("Edit", id="button_edit_stream_tag")
|
||||
yield Button("Delete", id="button_delete_stream_tag")
|
||||
yield Button(t("Add"), id="button_add_stream_tag")
|
||||
yield Button(t("Edit"), id="button_edit_stream_tag")
|
||||
yield Button(t("Delete"), id="button_delete_stream_tag")
|
||||
|
||||
# Row 16
|
||||
yield self.trackTagsTable
|
||||
|
||||
# Row 17
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
yield Static("Stream dispositions", classes="five")
|
||||
# Row 18
|
||||
yield Static(t("Stream dispositions"), classes="five")
|
||||
|
||||
# Row 19
|
||||
yield SelectionList[int](
|
||||
classes="five",
|
||||
id="dispositions_selection_list",
|
||||
)
|
||||
|
||||
yield Static(" ", classes="five")
|
||||
# Row 20
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
|
||||
# Row 21
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# Row 22
|
||||
yield Button(t("Save"), id="save_button")
|
||||
yield Button(t("Cancel"), id="cancel_button")
|
||||
|
||||
# Row 23
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# Row 24
|
||||
yield Static(" ", classes="five", id="messagestatic")
|
||||
|
||||
yield build_screen_log_pane()
|
||||
yield Footer(id="footer")
|
||||
|
||||
def getTrackDescriptorFromInput(self):
|
||||
@@ -328,15 +432,21 @@ class TrackDetailsScreen(Screen):
|
||||
if self.__subIndex is not None and int(self.__subIndex) >= 0:
|
||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(self.__subIndex)
|
||||
|
||||
selectedTrackType = TrackType.fromLabel(
|
||||
self.query_one("#type_select", Select).value
|
||||
)
|
||||
selectedTrackType = self.query_one("#type_select", Select).value
|
||||
if not isinstance(selectedTrackType, TrackType):
|
||||
selectedTrackType = TrackType.UNKNOWN
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType
|
||||
if selectedTrackType == TrackType.ATTACHMENT:
|
||||
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.__attachmentFormat
|
||||
else:
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
|
||||
|
||||
if selectedTrackType == TrackType.AUDIO:
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(
|
||||
self.query_one("#audio_layout_select", Select).value
|
||||
selectedAudioLayout = self.query_one("#audio_layout_select", Select).value
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = (
|
||||
selectedAudioLayout
|
||||
if isinstance(selectedAudioLayout, AudioLayout)
|
||||
else AudioLayout.LAYOUT_UNDEFINED
|
||||
)
|
||||
else:
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED
|
||||
@@ -344,8 +454,8 @@ class TrackDetailsScreen(Screen):
|
||||
trackTags = dict(self.__draftTrackTags)
|
||||
|
||||
language = self.query_one("#language_select", Select).value
|
||||
if language:
|
||||
trackTags["language"] = IsoLanguage.find(language).threeLetter()
|
||||
if isinstance(language, IsoLanguage):
|
||||
trackTags["language"] = language.threeLetter()
|
||||
|
||||
title = self.query_one("#title_input", Input).value
|
||||
if title:
|
||||
@@ -362,6 +472,9 @@ class TrackDetailsScreen(Screen):
|
||||
|
||||
return TrackDescriptor(**kwargs)
|
||||
|
||||
def action_back(self):
|
||||
go_back_or_exit(self)
|
||||
|
||||
def getSelectedTag(self):
|
||||
|
||||
try:
|
||||
@@ -370,12 +483,7 @@ class TrackDetailsScreen(Screen):
|
||||
)
|
||||
|
||||
if row_key is not None:
|
||||
selected_tag_data = self.trackTagsTable.get_row(row_key)
|
||||
|
||||
tagKey = removeRichColor(selected_tag_data[0])
|
||||
tagValue = removeRichColor(selected_tag_data[1])
|
||||
|
||||
return tagKey, tagValue
|
||||
return self.__tagRowData.get(row_key)
|
||||
|
||||
return None
|
||||
|
||||
@@ -427,8 +535,10 @@ class TrackDetailsScreen(Screen):
|
||||
):
|
||||
|
||||
self.query_one("#messagestatic", Static).update(
|
||||
t(
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.query_one("#messagestatic", Static).update(" ")
|
||||
self.dismiss(trackDescriptor)
|
||||
|
||||
@@ -18,6 +18,7 @@ from tests.support.ffx_bundle import (
|
||||
write_vtt,
|
||||
)
|
||||
|
||||
from ffx.attachment_format import AttachmentFormat
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
try:
|
||||
@@ -280,6 +281,72 @@ class SubtrackMappingBundleTests(unittest.TestCase):
|
||||
self.assertIn("non-existent source track #99", error_output)
|
||||
self.assertFalse(expected_output_path(self.workdir, source_filename).exists())
|
||||
|
||||
def test_styled_ass_source_preserves_current_font_attachments_when_pattern_count_differs(self):
|
||||
source_filename = "styled_ass_s01e01.mkv"
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="subtitle-2",
|
||||
language="eng",
|
||||
subtitle_lines=("styled subtitle payload",),
|
||||
),
|
||||
SourceTrackSpec(TrackType.ATTACHMENT, attachment_name="current.ttf"),
|
||||
],
|
||||
subtitle_encoder="ass",
|
||||
)
|
||||
|
||||
prepare_pattern_database(
|
||||
self.database_path,
|
||||
r"^styled_ass_(s[0-9]+e[0-9]+)\.mkv$",
|
||||
[
|
||||
PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO),
|
||||
PatternTrackSpec(index=1, source_index=1, track_type=TrackType.AUDIO),
|
||||
PatternTrackSpec(index=2, source_index=2, track_type=TrackType.SUBTITLE),
|
||||
PatternTrackSpec(
|
||||
index=3,
|
||||
source_index=3,
|
||||
track_type=TrackType.ATTACHMENT,
|
||||
attachment_format=AttachmentFormat.TTF,
|
||||
),
|
||||
PatternTrackSpec(
|
||||
index=4,
|
||||
source_index=4,
|
||||
track_type=TrackType.ATTACHMENT,
|
||||
attachment_format=AttachmentFormat.TTF,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_convert(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--video-encoder",
|
||||
"copy",
|
||||
"--no-tmdb",
|
||||
"--no-prompt",
|
||||
"--no-signature",
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
self.assertIn("Styled ASS subtitles", completed.stdout)
|
||||
|
||||
output_path = expected_output_path(self.workdir, source_filename)
|
||||
streams = ffprobe_json(output_path)["streams"]
|
||||
|
||||
self.assertEqual(
|
||||
[stream["codec_type"] for stream in streams],
|
||||
["video", "audio", "subtitle", "attachment"],
|
||||
)
|
||||
self.assertEqual(streams[2]["codec_name"], "ass")
|
||||
self.assertEqual(streams[3]["codec_name"], "ttf")
|
||||
self.assertEqual(get_tag(streams[3], "filename"), "current.ttf")
|
||||
|
||||
def test_external_subtitle_file_replaces_payload_and_overrides_metadata(self):
|
||||
source_filename = "substitute_s01e01.mkv"
|
||||
self.write_config(
|
||||
|
||||
@@ -18,6 +18,7 @@ from tests.support.ffx_bundle import (
|
||||
from ffx.pattern_controller import PatternController
|
||||
from ffx.show_controller import ShowController
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.track_codec import TrackCodec
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.track_type import TrackType
|
||||
@@ -109,6 +110,31 @@ class UnmuxCliTests(unittest.TestCase):
|
||||
finally:
|
||||
dispose_controller_context(context)
|
||||
|
||||
def add_show_shift(
|
||||
self,
|
||||
*,
|
||||
show_id: int,
|
||||
original_season: int,
|
||||
first_episode: int,
|
||||
last_episode: int,
|
||||
season_offset: int,
|
||||
episode_offset: int,
|
||||
) -> None:
|
||||
context = build_controller_context(self.database_path)
|
||||
try:
|
||||
ShiftedSeasonController(context).addShiftedSeason(
|
||||
showId=show_id,
|
||||
shiftedSeasonObj={
|
||||
"original_season": original_season,
|
||||
"first_episode": first_episode,
|
||||
"last_episode": last_episode,
|
||||
"season_offset": season_offset,
|
||||
"episode_offset": episode_offset,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
dispose_controller_context(context)
|
||||
|
||||
def test_subtitles_only_without_output_directory_uses_configured_base_plus_label(self):
|
||||
self.write_config(
|
||||
{
|
||||
@@ -223,6 +249,55 @@ class UnmuxCliTests(unittest.TestCase):
|
||||
output_filenames,
|
||||
)
|
||||
|
||||
def test_unmux_applies_shifted_season_mapping_to_output_filenames(self):
|
||||
self.seed_matching_show(
|
||||
r"^unmux_([sS][0-9]+[eE][0-9]+)\.mkv$",
|
||||
indicator_season_digits=2,
|
||||
indicator_episode_digits=2,
|
||||
)
|
||||
self.add_show_shift(
|
||||
show_id=1,
|
||||
original_season=1,
|
||||
first_episode=1,
|
||||
last_episode=99,
|
||||
season_offset=1,
|
||||
episode_offset=-88,
|
||||
)
|
||||
source_filename = "unmux_s01e89.mkv"
|
||||
output_directory = self.workdir / "unmux-output"
|
||||
output_directory.mkdir()
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="subtitle-1",
|
||||
language="eng",
|
||||
subtitle_lines=("subtitle payload",),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_unmux(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--label",
|
||||
"dball",
|
||||
"--output-directory",
|
||||
str(output_directory),
|
||||
"--subtitles-only",
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
|
||||
self.assertIn(
|
||||
"Unmuxing stream 1 into file dball_S02E01_1_eng",
|
||||
completed.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@@ -17,6 +18,7 @@ if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx.attachment_format import AttachmentFormat
|
||||
from ffx.audio_layout import AudioLayout
|
||||
from ffx.database import databaseContext
|
||||
from ffx.pattern_controller import PatternController
|
||||
@@ -55,6 +57,7 @@ class PatternTrackSpec:
|
||||
tags: Mapping[str, str] = field(default_factory=dict)
|
||||
dispositions: tuple[TrackDisposition, ...] = ()
|
||||
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO
|
||||
attachment_format: AttachmentFormat = AttachmentFormat.UNKNOWN
|
||||
|
||||
|
||||
def make_logger(name: str) -> logging.Logger:
|
||||
@@ -95,8 +98,69 @@ def write_vtt(path: Path, lines: tuple[str, ...]) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrackSpec], duration_seconds: int = 1) -> Path:
|
||||
@lru_cache(maxsize=None)
|
||||
def _ffmpeg_encoder_is_available(encoder_name: str) -> bool:
|
||||
completed = subprocess.run(
|
||||
["ffmpeg", "-encoders"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
return False
|
||||
|
||||
encoder_label = str(encoder_name).strip()
|
||||
for line in completed.stdout.splitlines():
|
||||
if not line.startswith(" "):
|
||||
continue
|
||||
|
||||
tokens = line.split(maxsplit=2)
|
||||
if len(tokens) >= 2 and tokens[1] == encoder_label:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_fixture_video_encoder(
|
||||
video_encoder: str,
|
||||
video_encoder_options: tuple[str, ...],
|
||||
) -> tuple[str, tuple[str, ...]]:
|
||||
if video_encoder != "libx264":
|
||||
return video_encoder, video_encoder_options
|
||||
|
||||
if _ffmpeg_encoder_is_available("libx264"):
|
||||
return video_encoder, video_encoder_options
|
||||
|
||||
if _ffmpeg_encoder_is_available("libopenh264"):
|
||||
# Keep fixture generation software-based when libx264 is missing.
|
||||
return "libopenh264", ("-pix_fmt", "yuv420p")
|
||||
|
||||
return video_encoder, video_encoder_options
|
||||
|
||||
|
||||
def create_source_fixture(
|
||||
workdir: Path,
|
||||
filename: str,
|
||||
tracks: list[SourceTrackSpec],
|
||||
duration_seconds: int = 1,
|
||||
*,
|
||||
video_encoder: str = "libx264",
|
||||
video_encoder_options: tuple[str, ...] = (
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-crf",
|
||||
"35",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
),
|
||||
audio_encoder: str = "aac",
|
||||
audio_encoder_options: tuple[str, ...] = ("-b:a", "48k"),
|
||||
subtitle_encoder: str = "webvtt",
|
||||
) -> Path:
|
||||
output_path = workdir / filename
|
||||
video_encoder, video_encoder_options = _resolve_fixture_video_encoder(
|
||||
video_encoder,
|
||||
video_encoder_options,
|
||||
)
|
||||
|
||||
has_video = any(track.track_type == TrackType.VIDEO for track in tracks)
|
||||
has_audio = any(track.track_type == TrackType.AUDIO for track in tracks)
|
||||
@@ -189,21 +253,16 @@ def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrack
|
||||
command += map_tokens
|
||||
command += metadata_tokens
|
||||
command += disposition_tokens
|
||||
if has_video:
|
||||
command += ["-c:v", video_encoder] + list(video_encoder_options)
|
||||
|
||||
if has_audio:
|
||||
command += ["-c:a", audio_encoder] + list(audio_encoder_options)
|
||||
|
||||
if subtitle_input_indices:
|
||||
command += ["-c:s", subtitle_encoder]
|
||||
|
||||
command += [
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-crf",
|
||||
"35",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"48k",
|
||||
"-c:s",
|
||||
"webvtt",
|
||||
"-t",
|
||||
str(duration_seconds),
|
||||
"-shortest",
|
||||
@@ -242,6 +301,8 @@ def prepare_pattern_database(database_path: Path, filename_pattern: str, track_s
|
||||
}
|
||||
if track.track_type == TrackType.AUDIO:
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout
|
||||
if track.track_type == TrackType.ATTACHMENT:
|
||||
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = track.attachment_format
|
||||
track_descriptors.append(TrackDescriptor(**kwargs))
|
||||
|
||||
pattern_id = PatternController(context).savePatternSchema(
|
||||
|
||||
211
tests/unit/test_cli_convert_diagnostics.py
Normal file
211
tests/unit/test_cli_convert_diagnostics.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx import cli # noqa: E402
|
||||
from ffx.diagnostics import FfmpegSkipFileWarning, recordUnremediedIssue # noqa: E402
|
||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||
|
||||
|
||||
class _FakeMediaDescriptor:
|
||||
def getVideoTracks(self):
|
||||
return []
|
||||
|
||||
def getAudioTracks(self):
|
||||
return []
|
||||
|
||||
def getSubtitleTracks(self):
|
||||
return []
|
||||
|
||||
def getAttachmentTracks(self):
|
||||
return []
|
||||
|
||||
def applyOverrides(self, overrides):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeFileProperties:
|
||||
def __init__(self, context, source_path):
|
||||
self.source_path = source_path
|
||||
|
||||
def getShowId(self):
|
||||
return -1
|
||||
|
||||
def getSeason(self):
|
||||
return -1
|
||||
|
||||
def getEpisode(self):
|
||||
return -1
|
||||
|
||||
def getMediaDescriptor(self):
|
||||
return _FakeMediaDescriptor()
|
||||
|
||||
def getPattern(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeShiftedSeasonController:
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
|
||||
def shiftSeason(self, show_id, season, episode, patternId=None):
|
||||
return season, episode
|
||||
|
||||
|
||||
class _FakeShowController:
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
|
||||
def getShowDescriptor(self, show_id):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeFfxController:
|
||||
calls: list[str] = []
|
||||
mode = "skip_first"
|
||||
|
||||
def __init__(self, context, *args, **kwargs):
|
||||
self.context = context
|
||||
|
||||
def runJob(self, sourcePath, *args, **kwargs):
|
||||
self.calls.append(sourcePath)
|
||||
if self.mode == "clean":
|
||||
return
|
||||
|
||||
if self.mode == "warn_unhandled" and sourcePath.endswith("episode1.avi"):
|
||||
recordUnremediedIssue(
|
||||
self.context,
|
||||
sourcePath,
|
||||
"unhandled-warning",
|
||||
)
|
||||
return
|
||||
|
||||
if self.mode == "skip_first" and sourcePath.endswith("episode1.avi"):
|
||||
message = (
|
||||
f"Skipping file {sourcePath}: ffmpeg still reported unset packet "
|
||||
+ "timestamps after retry with -fflags +genpts."
|
||||
)
|
||||
recordUnremediedIssue(
|
||||
self.context,
|
||||
sourcePath,
|
||||
"retry-with-generated-pts",
|
||||
)
|
||||
self.context["logger"].warning(message)
|
||||
raise FfmpegSkipFileWarning(message)
|
||||
|
||||
|
||||
class ConvertDiagnosticCliTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
logger = get_ffx_logger()
|
||||
for handler in list(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
self.home_dir = Path(self.tempdir.name) / "home"
|
||||
self.home_dir.mkdir()
|
||||
self.database_path = Path(self.tempdir.name) / "test.db"
|
||||
self.source_dir = Path(self.tempdir.name) / "source"
|
||||
self.source_dir.mkdir()
|
||||
self.source_one = self.source_dir / "episode1.avi"
|
||||
self.source_two = self.source_dir / "episode2.avi"
|
||||
self.source_one.write_bytes(b"one")
|
||||
self.source_two.write_bytes(b"two")
|
||||
_FakeFfxController.calls = []
|
||||
_FakeFfxController.mode = "skip_first"
|
||||
|
||||
def tearDown(self):
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def test_convert_continues_after_skipping_one_file_due_to_ffmpeg_diagnostic(self):
|
||||
runner = CliRunner()
|
||||
|
||||
with (
|
||||
patch("ffx.file_properties.FileProperties", _FakeFileProperties),
|
||||
patch("ffx.ffx_controller.FfxController", _FakeFfxController),
|
||||
patch(
|
||||
"ffx.shifted_season_controller.ShiftedSeasonController",
|
||||
_FakeShiftedSeasonController,
|
||||
),
|
||||
patch("ffx.show_controller.ShowController", _FakeShowController),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli.ffx,
|
||||
[
|
||||
"--database-file",
|
||||
str(self.database_path),
|
||||
"convert",
|
||||
"--no-tmdb",
|
||||
"--no-pattern",
|
||||
str(self.source_one),
|
||||
str(self.source_two),
|
||||
],
|
||||
env={**os.environ, "HOME": str(self.home_dir)},
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.exit_code, result.output)
|
||||
self.assertEqual(
|
||||
[str(self.source_one), str(self.source_two)],
|
||||
_FakeFfxController.calls,
|
||||
)
|
||||
self.assertIn("Skipping file", result.output)
|
||||
self.assertIn("-fflags +genpts", result.output)
|
||||
self.assertIn("Files with ffmpeg findings that require review:", result.output)
|
||||
self.assertIn(
|
||||
"episode1.avi: retry-with-generated-pts",
|
||||
result.output,
|
||||
)
|
||||
|
||||
def test_convert_prints_clean_summary_when_no_unremedied_issues_were_seen(self):
|
||||
runner = CliRunner()
|
||||
_FakeFfxController.mode = "clean"
|
||||
|
||||
with (
|
||||
patch("ffx.file_properties.FileProperties", _FakeFileProperties),
|
||||
patch("ffx.ffx_controller.FfxController", _FakeFfxController),
|
||||
patch(
|
||||
"ffx.shifted_season_controller.ShiftedSeasonController",
|
||||
_FakeShiftedSeasonController,
|
||||
),
|
||||
patch("ffx.show_controller.ShowController", _FakeShowController),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli.ffx,
|
||||
[
|
||||
"--database-file",
|
||||
str(self.database_path),
|
||||
"convert",
|
||||
"--no-tmdb",
|
||||
"--no-pattern",
|
||||
str(self.source_one),
|
||||
str(self.source_two),
|
||||
],
|
||||
env={**os.environ, "HOME": str(self.home_dir)},
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.exit_code, result.output)
|
||||
self.assertIn(
|
||||
"All files converted with no issues.",
|
||||
result.output,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
142
tests/unit/test_cli_inspect_shift.py
Normal file
142
tests/unit/test_cli_inspect_shift.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx import cli # noqa: E402
|
||||
|
||||
|
||||
class _FakePattern:
|
||||
def __init__(self, pattern_id: int):
|
||||
self._pattern_id = pattern_id
|
||||
|
||||
def getId(self):
|
||||
return self._pattern_id
|
||||
|
||||
|
||||
class _FakeFileProperties:
|
||||
def __init__(self, context, source_path):
|
||||
self.source_path = source_path
|
||||
|
||||
def getShowId(self):
|
||||
return 42 if self.source_path.endswith("mapped.mkv") else -1
|
||||
|
||||
def getSeason(self):
|
||||
if self.source_path.endswith("unknown.mkv"):
|
||||
return -1
|
||||
return 1
|
||||
|
||||
def getEpisode(self):
|
||||
if self.source_path.endswith("unknown.mkv"):
|
||||
return -1
|
||||
return 3
|
||||
|
||||
def getPattern(self):
|
||||
if self.source_path.endswith("mapped.mkv"):
|
||||
return _FakePattern(7)
|
||||
return None
|
||||
|
||||
|
||||
class _FakeShiftedSeasonController:
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
|
||||
def resolveShiftSeason(self, show_id, season, episode, patternId=None):
|
||||
if patternId is not None:
|
||||
return 2, 1, "pattern"
|
||||
return season, episode, "default"
|
||||
|
||||
|
||||
class InspectShiftCliTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
self.home_dir = Path(self.tempdir.name) / "home"
|
||||
self.home_dir.mkdir()
|
||||
self.database_path = Path(self.tempdir.name) / "test.db"
|
||||
self.source_dir = Path(self.tempdir.name) / "source"
|
||||
self.source_dir.mkdir()
|
||||
self.mapped_path = self.source_dir / "mapped.mkv"
|
||||
self.mapped_path.write_bytes(b"mapped")
|
||||
self.identity_path = self.source_dir / "identity.mkv"
|
||||
self.identity_path.write_bytes(b"identity")
|
||||
self.unknown_path = self.source_dir / "unknown.mkv"
|
||||
self.unknown_path.write_bytes(b"unknown")
|
||||
|
||||
def tearDown(self):
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def test_inspect_shift_prints_resolved_mapping_for_each_file(self):
|
||||
runner = CliRunner()
|
||||
|
||||
with (
|
||||
patch("ffx.file_properties.FileProperties", _FakeFileProperties),
|
||||
patch(
|
||||
"ffx.shifted_season_controller.ShiftedSeasonController",
|
||||
_FakeShiftedSeasonController,
|
||||
),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli.ffx,
|
||||
[
|
||||
"--database-file",
|
||||
str(self.database_path),
|
||||
"inspect",
|
||||
"--shift",
|
||||
str(self.mapped_path),
|
||||
str(self.identity_path),
|
||||
str(self.unknown_path),
|
||||
],
|
||||
env={**os.environ, "HOME": str(self.home_dir)},
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.exit_code, result.output)
|
||||
self.assertIn(
|
||||
f"{self.mapped_path}: 1/3 -> 2/1 from pattern",
|
||||
result.output,
|
||||
)
|
||||
self.assertIn(
|
||||
f"{self.identity_path}: none",
|
||||
result.output,
|
||||
)
|
||||
self.assertIn(
|
||||
f"{self.unknown_path}: no season/episode recognized",
|
||||
result.output,
|
||||
)
|
||||
|
||||
def test_inspect_without_shift_requires_exactly_one_filename(self):
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(
|
||||
cli.ffx,
|
||||
[
|
||||
"--database-file",
|
||||
str(self.database_path),
|
||||
"inspect",
|
||||
str(self.mapped_path),
|
||||
str(self.unknown_path),
|
||||
],
|
||||
env={**os.environ, "HOME": str(self.home_dir)},
|
||||
)
|
||||
|
||||
self.assertNotEqual(0, result.exit_code)
|
||||
self.assertIn(
|
||||
"Inspect without --shift requires exactly one filename.",
|
||||
result.output,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -168,6 +168,40 @@ class CliLazyImportTests(unittest.TestCase):
|
||||
result["modules"],
|
||||
)
|
||||
|
||||
def test_root_debug_flag_parses_without_loading_runtime_modules(self):
|
||||
result = self.run_python(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, {str(SRC_ROOT)!r})
|
||||
|
||||
import ffx.cli
|
||||
|
||||
context = ffx.cli.ffx.make_context(
|
||||
"ffx",
|
||||
["--debug", "help"],
|
||||
resilient_parsing=True,
|
||||
)
|
||||
|
||||
print(json.dumps({{
|
||||
"debug": context.params["debug"],
|
||||
"modules": {{
|
||||
module_name: module_name in sys.modules
|
||||
for module_name in {HEAVY_MODULES!r}
|
||||
}},
|
||||
}}))
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(result["debug"])
|
||||
self.assertTrue(
|
||||
all(not is_loaded for is_loaded in result["modules"].values()),
|
||||
result["modules"],
|
||||
)
|
||||
|
||||
def test_convert_cut_option_supports_flag_duration_and_start_duration_forms(self):
|
||||
result = self.run_python(
|
||||
textwrap.dedent(
|
||||
@@ -229,6 +263,92 @@ class CliLazyImportTests(unittest.TestCase):
|
||||
result["modules"],
|
||||
)
|
||||
|
||||
def test_convert_copy_flags_parse_without_loading_runtime_modules(self):
|
||||
result = self.run_python(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import click
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, {str(SRC_ROOT)!r})
|
||||
|
||||
import ffx.cli
|
||||
|
||||
context = ffx.cli.convert.make_context(
|
||||
"convert",
|
||||
["--copy-video", "--copy-audio"],
|
||||
resilient_parsing=True,
|
||||
)
|
||||
help_output = ffx.cli.convert.get_help(click.Context(ffx.cli.convert))
|
||||
|
||||
print(json.dumps({{
|
||||
"copy_video": context.params["copy_video"],
|
||||
"copy_audio": context.params["copy_audio"],
|
||||
"output": help_output,
|
||||
"modules": {{
|
||||
module_name: module_name in sys.modules
|
||||
for module_name in {HEAVY_MODULES!r}
|
||||
}},
|
||||
}}))
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(result["copy_video"])
|
||||
self.assertTrue(result["copy_audio"])
|
||||
self.assertIn("--copy-video", result["output"])
|
||||
self.assertIn("--copy-audio", result["output"])
|
||||
self.assertTrue(
|
||||
all(not is_loaded for is_loaded in result["modules"].values()),
|
||||
result["modules"],
|
||||
)
|
||||
|
||||
def test_edit_command_avoids_database_bootstrap(self):
|
||||
result = self.run_python(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from click.testing import CliRunner
|
||||
|
||||
sys.path.insert(0, {str(SRC_ROOT)!r})
|
||||
|
||||
import ffx.cli
|
||||
import ffx.ffx_app
|
||||
import ffx.logging_utils
|
||||
|
||||
ffx.ffx_app.FfxApp.run = lambda self: None
|
||||
ffx.logging_utils.configure_ffx_logger = lambda *args, **kwargs: None
|
||||
|
||||
runner = CliRunner()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sample_path = os.path.join(tmpdir, "sample.mkv")
|
||||
with open(sample_path, "w", encoding="utf-8"):
|
||||
pass
|
||||
|
||||
invoke_result = runner.invoke(
|
||||
ffx.cli.ffx,
|
||||
["--dry-run", "edit", sample_path],
|
||||
)
|
||||
|
||||
print(json.dumps({{
|
||||
"exit_code": invoke_result.exit_code,
|
||||
"output": invoke_result.output,
|
||||
"modules": {{
|
||||
module_name: module_name in sys.modules
|
||||
for module_name in {HEAVY_MODULES!r}
|
||||
}},
|
||||
}}))
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(0, result["exit_code"], result["output"])
|
||||
self.assertFalse(result["modules"]["ffx.database"], result["modules"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
89
tests/unit/test_cli_unmux_sequence.py
Normal file
89
tests/unit/test_cli_unmux_sequence.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx import cli # noqa: E402
|
||||
from ffx.track_codec import TrackCodec # noqa: E402
|
||||
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||
from ffx.track_type import TrackType # noqa: E402
|
||||
|
||||
|
||||
class UnmuxSequenceTests(unittest.TestCase):
|
||||
def test_h265_video_unmux_uses_annex_b_bitstream_filter_without_forced_format(self):
|
||||
track_descriptor = TrackDescriptor(
|
||||
index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
codec_name=TrackCodec.H265,
|
||||
tags={},
|
||||
disposition_set=set(),
|
||||
)
|
||||
|
||||
sequence = cli.getUnmuxSequence(
|
||||
track_descriptor,
|
||||
"input.mp4",
|
||||
"episode_0_eng",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
"input.mp4",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-bsf:v",
|
||||
"hevc_mp4toannexb",
|
||||
"episode_0_eng.h265",
|
||||
],
|
||||
sequence,
|
||||
)
|
||||
|
||||
def test_non_h265_unmux_keeps_generic_copy_behavior(self):
|
||||
track_descriptor = TrackDescriptor(
|
||||
index=1,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
codec_name=TrackCodec.SRT,
|
||||
tags={},
|
||||
disposition_set=set(),
|
||||
)
|
||||
|
||||
sequence = cli.getUnmuxSequence(
|
||||
track_descriptor,
|
||||
"input.mkv",
|
||||
"episode_1_eng",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
"input.mkv",
|
||||
"-map",
|
||||
"0:s:0",
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"srt",
|
||||
"episode_1_eng.srt",
|
||||
],
|
||||
sequence,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -57,7 +57,7 @@ class UpgradeCommandTests(unittest.TestCase):
|
||||
self.assertTrue(subprocess_calls[0][1]["capture_output"])
|
||||
self.assertTrue(subprocess_calls[0][1]["text"])
|
||||
|
||||
def test_upgrade_resets_before_checkout_and_pull_when_user_confirms(self):
|
||||
def test_upgrade_resets_then_fetches_and_checks_out_requested_branch_when_user_confirms(self):
|
||||
runner = CliRunner()
|
||||
repo_path = "/tmp/ffx-repo"
|
||||
pip_path = "/tmp/ffx-venv/bin/pip"
|
||||
@@ -68,11 +68,14 @@ class UpgradeCommandTests(unittest.TestCase):
|
||||
subprocess_calls.append((args, kwargs))
|
||||
if args == ['git', 'status', '--porcelain', '--untracked-files=no']:
|
||||
return self.make_completed(args, stdout="M src/ffx/constants.py\n")
|
||||
if args == ['git', 'rev-parse', '--abbrev-ref', 'HEAD']:
|
||||
return self.make_completed(args, stdout="main\n")
|
||||
return self.make_completed(args)
|
||||
|
||||
with (
|
||||
patch.object(cli, "getBundleRepoPath", return_value=repo_path),
|
||||
patch.object(cli, "getBundlePipPath", return_value=pip_path),
|
||||
patch.object(cli, "getBundleVersion", return_value="0.3.2"),
|
||||
patch.object(cli.os.path, "isdir", return_value=True),
|
||||
patch.object(cli.os.path, "isfile", return_value=True),
|
||||
patch.object(cli.subprocess, "run", side_effect=fake_run),
|
||||
@@ -81,20 +84,60 @@ class UpgradeCommandTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(0, result.exit_code, result.output)
|
||||
self.assertIn("Tracked local changes detected in the bundle repository:", result.output)
|
||||
self.assertIn("Updated FFX to version 0.3.2 from branch main.", result.output)
|
||||
self.assertEqual(
|
||||
[
|
||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||
['git', 'reset', '--hard', 'HEAD'],
|
||||
['git', 'checkout', 'main'],
|
||||
['git', 'pull'],
|
||||
['git', 'fetch', 'origin', 'main'],
|
||||
['git', 'checkout', '-B', 'main', 'FETCH_HEAD'],
|
||||
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||
[pip_path, 'install', '--editable', '.'],
|
||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
],
|
||||
[call[0] for call in subprocess_calls],
|
||||
)
|
||||
for args, kwargs in subprocess_calls[1:]:
|
||||
self.assertEqual(repo_path, kwargs["cwd"], args)
|
||||
|
||||
def test_upgrade_pulls_current_branch_when_no_branch_is_requested(self):
|
||||
runner = CliRunner()
|
||||
repo_path = "/tmp/ffx-repo"
|
||||
pip_path = "/tmp/ffx-venv/bin/pip"
|
||||
|
||||
subprocess_calls = []
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
subprocess_calls.append((args, kwargs))
|
||||
if args == ['git', 'status', '--porcelain', '--untracked-files=no']:
|
||||
return self.make_completed(args, stdout="")
|
||||
if args == ['git', 'rev-parse', '--abbrev-ref', 'HEAD']:
|
||||
return self.make_completed(args, stdout="develop\n")
|
||||
return self.make_completed(args)
|
||||
|
||||
with (
|
||||
patch.object(cli, "getBundleRepoPath", return_value=repo_path),
|
||||
patch.object(cli, "getBundlePipPath", return_value=pip_path),
|
||||
patch.object(cli, "getBundleVersion", return_value="0.3.3"),
|
||||
patch.object(cli.os.path, "isdir", return_value=True),
|
||||
patch.object(cli.os.path, "isfile", return_value=True),
|
||||
patch.object(cli.subprocess, "run", side_effect=fake_run),
|
||||
):
|
||||
result = runner.invoke(cli.ffx, ["upgrade"])
|
||||
|
||||
self.assertEqual(0, result.exit_code, result.output)
|
||||
self.assertIn("Updated FFX to version 0.3.3 from branch develop.", result.output)
|
||||
self.assertEqual(
|
||||
[
|
||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||
['git', 'pull'],
|
||||
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||
[pip_path, 'install', '--editable', '.'],
|
||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
],
|
||||
[call[0] for call in subprocess_calls],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -49,6 +49,9 @@ class ConfigureWorkstationScriptTests(unittest.TestCase):
|
||||
"HOME": str(self.home_dir),
|
||||
"PATH": f"{self.stub_bin_dir}:{os.environ.get('PATH', '')}",
|
||||
"FFX_PYTHON": str(BUNDLE_PYTHON),
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"LC_MESSAGES": "C.UTF-8",
|
||||
**env_overrides,
|
||||
}
|
||||
|
||||
@@ -76,6 +79,7 @@ class ConfigureWorkstationScriptTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
{
|
||||
"databasePath": str(self.home_dir / ".local" / "var" / "ffx" / "ffx.db"),
|
||||
"language": "de",
|
||||
"logDirectory": str(self.home_dir / ".local" / "var" / "log"),
|
||||
"subtitlesDirectory": str(
|
||||
self.home_dir / ".local" / "var" / "sync" / "subtitles"
|
||||
@@ -113,6 +117,24 @@ class ConfigureWorkstationScriptTests(unittest.TestCase):
|
||||
config_data,
|
||||
)
|
||||
|
||||
def test_script_seeds_system_language_into_default_config(self):
|
||||
completed = self.run_script(
|
||||
LANG="fr_FR.UTF-8",
|
||||
LC_ALL="fr_FR.UTF-8",
|
||||
LC_MESSAGES="fr_FR.UTF-8",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
0,
|
||||
completed.returncode,
|
||||
f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}",
|
||||
)
|
||||
|
||||
config_path = self.home_dir / ".local" / "etc" / "ffx.json"
|
||||
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual("fr", config_data["language"])
|
||||
|
||||
def test_script_honors_custom_template_override(self):
|
||||
custom_template_path = Path(self.tempdir.name) / "custom-config.j2"
|
||||
custom_template_path.write_text(
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import click
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
@@ -15,8 +18,18 @@ if str(SRC_ROOT) not in sys.path:
|
||||
|
||||
from ffx.constants import DATABASE_VERSION # noqa: E402
|
||||
from ffx.database import DATABASE_VERSION_KEY, databaseContext, getDatabaseVersion # noqa: E402
|
||||
from ffx.model.shifted_season import ShiftedSeason # noqa: E402
|
||||
from ffx.model.property import Property # noqa: E402
|
||||
from ffx.model.show import Show # noqa: E402
|
||||
from ffx.model.show import Base # noqa: E402
|
||||
from ffx.show_controller import ShowController # noqa: E402
|
||||
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController # noqa: E402
|
||||
|
||||
|
||||
class StaticConfig:
|
||||
def getData(self):
|
||||
return {}
|
||||
|
||||
|
||||
class DatabaseContextTests(unittest.TestCase):
|
||||
@@ -27,6 +40,115 @@ class DatabaseContextTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def create_demo_show_with_shift(self):
|
||||
database_context = databaseContext(str(self.database_path))
|
||||
context = {
|
||||
"database": database_context,
|
||||
"config": StaticConfig(),
|
||||
"logger": object(),
|
||||
}
|
||||
try:
|
||||
ShowController(context).updateShow(
|
||||
ShowDescriptor(id=1, name="Demo", year=2000)
|
||||
)
|
||||
shifted_season_id = ShiftedSeasonController(context).addShiftedSeason(
|
||||
showId=1,
|
||||
shiftedSeasonObj={
|
||||
"original_season": 1,
|
||||
"first_episode": 1,
|
||||
"last_episode": 10,
|
||||
"season_offset": 1,
|
||||
"episode_offset": -10,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
database_context["engine"].dispose()
|
||||
|
||||
return shifted_season_id
|
||||
|
||||
def rewrite_shows_table_without_show_fields(self, cursor):
|
||||
cursor.execute("ALTER TABLE shows RENAME TO shows_current")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE shows (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR,
|
||||
year INTEGER,
|
||||
index_season_digits INTEGER,
|
||||
index_episode_digits INTEGER,
|
||||
indicator_season_digits INTEGER,
|
||||
indicator_episode_digits INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO shows (
|
||||
id,
|
||||
name,
|
||||
year,
|
||||
index_season_digits,
|
||||
index_episode_digits,
|
||||
indicator_season_digits,
|
||||
indicator_episode_digits
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
year,
|
||||
index_season_digits,
|
||||
index_episode_digits,
|
||||
indicator_season_digits,
|
||||
indicator_episode_digits
|
||||
FROM shows_current
|
||||
"""
|
||||
)
|
||||
cursor.execute("DROP TABLE shows_current")
|
||||
|
||||
def rewrite_shifted_seasons_table_without_pattern_owner(self, cursor):
|
||||
cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_show_id")
|
||||
cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_pattern_id")
|
||||
cursor.execute(
|
||||
"ALTER TABLE shifted_seasons RENAME TO shifted_seasons_current"
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE shifted_seasons (
|
||||
id INTEGER PRIMARY KEY,
|
||||
show_id INTEGER,
|
||||
original_season INTEGER,
|
||||
first_episode INTEGER DEFAULT -1,
|
||||
last_episode INTEGER DEFAULT -1,
|
||||
season_offset INTEGER DEFAULT 0,
|
||||
episode_offset INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO shifted_seasons (
|
||||
id,
|
||||
show_id,
|
||||
original_season,
|
||||
first_episode,
|
||||
last_episode,
|
||||
season_offset,
|
||||
episode_offset
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
show_id,
|
||||
original_season,
|
||||
first_episode,
|
||||
last_episode,
|
||||
season_offset,
|
||||
episode_offset
|
||||
FROM shifted_seasons_current
|
||||
"""
|
||||
)
|
||||
cursor.execute("DROP TABLE shifted_seasons_current")
|
||||
|
||||
def test_database_context_bootstraps_new_database_with_current_version(self):
|
||||
with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all:
|
||||
context = databaseContext(str(self.database_path))
|
||||
@@ -78,6 +200,127 @@ class DatabaseContextTests(unittest.TestCase):
|
||||
|
||||
mocked_create_all.assert_not_called()
|
||||
|
||||
def test_database_context_migrates_v2_shifted_seasons_schema_to_v3(self):
|
||||
shifted_season_id = self.create_demo_show_with_shift()
|
||||
|
||||
connection = sqlite3.connect(self.database_path)
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=OFF")
|
||||
self.rewrite_shifted_seasons_table_without_pattern_owner(cursor)
|
||||
self.rewrite_shows_table_without_show_fields(cursor)
|
||||
cursor.execute(
|
||||
"UPDATE properties SET value = '2' WHERE key = ?",
|
||||
(DATABASE_VERSION_KEY,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with patch("ffx.database.click.confirm", return_value=True) as mocked_confirm, patch(
|
||||
"ffx.database.click.echo"
|
||||
) as mocked_echo:
|
||||
reopened_context = databaseContext(str(self.database_path))
|
||||
try:
|
||||
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
|
||||
mocked_confirm.assert_called_once()
|
||||
|
||||
backup_path = Path(f"{self.database_path}.v2-to-v{DATABASE_VERSION}.bak")
|
||||
self.assertTrue(backup_path.exists())
|
||||
|
||||
Session = reopened_context["session"]
|
||||
session = Session()
|
||||
try:
|
||||
migrated_shifted_season = (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == shifted_season_id)
|
||||
.first()
|
||||
)
|
||||
self.assertIsNotNone(migrated_shifted_season)
|
||||
self.assertEqual(1, migrated_shifted_season.getShowId())
|
||||
self.assertIsNone(migrated_shifted_season.getPatternId())
|
||||
self.assertEqual(1, migrated_shifted_season.getOriginalSeason())
|
||||
self.assertEqual(1, migrated_shifted_season.getFirstEpisode())
|
||||
self.assertEqual(10, migrated_shifted_season.getLastEpisode())
|
||||
migrated_show = session.query(Show).filter(Show.id == 1).first()
|
||||
self.assertIsNotNone(migrated_show)
|
||||
self.assertEqual(0, int(migrated_show.quality or 0))
|
||||
self.assertEqual('', str(migrated_show.notes or ''))
|
||||
finally:
|
||||
session.close()
|
||||
finally:
|
||||
reopened_context["engine"].dispose()
|
||||
|
||||
echoedLines = [call.args[0] for call in mocked_echo.call_args_list]
|
||||
self.assertIn("Database migration required.", echoedLines)
|
||||
self.assertIn("Current version: 2", echoedLines)
|
||||
self.assertIn(f"Target version: {DATABASE_VERSION}", echoedLines)
|
||||
self.assertIn(
|
||||
" 2 -> 3: ffx.model.migration.step_2_3 [present]",
|
||||
echoedLines,
|
||||
)
|
||||
|
||||
def test_database_context_aborts_migration_when_confirmation_is_declined(self):
|
||||
context = databaseContext(str(self.database_path))
|
||||
try:
|
||||
Session = context["session"]
|
||||
session = Session()
|
||||
try:
|
||||
version_row = (
|
||||
session.query(Property)
|
||||
.filter(Property.key == DATABASE_VERSION_KEY)
|
||||
.first()
|
||||
)
|
||||
version_row.value = "2"
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
finally:
|
||||
context["engine"].dispose()
|
||||
|
||||
with patch("ffx.database.click.confirm", return_value=False), patch(
|
||||
"ffx.database.click.echo"
|
||||
):
|
||||
with self.assertRaises(click.ClickException) as raisedContext:
|
||||
databaseContext(str(self.database_path))
|
||||
|
||||
self.assertEqual("Database migration aborted by user.", str(raisedContext.exception))
|
||||
self.assertFalse(Path(f"{self.database_path}.v2-to-v{DATABASE_VERSION}.bak").exists())
|
||||
|
||||
def test_database_context_repairs_current_show_schema_without_version_bump(self):
|
||||
self.create_demo_show_with_shift()
|
||||
|
||||
connection = sqlite3.connect(self.database_path)
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=OFF")
|
||||
self.rewrite_shows_table_without_show_fields(cursor)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with patch("ffx.database.click.confirm") as mocked_confirm, patch(
|
||||
"ffx.database.click.echo"
|
||||
) as mocked_echo:
|
||||
reopened_context = databaseContext(str(self.database_path))
|
||||
try:
|
||||
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
|
||||
|
||||
Session = reopened_context["session"]
|
||||
session = Session()
|
||||
try:
|
||||
repaired_show = session.query(Show).filter(Show.id == 1).first()
|
||||
self.assertIsNotNone(repaired_show)
|
||||
self.assertEqual(0, int(repaired_show.quality or 0))
|
||||
self.assertEqual('', str(repaired_show.notes or ''))
|
||||
finally:
|
||||
session.close()
|
||||
finally:
|
||||
reopened_context["engine"].dispose()
|
||||
|
||||
mocked_confirm.assert_not_called()
|
||||
mocked_echo.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
196
tests/unit/test_ffmpeg_diagnostics.py
Normal file
196
tests/unit/test_ffmpeg_diagnostics.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx.diagnostics import ( # noqa: E402
|
||||
FfmpegCommandRunner,
|
||||
FfmpegDiagnosticMonitor,
|
||||
FfmpegSkipFileWarning,
|
||||
getUnremediedIssues,
|
||||
iterUnremediedIssueSummaryLines,
|
||||
)
|
||||
|
||||
|
||||
class RecordingLogger:
|
||||
def __init__(self):
|
||||
self.messages: list[str] = []
|
||||
|
||||
def warning(self, message, *args, **kwargs):
|
||||
if args:
|
||||
message = message % args
|
||||
self.messages.append(str(message))
|
||||
|
||||
|
||||
class FfmpegDiagnosticsTests(unittest.TestCase):
|
||||
def test_command_runner_retries_with_genpts_after_timestamp_warning(self):
|
||||
logger = RecordingLogger()
|
||||
context = {
|
||||
"logger": logger,
|
||||
"current_source_path": "tests/assets/avi/conan_S01E754_amalgam.avi",
|
||||
}
|
||||
runner = FfmpegCommandRunner(context)
|
||||
commands = []
|
||||
|
||||
def fake_execute(commandSequence, **kwargs):
|
||||
commands.append(list(commandSequence))
|
||||
stderrLineHandler = kwargs["stderrLineHandler"]
|
||||
if len(commands) == 1:
|
||||
self.assertTrue(
|
||||
stderrLineHandler(
|
||||
"[matroska @ 0x1] Timestamps are unset in a packet for stream 0. "
|
||||
+ "This is deprecated and will stop working in the future."
|
||||
)
|
||||
)
|
||||
return "", "timestamp warning\n", -15
|
||||
|
||||
return "done", "", 0
|
||||
|
||||
with patch("ffx.diagnostics.monitor.executeProcess", side_effect=fake_execute):
|
||||
out, err, rc = runner.execute(["ffmpeg", "-y", "-i", "input.avi", "output.mkv"])
|
||||
|
||||
self.assertEqual("done", out)
|
||||
self.assertEqual("", err)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertEqual(
|
||||
[
|
||||
["ffmpeg", "-y", "-i", "input.avi", "output.mkv"],
|
||||
["ffmpeg", "-fflags", "+genpts", "-y", "-i", "input.avi", "output.mkv"],
|
||||
],
|
||||
commands,
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"ffmpeg reported unset packet timestamps for tests/assets/avi/conan_S01E754_amalgam.avi. "
|
||||
+ "Stopping early and retrying with -fflags +genpts."
|
||||
],
|
||||
logger.messages,
|
||||
)
|
||||
self.assertEqual({}, getUnremediedIssues(context))
|
||||
|
||||
def test_command_runner_skips_file_when_timestamp_warning_persists_after_genpts(self):
|
||||
logger = RecordingLogger()
|
||||
context = {
|
||||
"logger": logger,
|
||||
"current_source_path": "tests/assets/avi/conan_S01E754_amalgam.avi",
|
||||
}
|
||||
runner = FfmpegCommandRunner(context)
|
||||
|
||||
def fake_execute(commandSequence, **kwargs):
|
||||
stderrLineHandler = kwargs["stderrLineHandler"]
|
||||
self.assertTrue(
|
||||
stderrLineHandler(
|
||||
"[matroska @ 0x1] Timestamps are unset in a packet for stream 0. "
|
||||
+ "This is deprecated and will stop working in the future."
|
||||
)
|
||||
)
|
||||
return "", "timestamp warning\n", -15
|
||||
|
||||
with patch("ffx.diagnostics.monitor.executeProcess", side_effect=fake_execute):
|
||||
with self.assertRaises(FfmpegSkipFileWarning):
|
||||
runner.execute(
|
||||
["ffmpeg", "-fflags", "+genpts", "-y", "-i", "input.avi", "output.mkv"]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"Skipping file tests/assets/avi/conan_S01E754_amalgam.avi: ffmpeg still reported "
|
||||
+ "unset packet timestamps after retry with -fflags +genpts."
|
||||
],
|
||||
logger.messages,
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"tests/assets/avi/conan_S01E754_amalgam.avi": ["retry-with-generated-pts"]
|
||||
},
|
||||
getUnremediedIssues(context),
|
||||
)
|
||||
|
||||
def test_monitor_tracks_non_harmless_corrupt_mpeg_audio_remedy_in_summary(self):
|
||||
logger = RecordingLogger()
|
||||
context = {
|
||||
"logger": logger,
|
||||
"current_source_path": "tests/assets/avi/conan_S01E763_amalgam.avi",
|
||||
}
|
||||
monitor = FfmpegDiagnosticMonitor(
|
||||
context,
|
||||
["ffmpeg", "-y", "-i", "input.avi", "output.mkv"],
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
monitor.handle_stderr_line("[mp3float @ 0x1] invalid new backstep -1")
|
||||
)
|
||||
self.assertFalse(monitor.handle_stderr_line("[mp3float @ 0x1] invalid block type"))
|
||||
self.assertFalse(
|
||||
monitor.handle_stderr_line(
|
||||
"[aist#0:1/mp3 @ 0x2] [dec:mp3float @ 0x3] Error submitting packet to decoder: "
|
||||
+ "Invalid data found when processing input"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"ffmpeg reported damaged MPEG audio frames while converting "
|
||||
+ "tests/assets/avi/conan_S01E763_amalgam.avi. FFX will continue, but the "
|
||||
+ "output audio may contain gaps or glitches."
|
||||
],
|
||||
logger.messages,
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"tests/assets/avi/conan_S01E763_amalgam.avi": ["warn-corrupt-mpeg-audio"]
|
||||
},
|
||||
getUnremediedIssues(context),
|
||||
)
|
||||
self.assertEqual(
|
||||
["conan_S01E763_amalgam.avi: warn-corrupt-mpeg-audio"],
|
||||
iterUnremediedIssueSummaryLines(context),
|
||||
)
|
||||
|
||||
def test_monitor_tracks_unhandled_diagnostic_for_summary(self):
|
||||
context = {
|
||||
"logger": RecordingLogger(),
|
||||
"current_source_path": "tests/assets/avi/example.avi",
|
||||
}
|
||||
monitor = FfmpegDiagnosticMonitor(
|
||||
context,
|
||||
["ffmpeg", "-y", "-i", "input.avi", "output.mkv"],
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
monitor.handle_stderr_line(
|
||||
"[avi @ 0x1] Strange warning with no automatic remedy is present"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"tests/assets/avi/example.avi": ["unhandled-warning"]
|
||||
},
|
||||
getUnremediedIssues(context),
|
||||
)
|
||||
self.assertEqual(
|
||||
["example.avi: unhandled-warning"],
|
||||
iterUnremediedIssueSummaryLines(context),
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"ffmpeg reported a diagnostic with no automatic remedy while converting "
|
||||
+ "tests/assets/avi/example.avi. FFX will continue, but review the output "
|
||||
+ "file. First unhandled line: [avi @ 0x1] Strange warning with no automatic remedy is present"
|
||||
],
|
||||
context["logger"].messages,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
@@ -13,8 +15,10 @@ if str(SRC_ROOT) not in sys.path:
|
||||
|
||||
|
||||
from ffx.ffx_controller import FfxController # noqa: E402
|
||||
from ffx.audio_layout import AudioLayout # noqa: E402
|
||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
||||
from ffx.track_codec import TrackCodec # noqa: E402
|
||||
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||
from ffx.track_type import TrackType # noqa: E402
|
||||
@@ -30,6 +34,9 @@ class StaticConfig:
|
||||
|
||||
|
||||
class FfxControllerTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
FfxController.isFfmpegEncoderAvailable.cache_clear()
|
||||
|
||||
def make_context(self, video_encoder: VideoEncoder) -> dict:
|
||||
return {
|
||||
"logger": get_ffx_logger(),
|
||||
@@ -37,6 +44,8 @@ class FfxControllerTests(unittest.TestCase):
|
||||
"video_encoder": video_encoder,
|
||||
"dry_run": False,
|
||||
"perform_cut": False,
|
||||
"copy_video": False,
|
||||
"copy_audio": False,
|
||||
"bitrates": {
|
||||
"stereo": "112k",
|
||||
"ac3": "256k",
|
||||
@@ -69,6 +78,56 @@ class FfxControllerTests(unittest.TestCase):
|
||||
)
|
||||
return descriptor, source_descriptor
|
||||
|
||||
def make_media_descriptors_with_audio(
|
||||
self,
|
||||
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO,
|
||||
) -> tuple[MediaDescriptor, MediaDescriptor]:
|
||||
descriptor = MediaDescriptor(
|
||||
track_descriptors=[
|
||||
TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
codec_name=TrackCodec.H264,
|
||||
),
|
||||
TrackDescriptor(
|
||||
index=1,
|
||||
source_index=1,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
codec_name=TrackCodec.AAC,
|
||||
audio_layout=audio_layout,
|
||||
),
|
||||
]
|
||||
)
|
||||
source_descriptor = MediaDescriptor(
|
||||
track_descriptors=[
|
||||
TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
codec_name=TrackCodec.H264,
|
||||
),
|
||||
TrackDescriptor(
|
||||
index=1,
|
||||
source_index=1,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
codec_name=TrackCodec.AAC,
|
||||
audio_layout=audio_layout,
|
||||
),
|
||||
]
|
||||
)
|
||||
return descriptor, source_descriptor
|
||||
|
||||
def assert_token_pair(self, command: list[str], first: str, second: str):
|
||||
self.assertTrue(
|
||||
any(command[index:index + 2] == [first, second] for index in range(len(command) - 1)),
|
||||
command,
|
||||
)
|
||||
|
||||
def test_vp9_run_job_emits_file_level_encoding_quality_metadata(self):
|
||||
context = self.make_context(VideoEncoder.VP9)
|
||||
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||
@@ -134,6 +193,191 @@ class FfxControllerTests(unittest.TestCase):
|
||||
self.assertIn("ENCODING_QUALITY=29", commands[0])
|
||||
self.assertIn("ENCODING_PRESET=7", commands[0])
|
||||
|
||||
def test_run_job_uses_show_quality_when_pattern_quality_is_unset(self):
|
||||
context = self.make_context(VideoEncoder.H264)
|
||||
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||
commands = []
|
||||
show_descriptor = ShowDescriptor(id=1, name="Show", year=2024, quality=23)
|
||||
pattern = SimpleNamespace(quality=0)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
controller,
|
||||
"executeCommandSequence",
|
||||
side_effect=lambda command: commands.append(command) or ("", "", 0),
|
||||
),
|
||||
patch.object(context["logger"], "info") as mocked_info,
|
||||
):
|
||||
controller.runJob(
|
||||
"input.mkv",
|
||||
"output.mkv",
|
||||
chainIteration=[],
|
||||
currentPattern=pattern,
|
||||
currentShowDescriptor=show_descriptor,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(commands))
|
||||
self.assertIn("ENCODING_QUALITY=23", commands[0])
|
||||
mocked_info.assert_any_call("Setting quality 23 from show")
|
||||
|
||||
def test_run_job_prefers_pattern_quality_over_show_quality(self):
|
||||
context = self.make_context(VideoEncoder.H264)
|
||||
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||
commands = []
|
||||
show_descriptor = ShowDescriptor(id=1, name="Show", year=2024, quality=23)
|
||||
pattern = SimpleNamespace(quality=19)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
controller,
|
||||
"executeCommandSequence",
|
||||
side_effect=lambda command: commands.append(command) or ("", "", 0),
|
||||
),
|
||||
patch.object(context["logger"], "info") as mocked_info,
|
||||
):
|
||||
controller.runJob(
|
||||
"input.mkv",
|
||||
"output.mkv",
|
||||
chainIteration=[],
|
||||
currentPattern=pattern,
|
||||
currentShowDescriptor=show_descriptor,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(commands))
|
||||
self.assertIn("ENCODING_QUALITY=19", commands[0])
|
||||
mocked_info.assert_any_call("Setting quality 19 from pattern")
|
||||
|
||||
def test_copy_video_uses_single_copy_command_without_video_encoding_options(self):
|
||||
context = self.make_context(VideoEncoder.VP9)
|
||||
context["copy_video"] = True
|
||||
target_descriptor, source_descriptor = self.make_media_descriptors_with_audio()
|
||||
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||
commands = []
|
||||
|
||||
with patch.object(
|
||||
controller,
|
||||
"executeCommandSequence",
|
||||
side_effect=lambda command: commands.append(command) or ("", "", 0),
|
||||
):
|
||||
controller.runJob(
|
||||
"input.mkv",
|
||||
"output.mkv",
|
||||
chainIteration=[
|
||||
{
|
||||
"identifier": "quality",
|
||||
"parameters": {"quality": 27},
|
||||
},
|
||||
{
|
||||
"identifier": "nlmeans",
|
||||
"parameters": {},
|
||||
"tokens": ["nlmeans=s=2.0"],
|
||||
},
|
||||
],
|
||||
cropArguments={
|
||||
"output_width": 1280,
|
||||
"output_height": 720,
|
||||
"x_offset": 0,
|
||||
"y_offset": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(commands))
|
||||
self.assert_token_pair(commands[0], "-c:v", "copy")
|
||||
self.assertIn("libopus", commands[0])
|
||||
self.assertNotIn("libvpx-vp9", commands[0])
|
||||
self.assertNotIn("-pass", commands[0])
|
||||
self.assertNotIn("-vf", commands[0])
|
||||
self.assertFalse(any(token.startswith("ENCODING_QUALITY=") for token in commands[0]))
|
||||
|
||||
def test_copy_audio_uses_audio_copy_without_audio_encoding_options(self):
|
||||
context = self.make_context(VideoEncoder.H264)
|
||||
context["copy_audio"] = True
|
||||
target_descriptor, source_descriptor = self.make_media_descriptors_with_audio(
|
||||
AudioLayout.LAYOUT_5_1
|
||||
)
|
||||
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||
commands = []
|
||||
|
||||
with patch.object(
|
||||
controller,
|
||||
"executeCommandSequence",
|
||||
side_effect=lambda command: commands.append(command) or ("", "", 0),
|
||||
):
|
||||
controller.runJob(
|
||||
"input.mkv",
|
||||
"output.mkv",
|
||||
chainIteration=[
|
||||
{
|
||||
"identifier": "quality",
|
||||
"parameters": {"quality": 21},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(commands))
|
||||
self.assert_token_pair(commands[0], "-c:a", "copy")
|
||||
self.assertIn("libx264", commands[0])
|
||||
self.assertNotIn("libopus", commands[0])
|
||||
self.assertFalse(any(token.startswith("-b:a") for token in commands[0]))
|
||||
self.assertFalse(any(token.startswith("-filter:a") for token in commands[0]))
|
||||
def test_generate_h264_tokens_prefers_libx264_when_available(self):
|
||||
context = self.make_context(VideoEncoder.H264)
|
||||
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||
|
||||
with patch.object(
|
||||
FfxController,
|
||||
"getSupportedSoftwareH264Encoder",
|
||||
return_value="libx264",
|
||||
):
|
||||
tokens = controller.generateH264Tokens(23)
|
||||
|
||||
self.assertEqual(
|
||||
["-c:v:0", "libx264", "-preset", "slow", "-crf", "23"],
|
||||
tokens,
|
||||
)
|
||||
|
||||
def test_generate_h264_tokens_falls_back_to_libopenh264_and_logs_warning(self):
|
||||
context = self.make_context(VideoEncoder.H264)
|
||||
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
FfxController,
|
||||
"getSupportedSoftwareH264Encoder",
|
||||
return_value="libopenh264",
|
||||
),
|
||||
patch.object(context["logger"], "warning") as mocked_warning,
|
||||
):
|
||||
tokens = controller.generateH264Tokens(23)
|
||||
|
||||
self.assertEqual(
|
||||
["-c:v:0", "libopenh264", "-pix_fmt", "yuv420p"],
|
||||
tokens,
|
||||
)
|
||||
mocked_warning.assert_called_once_with(
|
||||
"libx264 encoder unavailable; falling back to libopenh264 for H.264 encoding."
|
||||
)
|
||||
|
||||
def test_generate_h264_tokens_raises_when_no_supported_software_encoder_exists(self):
|
||||
context = self.make_context(VideoEncoder.H264)
|
||||
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||
|
||||
with patch.object(
|
||||
FfxController,
|
||||
"getSupportedSoftwareH264Encoder",
|
||||
return_value=None,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
click.ClickException,
|
||||
"no supported software H.264 encoder is available",
|
||||
):
|
||||
controller.generateH264Tokens(23)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
82
tests/unit/test_file_properties_asset_probe.py
Normal file
82
tests/unit/test_file_properties_asset_probe.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx.file_properties import FileProperties # noqa: E402
|
||||
from ffx.i18n import set_current_language # noqa: E402
|
||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||
from ffx.track_codec import TrackCodec # noqa: E402
|
||||
from ffx.track_type import TrackType # noqa: E402
|
||||
from tests.support.ffx_bundle import SourceTrackSpec, create_source_fixture # noqa: E402
|
||||
|
||||
|
||||
class StaticConfig:
|
||||
def __init__(self, data: dict):
|
||||
self._data = data
|
||||
|
||||
def getData(self):
|
||||
return self._data
|
||||
|
||||
|
||||
class FilePropertiesAssetProbeTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
set_current_language("de")
|
||||
|
||||
def test_boruto_webm_probe_recognizes_webm_stream_codecs(self):
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig({}),
|
||||
"language": "de",
|
||||
"use_pattern": False,
|
||||
}
|
||||
set_current_language("de")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
media_path = create_source_fixture(
|
||||
Path(tmpdir),
|
||||
"fixture.webm",
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="subtitle-2",
|
||||
language="eng",
|
||||
subtitle_lines=("Lorem ipsum dolor sit amet.",),
|
||||
),
|
||||
],
|
||||
duration_seconds=3,
|
||||
video_encoder="libvpx-vp9",
|
||||
video_encoder_options=("-b:v", "0", "-crf", "45"),
|
||||
audio_encoder="libopus",
|
||||
audio_encoder_options=("-b:a", "48k"),
|
||||
subtitle_encoder="webvtt",
|
||||
)
|
||||
|
||||
file_properties = FileProperties(context, str(media_path))
|
||||
tracks = file_properties.getMediaDescriptor().getTrackDescriptors()
|
||||
|
||||
subtitle_codecs = [
|
||||
track.getCodec()
|
||||
for track in tracks
|
||||
if track.getType() == TrackType.SUBTITLE
|
||||
]
|
||||
|
||||
self.assertIn(TrackCodec.VP9, [track.getCodec() for track in tracks])
|
||||
self.assertIn(TrackCodec.OPUS, [track.getCodec() for track in tracks])
|
||||
self.assertTrue(subtitle_codecs)
|
||||
self.assertTrue(all(codec == TrackCodec.WEBVTT for codec in subtitle_codecs))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -107,6 +107,22 @@ class FilePropertiesProbeTests(unittest.TestCase):
|
||||
+ ["/tmp/example_s01e01.mkv"]
|
||||
)
|
||||
|
||||
def test_use_pattern_false_skips_pattern_controller_construction(self):
|
||||
file_properties_module = self.import_module()
|
||||
|
||||
with patch.object(
|
||||
file_properties_module,
|
||||
"PatternController",
|
||||
side_effect=AssertionError("PatternController should not be created"),
|
||||
):
|
||||
file_properties = file_properties_module.FileProperties(
|
||||
self.make_context(),
|
||||
"/tmp/example_s01e01.mkv",
|
||||
)
|
||||
|
||||
self.assertEqual(-1, file_properties.getShowId())
|
||||
self.assertIsNone(file_properties.getPattern())
|
||||
|
||||
def test_cropdetect_uses_configured_window_and_caches_results(self):
|
||||
file_properties_module = self.import_module()
|
||||
file_properties_module.FileProperties._clear_cropdetect_cache()
|
||||
|
||||
89
tests/unit/test_i18n.py
Normal file
89
tests/unit/test_i18n.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx.i18n import ( # noqa: E402
|
||||
detect_system_language,
|
||||
read_configured_language,
|
||||
resolve_application_language,
|
||||
set_current_language,
|
||||
t,
|
||||
)
|
||||
from ffx.iso_language import IsoLanguage # noqa: E402
|
||||
|
||||
|
||||
class I18nTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
set_current_language("de")
|
||||
|
||||
def test_cli_language_takes_precedence_over_config_and_system(self):
|
||||
self.assertEqual(
|
||||
"es",
|
||||
resolve_application_language(
|
||||
cli_language="es",
|
||||
config_language="fr",
|
||||
system_language="ja",
|
||||
),
|
||||
)
|
||||
|
||||
def test_config_language_takes_precedence_over_system(self):
|
||||
self.assertEqual(
|
||||
"fr",
|
||||
resolve_application_language(
|
||||
config_language="fr",
|
||||
system_language="ja",
|
||||
),
|
||||
)
|
||||
|
||||
def test_system_language_is_used_when_no_cli_or_config_is_present(self):
|
||||
self.assertEqual("ja", resolve_application_language(system_language="ja"))
|
||||
|
||||
def test_german_is_default_when_no_supported_language_is_available(self):
|
||||
self.assertEqual(
|
||||
"de",
|
||||
resolve_application_language(
|
||||
env={
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"LC_MESSAGES": "C.UTF-8",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def test_system_language_detection_normalizes_norwegian_bokmal(self):
|
||||
self.assertEqual(
|
||||
"nb",
|
||||
detect_system_language({"LANG": "nb_NO.UTF-8"}),
|
||||
)
|
||||
|
||||
def test_read_configured_language_normalizes_language_code(self):
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
config_path = Path(tempdir) / "ffx.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"language": "pt_BR.UTF-8"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual("pt", read_configured_language(config_path))
|
||||
|
||||
def test_phrase_translation_uses_catalog_for_selected_language(self):
|
||||
set_current_language("fr")
|
||||
self.assertEqual("Ajouter", t("Add"))
|
||||
|
||||
def test_iso_language_labels_use_catalog_for_selected_language(self):
|
||||
set_current_language("de")
|
||||
self.assertEqual("Deutsch", IsoLanguage.GERMAN.label())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -16,8 +16,10 @@ if str(SRC_ROOT) not in sys.path:
|
||||
from ffx.logging_utils import ( # noqa: E402
|
||||
CONSOLE_HANDLER_NAME,
|
||||
FILE_HANDLER_NAME,
|
||||
MUTED_CONSOLE_LEVEL,
|
||||
configure_ffx_logger,
|
||||
get_ffx_logger,
|
||||
set_ffx_console_logging_enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,6 +83,33 @@ class LoggingUtilsTests(unittest.TestCase):
|
||||
|
||||
self.cleanup_logger(logger_name)
|
||||
|
||||
def test_set_ffx_console_logging_enabled_mutes_and_restores_console_handler(self):
|
||||
logger_name = "ffx-test-console-mute"
|
||||
self.cleanup_logger(logger_name)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
log_path = Path(tempdir) / "ffx.log"
|
||||
|
||||
logger = configure_ffx_logger(
|
||||
str(log_path),
|
||||
logging.DEBUG,
|
||||
logging.INFO,
|
||||
name=logger_name,
|
||||
)
|
||||
console_handler = next(
|
||||
handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME
|
||||
)
|
||||
|
||||
self.assertEqual(logging.INFO, console_handler.level)
|
||||
|
||||
set_ffx_console_logging_enabled(logger, enabled=False)
|
||||
self.assertEqual(MUTED_CONSOLE_LEVEL, console_handler.level)
|
||||
|
||||
set_ffx_console_logging_enabled(logger, enabled=True)
|
||||
self.assertEqual(logging.INFO, console_handler.level)
|
||||
|
||||
self.cleanup_logger(logger_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -13,8 +13,10 @@ if str(SRC_ROOT) not in sys.path:
|
||||
|
||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
|
||||
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
||||
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||
from ffx.track_type import TrackType # noqa: E402
|
||||
from ffx.i18n import set_current_language # noqa: E402
|
||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||
|
||||
|
||||
@@ -27,6 +29,9 @@ class StaticConfig:
|
||||
|
||||
|
||||
class MediaDescriptorChangeSetTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
set_current_language("de")
|
||||
|
||||
def test_non_primary_source_language_code_is_normalized_in_changed_track_metadata(self):
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
@@ -171,6 +176,179 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
|
||||
self.assertIn("language=deu", metadata_tokens)
|
||||
self.assertNotIn("language=ger", metadata_tokens)
|
||||
|
||||
def test_subtitle_without_title_gets_language_name_when_normalization_enabled(self):
|
||||
set_current_language("de")
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig({}),
|
||||
}
|
||||
|
||||
source_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
|
||||
change_set = MediaDescriptorChangeSet(
|
||||
context,
|
||||
MediaDescriptor(track_descriptors=[target_track]),
|
||||
MediaDescriptor(track_descriptors=[source_track]),
|
||||
)
|
||||
|
||||
metadata_tokens = change_set.generateMetadataTokens()
|
||||
change_set_obj = change_set.getChangeSetObj()
|
||||
|
||||
self.assertIn("-metadata:s:s:0", metadata_tokens)
|
||||
self.assertIn("language=deu", metadata_tokens)
|
||||
self.assertIn("title=Deutsch", metadata_tokens)
|
||||
self.assertEqual(
|
||||
"Deutsch",
|
||||
change_set_obj["tracks"]["changed"][0]["tags"]["added"]["title"],
|
||||
)
|
||||
|
||||
def test_subtitle_without_title_uses_current_language_for_generated_title(self):
|
||||
set_current_language("en")
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig({}),
|
||||
}
|
||||
|
||||
source_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
|
||||
change_set = MediaDescriptorChangeSet(
|
||||
context,
|
||||
MediaDescriptor(track_descriptors=[target_track]),
|
||||
MediaDescriptor(track_descriptors=[source_track]),
|
||||
)
|
||||
|
||||
metadata_tokens = change_set.generateMetadataTokens()
|
||||
|
||||
self.assertIn("title=German", metadata_tokens)
|
||||
self.assertNotIn("title=Deutsch", metadata_tokens)
|
||||
|
||||
def test_audio_track_without_title_gets_language_name_when_normalization_enabled(self):
|
||||
set_current_language("de")
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig({}),
|
||||
}
|
||||
|
||||
source_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
|
||||
change_set = MediaDescriptorChangeSet(
|
||||
context,
|
||||
MediaDescriptor(track_descriptors=[target_track]),
|
||||
MediaDescriptor(track_descriptors=[source_track]),
|
||||
)
|
||||
|
||||
metadata_tokens = change_set.generateMetadataTokens()
|
||||
|
||||
self.assertIn("-metadata:s:a:0", metadata_tokens)
|
||||
self.assertIn("language=deu", metadata_tokens)
|
||||
self.assertIn("title=Deutsch", metadata_tokens)
|
||||
|
||||
def test_video_track_without_title_gets_language_name_when_normalization_enabled(self):
|
||||
set_current_language("de")
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig({}),
|
||||
}
|
||||
|
||||
source_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
|
||||
change_set = MediaDescriptorChangeSet(
|
||||
context,
|
||||
MediaDescriptor(track_descriptors=[target_track]),
|
||||
MediaDescriptor(track_descriptors=[source_track]),
|
||||
)
|
||||
|
||||
metadata_tokens = change_set.generateMetadataTokens()
|
||||
|
||||
self.assertIn("language=deu", metadata_tokens)
|
||||
self.assertIn("title=Deutsch", metadata_tokens)
|
||||
|
||||
def test_changed_track_language_does_not_autofill_title_when_title_already_exists(self):
|
||||
set_current_language("de")
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig({}),
|
||||
}
|
||||
|
||||
source_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
tags={"language": "ger", "title": "Deutsch [FN]"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
tags={"language": "jpn", "title": "Deutsch [FN]"},
|
||||
)
|
||||
|
||||
change_set = MediaDescriptorChangeSet(
|
||||
context,
|
||||
MediaDescriptor(track_descriptors=[target_track]),
|
||||
MediaDescriptor(track_descriptors=[source_track]),
|
||||
)
|
||||
|
||||
metadata_tokens = change_set.generateMetadataTokens()
|
||||
|
||||
self.assertIn("language=jpn", metadata_tokens)
|
||||
self.assertNotIn("title=Japanisch", metadata_tokens)
|
||||
self.assertNotIn("title=Deutsch", metadata_tokens)
|
||||
|
||||
def test_target_only_tracks_still_emit_remove_tokens_for_configured_stream_keys(self):
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
@@ -212,6 +390,120 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
|
||||
self.assertIn("BPS=", metadata_tokens)
|
||||
self.assertIn("KEEP_ME=keep-me", metadata_tokens)
|
||||
|
||||
def test_cleanup_can_be_disabled_per_context(self):
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig(
|
||||
{
|
||||
"metadata": {
|
||||
"remove": ["creation_time"],
|
||||
"streams": {
|
||||
"remove": ["BPS"],
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
"apply_metadata_cleanup": False,
|
||||
}
|
||||
|
||||
source_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
tags={"BPS": "keep-me"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
tags={"BPS": "keep-me"},
|
||||
)
|
||||
|
||||
change_set = MediaDescriptorChangeSet(
|
||||
context,
|
||||
MediaDescriptor(
|
||||
tags={"creation_time": "keep-me"},
|
||||
track_descriptors=[target_track],
|
||||
),
|
||||
MediaDescriptor(
|
||||
tags={"creation_time": "keep-me"},
|
||||
track_descriptors=[source_track],
|
||||
),
|
||||
)
|
||||
|
||||
metadata_tokens = change_set.generateMetadataTokens()
|
||||
self.assertNotIn("creation_time=", metadata_tokens)
|
||||
self.assertNotIn("BPS=", metadata_tokens)
|
||||
|
||||
def test_attachment_tracks_are_ignored_for_pattern_comparison(self):
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig({}),
|
||||
}
|
||||
|
||||
source_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.ATTACHMENT,
|
||||
attachment_format=AttachmentFormat.TTF,
|
||||
tags={"filename": "current.ttf", "mimetype": "font/ttf"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.ATTACHMENT,
|
||||
attachment_format=AttachmentFormat.TTF,
|
||||
tags={"filename": "stored.ttf", "mimetype": "font/ttf"},
|
||||
)
|
||||
stale_target_track = TrackDescriptor(
|
||||
index=1,
|
||||
source_index=1,
|
||||
sub_index=1,
|
||||
track_type=TrackType.ATTACHMENT,
|
||||
attachment_format=AttachmentFormat.TTF,
|
||||
tags={"filename": "missing.ttf", "mimetype": "font/ttf"},
|
||||
)
|
||||
|
||||
change_set = MediaDescriptorChangeSet(
|
||||
context,
|
||||
MediaDescriptor(track_descriptors=[target_track, stale_target_track]),
|
||||
MediaDescriptor(track_descriptors=[source_track]),
|
||||
)
|
||||
|
||||
self.assertEqual({}, change_set.getChangeSetObj())
|
||||
self.assertEqual([], change_set.generateMetadataTokens())
|
||||
self.assertEqual([], change_set.generateDispositionTokens())
|
||||
|
||||
def test_normalization_can_be_disabled_per_context(self):
|
||||
context = {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig({}),
|
||||
"apply_metadata_normalization": False,
|
||||
}
|
||||
|
||||
target_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
tags={"language": "ger", "title": "German Main"},
|
||||
)
|
||||
|
||||
change_set = MediaDescriptorChangeSet(
|
||||
context,
|
||||
MediaDescriptor(track_descriptors=[target_track]),
|
||||
)
|
||||
|
||||
metadata_tokens = change_set.generateMetadataTokens()
|
||||
|
||||
self.assertIn("-metadata:s:a:0", metadata_tokens)
|
||||
self.assertIn("language=ger", metadata_tokens)
|
||||
self.assertNotIn("language=deu", metadata_tokens)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
79
tests/unit/test_media_descriptor_import_subtitles.py
Normal file
79
tests/unit/test_media_descriptor_import_subtitles.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||
from ffx.track_disposition import TrackDisposition # noqa: E402
|
||||
from ffx.track_type import TrackType # noqa: E402
|
||||
|
||||
|
||||
class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
|
||||
def make_descriptor(self) -> MediaDescriptor:
|
||||
return MediaDescriptor(
|
||||
context={"logger": get_ffx_logger()},
|
||||
track_descriptors=[
|
||||
TrackDescriptor(
|
||||
index=3,
|
||||
source_index=3,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
tags={"language": "eng", "title": "DB Subtitle"},
|
||||
disposition_set={TrackDisposition.DEFAULT},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_import_subtitles_preserves_target_dispositions_when_requested(self):
|
||||
descriptor = self.make_descriptor()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sidecar_path = Path(tmpdir) / "dball_S01E01_3_deu_FOR.vtt"
|
||||
sidecar_path.write_text("WEBVTT\n\n", encoding="utf-8")
|
||||
|
||||
descriptor.importSubtitles(
|
||||
tmpdir,
|
||||
"dball",
|
||||
season=1,
|
||||
episode=1,
|
||||
preserve_dispositions=True,
|
||||
)
|
||||
|
||||
track = descriptor.getSubtitleTracks()[0]
|
||||
self.assertEqual(str(sidecar_path), track.getExternalSourceFilePath())
|
||||
self.assertEqual("deu", track.getTags()["language"])
|
||||
self.assertEqual({TrackDisposition.DEFAULT}, track.getDispositionSet())
|
||||
|
||||
def test_import_subtitles_uses_sidecar_dispositions_by_default(self):
|
||||
descriptor = self.make_descriptor()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sidecar_path = Path(tmpdir) / "dball_S01E01_3_deu_FOR.vtt"
|
||||
sidecar_path.write_text("WEBVTT\n\n", encoding="utf-8")
|
||||
|
||||
descriptor.importSubtitles(
|
||||
tmpdir,
|
||||
"dball",
|
||||
season=1,
|
||||
episode=1,
|
||||
)
|
||||
|
||||
track = descriptor.getSubtitleTracks()[0]
|
||||
self.assertEqual(str(sidecar_path), track.getExternalSourceFilePath())
|
||||
self.assertEqual("deu", track.getTags()["language"])
|
||||
self.assertEqual({TrackDisposition.FORCED}, track.getDispositionSet())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
240
tests/unit/test_metadata_editor.py
Normal file
240
tests/unit/test_metadata_editor.py
Normal file
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||
from ffx.helper import LogLevel # noqa: E402
|
||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||
from ffx.metadata_editor import ( # noqa: E402
|
||||
apply_metadata_edits,
|
||||
build_metadata_edit_command,
|
||||
build_metadata_edit_context,
|
||||
create_temporary_output_path,
|
||||
)
|
||||
from ffx.track_codec import TrackCodec # noqa: E402
|
||||
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||
from ffx.track_type import TrackType # noqa: E402
|
||||
from ffx.video_encoder import VideoEncoder # noqa: E402
|
||||
|
||||
|
||||
class StaticConfig:
|
||||
def getData(self):
|
||||
return {}
|
||||
|
||||
|
||||
class NotificationCollector:
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[str] = []
|
||||
self.levels: list[LogLevel | None] = []
|
||||
|
||||
def __call__(self, message: str, level: LogLevel | None = None) -> None:
|
||||
self.messages.append(message)
|
||||
self.levels.append(level)
|
||||
|
||||
|
||||
def make_context(*, dry_run: bool = False) -> dict:
|
||||
return {
|
||||
"logger": get_ffx_logger(),
|
||||
"config": StaticConfig(),
|
||||
"dry_run": dry_run,
|
||||
"apply_metadata_cleanup": True,
|
||||
"apply_metadata_normalization": True,
|
||||
}
|
||||
|
||||
|
||||
def make_descriptor() -> MediaDescriptor:
|
||||
return MediaDescriptor(
|
||||
track_descriptors=[
|
||||
TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
codec_name=TrackCodec.H264,
|
||||
tags={"title": "Main"},
|
||||
)
|
||||
],
|
||||
tags={"TITLE": "Demo"},
|
||||
)
|
||||
|
||||
|
||||
class MetadataEditorTests(unittest.TestCase):
|
||||
def test_build_metadata_edit_context_forces_copy_without_signature(self):
|
||||
context = build_metadata_edit_context(make_context())
|
||||
|
||||
self.assertEqual(VideoEncoder.COPY, context["video_encoder"])
|
||||
self.assertFalse(context["perform_cut"])
|
||||
self.assertTrue(context["no_signature"])
|
||||
self.assertEqual({}, context["encoding_metadata_tags"])
|
||||
self.assertTrue(context["apply_metadata_cleanup"])
|
||||
self.assertTrue(context["apply_metadata_normalization"])
|
||||
|
||||
def test_create_temporary_output_path_uses_same_directory_and_extension(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
source_path = os.path.join(tmpdir, "episode.mkv")
|
||||
temporary_path = create_temporary_output_path(source_path)
|
||||
|
||||
self.assertEqual(".mkv", Path(temporary_path).suffix)
|
||||
self.assertEqual(Path(source_path).parent, Path(temporary_path).parent)
|
||||
|
||||
def test_build_metadata_edit_command_maps_all_streams_and_uses_single_copy_codec(self):
|
||||
context = build_metadata_edit_context(make_context())
|
||||
baseline_descriptor = make_descriptor()
|
||||
draft_descriptor = baseline_descriptor.clone(context=context)
|
||||
|
||||
command = build_metadata_edit_command(
|
||||
context,
|
||||
"/tmp/example.mkv",
|
||||
"/tmp/.edit.mkv",
|
||||
baseline_descriptor,
|
||||
draft_descriptor,
|
||||
)
|
||||
|
||||
self.assertEqual(1, command.count("-map"))
|
||||
self.assertEqual(1, command.count("-c"))
|
||||
self.assertNotIn("-c:v:0", command)
|
||||
self.assertNotIn("-c:a:0", command)
|
||||
self.assertNotIn("-c:s:0", command)
|
||||
self.assertEqual(
|
||||
["-map", "0", "-c", "copy"],
|
||||
command[command.index("-map"):command.index("-c") + 2],
|
||||
)
|
||||
|
||||
def test_apply_metadata_edits_rewrites_via_temporary_file_then_replaces_source(self):
|
||||
context = make_context()
|
||||
baseline_descriptor = make_descriptor()
|
||||
draft_descriptor = baseline_descriptor.clone(context=context)
|
||||
source_path = "/tmp/example.mkv"
|
||||
expected_command = build_metadata_edit_command(
|
||||
build_metadata_edit_context(context),
|
||||
source_path,
|
||||
"/tmp/.edit.mkv",
|
||||
baseline_descriptor,
|
||||
draft_descriptor,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
|
||||
patch("ffx.metadata_editor.executeProcess", return_value=("", "", 0)) as mocked_execute,
|
||||
patch("ffx.metadata_editor.os.replace") as mocked_replace,
|
||||
):
|
||||
result = apply_metadata_edits(
|
||||
context,
|
||||
source_path,
|
||||
baseline_descriptor,
|
||||
draft_descriptor,
|
||||
)
|
||||
|
||||
mocked_execute.assert_called_once_with(expected_command, context=build_metadata_edit_context(context))
|
||||
mocked_replace.assert_called_once_with("/tmp/.edit.mkv", source_path)
|
||||
self.assertEqual(
|
||||
{
|
||||
"applied": True,
|
||||
"dry_run": False,
|
||||
"target_path": source_path,
|
||||
"command_sequence": expected_command,
|
||||
},
|
||||
{
|
||||
"applied": result["applied"],
|
||||
"dry_run": result["dry_run"],
|
||||
"target_path": result["target_path"],
|
||||
"command_sequence": result["command_sequence"],
|
||||
},
|
||||
)
|
||||
self.assertIn("timings", result)
|
||||
self.assertIn("ffmpeg_seconds", result["timings"])
|
||||
self.assertIn("replace_seconds", result["timings"])
|
||||
self.assertIn("write_seconds", result["timings"])
|
||||
|
||||
def test_apply_metadata_edits_dry_run_skips_replace_and_cleans_temp_path(self):
|
||||
context = make_context(dry_run=True)
|
||||
baseline_descriptor = make_descriptor()
|
||||
draft_descriptor = baseline_descriptor.clone(context=context)
|
||||
notifications = NotificationCollector()
|
||||
expected_command = build_metadata_edit_command(
|
||||
build_metadata_edit_context(context),
|
||||
"/tmp/example.mkv",
|
||||
"/tmp/.edit.mkv",
|
||||
baseline_descriptor,
|
||||
draft_descriptor,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
|
||||
patch("ffx.metadata_editor.executeProcess") as mocked_execute,
|
||||
patch("ffx.metadata_editor.os.replace") as mocked_replace,
|
||||
):
|
||||
result = apply_metadata_edits(
|
||||
context,
|
||||
"/tmp/example.mkv",
|
||||
baseline_descriptor,
|
||||
draft_descriptor,
|
||||
loggingHandler = notifications,
|
||||
)
|
||||
|
||||
mocked_execute.assert_not_called()
|
||||
mocked_replace.assert_not_called()
|
||||
self.assertEqual(["ffmpeg dry-run prepared."], notifications.messages)
|
||||
self.assertEqual([None], notifications.levels)
|
||||
self.assertEqual(
|
||||
{
|
||||
"applied": False,
|
||||
"dry_run": True,
|
||||
"target_path": "/tmp/.edit.mkv",
|
||||
"command_sequence": expected_command,
|
||||
},
|
||||
{
|
||||
"applied": result["applied"],
|
||||
"dry_run": result["dry_run"],
|
||||
"target_path": result["target_path"],
|
||||
"command_sequence": result["command_sequence"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"ffmpeg_seconds": 0.0,
|
||||
"replace_seconds": 0.0,
|
||||
"write_seconds": 0.0,
|
||||
},
|
||||
result["timings"],
|
||||
)
|
||||
|
||||
def test_apply_metadata_edits_notifies_with_command_when_verbose(self):
|
||||
context = make_context()
|
||||
context["verbosity"] = 1
|
||||
baseline_descriptor = make_descriptor()
|
||||
draft_descriptor = baseline_descriptor.clone(context=context)
|
||||
notifications = NotificationCollector()
|
||||
|
||||
with (
|
||||
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
|
||||
patch("ffx.metadata_editor.executeProcess", return_value=("", "", 0)),
|
||||
patch("ffx.metadata_editor.os.replace"),
|
||||
):
|
||||
apply_metadata_edits(
|
||||
context,
|
||||
"/tmp/example.mkv",
|
||||
baseline_descriptor,
|
||||
draft_descriptor,
|
||||
loggingHandler = notifications,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(notifications.messages))
|
||||
self.assertTrue(notifications.messages[0].startswith("ffmpeg: ffmpeg "))
|
||||
self.assertEqual([LogLevel.DEBUG], notifications.levels)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
47
tests/unit/test_migration.py
Normal file
47
tests/unit/test_migration.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx.model.migration import ( # noqa: E402
|
||||
DatabaseVersionException,
|
||||
getMigrationPlan,
|
||||
loadMigrationStep,
|
||||
migrateDatabase,
|
||||
)
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def test_get_migration_plan_lists_known_step_with_module_presence(self):
|
||||
migrationPlan = getMigrationPlan(2, 3)
|
||||
|
||||
self.assertEqual(1, len(migrationPlan))
|
||||
self.assertEqual(2, migrationPlan[0].versionFrom)
|
||||
self.assertEqual(3, migrationPlan[0].versionTo)
|
||||
self.assertEqual("ffx.model.migration.step_2_3", migrationPlan[0].moduleName)
|
||||
self.assertTrue(migrationPlan[0].modulePresent)
|
||||
|
||||
def test_load_migration_step_returns_known_step(self):
|
||||
migrationStep = loadMigrationStep(2, 3)
|
||||
|
||||
self.assertTrue(callable(migrationStep))
|
||||
|
||||
def test_migrate_database_raises_when_step_module_is_missing(self):
|
||||
updatedVersions = []
|
||||
|
||||
with self.assertRaises(DatabaseVersionException):
|
||||
migrateDatabase({}, 1, 2, lambda context, version: updatedVersions.append(version))
|
||||
|
||||
self.assertEqual([], updatedVersions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -193,6 +193,36 @@ class PatternManagementTests(unittest.TestCase):
|
||||
|
||||
self.assertIn("at least one track", str(caught.exception))
|
||||
|
||||
def test_save_pattern_schema_does_not_persist_attachment_tracks(self):
|
||||
pattern_id = self.save_pattern(
|
||||
1,
|
||||
r"^noattachments_(s[0-9]+e[0-9]+)\.mkv$",
|
||||
tracks=[
|
||||
make_track_descriptor(0, track_type=TrackType.VIDEO),
|
||||
make_track_descriptor(1, track_type=TrackType.ATTACHMENT),
|
||||
],
|
||||
)
|
||||
|
||||
Session = self.context["database"]["session"]
|
||||
session = Session()
|
||||
try:
|
||||
tracks = session.query(Pattern).filter(Pattern.id == pattern_id).first().tracks
|
||||
self.assertEqual(1, len(tracks))
|
||||
self.assertEqual(TrackType.VIDEO, tracks[0].getType())
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_track_controller_does_not_add_attachment_tracks_to_patterns(self):
|
||||
pattern_id = self.save_pattern(1, r"^skipadd_(s[0-9]+e[0-9]+)\.mkv$")
|
||||
|
||||
added = self.track_controller.addTrack(
|
||||
make_track_descriptor(1, track_type=TrackType.ATTACHMENT),
|
||||
patternId=pattern_id,
|
||||
)
|
||||
|
||||
self.assertFalse(added)
|
||||
self.assertEqual(1, len(self.track_controller.findTracks(pattern_id)))
|
||||
|
||||
def test_match_filename_rejects_existing_trackless_pattern_rows(self):
|
||||
self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$")
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -51,6 +52,33 @@ class ProcessTests(unittest.TestCase):
|
||||
self.assertIn("Command timed out", err)
|
||||
self.assertIn(sys.executable, err)
|
||||
|
||||
def test_execute_process_can_stop_early_while_streaming_stderr(self):
|
||||
start = time.monotonic()
|
||||
observed_lines = []
|
||||
|
||||
out, err, rc = executeProcess(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import sys, time; "
|
||||
"sys.stderr.write('fatal warning\\n'); sys.stderr.flush(); "
|
||||
"time.sleep(2); "
|
||||
"sys.stderr.write('late line\\n'); sys.stderr.flush()"
|
||||
),
|
||||
],
|
||||
stderrLineHandler=lambda line: observed_lines.append(line) or ("fatal warning" in line),
|
||||
)
|
||||
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
self.assertLess(elapsed, 1.5)
|
||||
self.assertNotEqual(0, rc)
|
||||
self.assertEqual("", out)
|
||||
self.assertIn("fatal warning", err)
|
||||
self.assertNotIn("late line", err)
|
||||
self.assertEqual(["fatal warning\n"], observed_lines)
|
||||
|
||||
def test_get_wrapped_command_sequence_leaves_command_unwrapped_when_limits_disabled(self):
|
||||
wrapped = getWrappedCommandSequence(
|
||||
["ffmpeg", "-i", "input.mkv"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -13,6 +14,7 @@ if str(SRC_ROOT) not in sys.path:
|
||||
|
||||
|
||||
from ffx import screen_support # noqa: E402
|
||||
from ffx.i18n import set_current_language, t # noqa: E402
|
||||
|
||||
|
||||
class StaticConfig:
|
||||
@@ -23,7 +25,72 @@ class StaticConfig:
|
||||
return self._data
|
||||
|
||||
|
||||
class FakeTagTable:
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
self._next_index = 0
|
||||
|
||||
def clear(self):
|
||||
self.rows.clear()
|
||||
|
||||
def add_row(self, *values):
|
||||
row_key = f"row-{self._next_index}"
|
||||
self._next_index += 1
|
||||
self.rows[row_key] = tuple(values)
|
||||
return row_key
|
||||
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self, screen_stack):
|
||||
self.screen_stack = list(screen_stack)
|
||||
self.pop_called = False
|
||||
self.exit_called = False
|
||||
|
||||
def pop_screen(self):
|
||||
self.pop_called = True
|
||||
|
||||
def exit(self):
|
||||
self.exit_called = True
|
||||
|
||||
|
||||
class FakeScreen:
|
||||
def __init__(self, screen_stack):
|
||||
self.app = FakeApp(screen_stack)
|
||||
|
||||
|
||||
class FakeRichLog:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def write(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class FakeScreenWithLog:
|
||||
def __init__(self):
|
||||
self.log_view = FakeRichLog()
|
||||
|
||||
def query_one(self, selector, _widget_type=None):
|
||||
if selector == f"#{screen_support.SCREEN_LOG_VIEW_ID}":
|
||||
return self.log_view
|
||||
raise LookupError(selector)
|
||||
|
||||
|
||||
class FakeThreadedApp:
|
||||
def __init__(self, screen):
|
||||
self.screen = screen
|
||||
self.calls = []
|
||||
|
||||
def call_from_thread(self, func, *args):
|
||||
self.calls.append((func, args))
|
||||
return func(*args)
|
||||
|
||||
|
||||
class ScreenSupportTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
set_current_language("de")
|
||||
screen_support.set_screen_log_pane_enabled(False)
|
||||
|
||||
def make_context(self):
|
||||
return {
|
||||
"config": StaticConfig(
|
||||
@@ -81,6 +148,113 @@ class ScreenSupportTests(unittest.TestCase):
|
||||
controllers,
|
||||
)
|
||||
|
||||
def test_populate_tag_table_keeps_raw_values_outside_display_labels(self):
|
||||
table = FakeTagTable()
|
||||
|
||||
row_data = screen_support.populate_tag_table(
|
||||
table,
|
||||
{"BPS": 4835, "KEEP": "plain"},
|
||||
ignore_keys=["KEEP"],
|
||||
remove_keys=["BPS"],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"row-0": ("BPS", "4835"),
|
||||
"row-1": ("KEEP", "plain"),
|
||||
},
|
||||
row_data,
|
||||
)
|
||||
self.assertEqual(
|
||||
("[red]BPS[/red]", "[red]4835[/red]"),
|
||||
table.rows["row-0"],
|
||||
)
|
||||
self.assertEqual(
|
||||
("[blue]KEEP[/blue]", "[blue]plain[/blue]"),
|
||||
table.rows["row-1"],
|
||||
)
|
||||
|
||||
def test_go_back_or_exit_exits_from_first_pushed_screen(self):
|
||||
screen = FakeScreen(screen_stack=["base", "shows"])
|
||||
|
||||
screen_support.go_back_or_exit(screen)
|
||||
|
||||
self.assertFalse(screen.app.pop_called)
|
||||
self.assertTrue(screen.app.exit_called)
|
||||
|
||||
def test_go_back_or_exit_pops_nested_screen(self):
|
||||
screen = FakeScreen(screen_stack=["base", "shows", "details"])
|
||||
|
||||
screen_support.go_back_or_exit(screen)
|
||||
|
||||
self.assertTrue(screen.app.pop_called)
|
||||
self.assertFalse(screen.app.exit_called)
|
||||
|
||||
def test_localized_column_width_handles_combining_character_labels(self):
|
||||
set_current_language("ta")
|
||||
|
||||
translated = t("SubIndex")
|
||||
self.assertEqual("துணைச்சுட்டி", translated)
|
||||
self.assertGreater(len(translated), 8)
|
||||
self.assertEqual(len(translated) + 2, screen_support.localized_column_width(translated, 8))
|
||||
|
||||
def test_build_screen_log_pane_is_hidden_when_debug_mode_is_disabled(self):
|
||||
screen_support.set_screen_log_pane_enabled(False)
|
||||
|
||||
log_pane = screen_support.build_screen_log_pane()
|
||||
|
||||
self.assertFalse(log_pane.display)
|
||||
|
||||
def test_build_screen_log_pane_is_collapsed_when_debug_mode_is_enabled(self):
|
||||
screen_support.set_screen_log_pane_enabled(True)
|
||||
|
||||
log_pane = screen_support.build_screen_log_pane()
|
||||
|
||||
self.assertIsInstance(log_pane, screen_support.ResizableScreenLogPane)
|
||||
self.assertEqual(screen_support.SCREEN_LOG_PANE_ID, log_pane.id)
|
||||
self.assertTrue(log_pane.collapsed)
|
||||
|
||||
def test_resizable_screen_log_pane_clamps_height_to_minimum(self):
|
||||
log_pane = screen_support.ResizableScreenLogPane()
|
||||
|
||||
log_pane.set_log_height(1)
|
||||
|
||||
self.assertEqual(screen_support.SCREEN_LOG_MIN_HEIGHT, log_pane.get_log_height())
|
||||
|
||||
def test_configure_screen_log_handler_routes_logger_messages_to_active_screen(self):
|
||||
logger_name = "ffx-test-screen-log-handler"
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
|
||||
for handler in list(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
screen = FakeScreenWithLog()
|
||||
app = FakeThreadedApp(screen)
|
||||
|
||||
try:
|
||||
handler = screen_support.configure_screen_log_handler(
|
||||
logger,
|
||||
app,
|
||||
enabled=True,
|
||||
)
|
||||
self.assertIsNotNone(handler)
|
||||
|
||||
logger.info("hello pane")
|
||||
|
||||
self.assertEqual(1, len(screen.log_view.messages))
|
||||
self.assertRegex(
|
||||
screen.log_view.messages[0],
|
||||
r"^ffx-test-screen-log-handler\s+INFO\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \| hello pane$",
|
||||
)
|
||||
finally:
|
||||
screen_support.configure_screen_log_handler(logger, app, enabled=False)
|
||||
for handler in list(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
204
tests/unit/test_shifted_season_controller.py
Normal file
204
tests/unit/test_shifted_season_controller.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
|
||||
from ffx.database import databaseContext # noqa: E402
|
||||
from ffx.model.pattern import Pattern # noqa: E402
|
||||
from ffx.model.track import Track # noqa: E402
|
||||
from ffx.show_controller import ShowController # noqa: E402
|
||||
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController # noqa: E402
|
||||
from ffx.track_type import TrackType # noqa: E402
|
||||
|
||||
|
||||
class StaticConfig:
|
||||
def __init__(self, data: dict | None = None):
|
||||
self._data = data or {}
|
||||
|
||||
def getData(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def make_logger(name: str) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
logger.handlers = []
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
logger.addHandler(logging.NullHandler())
|
||||
return logger
|
||||
|
||||
|
||||
def make_context(database_path: Path) -> dict:
|
||||
return {
|
||||
"logger": make_logger(f"ffx-test-shifted-{database_path.stem}"),
|
||||
"config": StaticConfig(),
|
||||
"database": databaseContext(str(database_path)),
|
||||
}
|
||||
|
||||
|
||||
class ShiftedSeasonControllerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
self.database_path = Path(self.tempdir.name) / "shifted-season-test.db"
|
||||
self.context = make_context(self.database_path)
|
||||
self.show_controller = ShowController(self.context)
|
||||
self.shifted_season_controller = ShiftedSeasonController(self.context)
|
||||
|
||||
def tearDown(self):
|
||||
self.context["database"]["engine"].dispose()
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def add_show(self, show_id: int, name: str = "Demo Show"):
|
||||
self.show_controller.updateShow(
|
||||
ShowDescriptor(id=show_id, name=name, year=2000 + show_id)
|
||||
)
|
||||
|
||||
def add_pattern(self, show_id: int, expression: str) -> int:
|
||||
self.add_show(show_id)
|
||||
Session = self.context["database"]["session"]
|
||||
session = Session()
|
||||
try:
|
||||
pattern = Pattern(show_id=show_id, pattern=expression)
|
||||
session.add(pattern)
|
||||
session.flush()
|
||||
session.add(
|
||||
Track(
|
||||
pattern_id=pattern.getId(),
|
||||
track_type=TrackType.VIDEO.index(),
|
||||
codec_name="h264",
|
||||
index=0,
|
||||
source_index=0,
|
||||
disposition_flags=0,
|
||||
audio_layout=0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return pattern.getId()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_shift_season_uses_show_mapping_when_no_pattern_mapping_exists(self):
|
||||
pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
|
||||
self.shifted_season_controller.addShiftedSeason(
|
||||
showId=1,
|
||||
shiftedSeasonObj={
|
||||
"original_season": 1,
|
||||
"first_episode": 1,
|
||||
"last_episode": 10,
|
||||
"season_offset": 2,
|
||||
"episode_offset": 5,
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(self.context["logger"], "info") as mocked_info:
|
||||
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
|
||||
showId=1,
|
||||
patternId=pattern_id,
|
||||
season=1,
|
||||
episode=3,
|
||||
)
|
||||
|
||||
self.assertEqual((3, 8), (shifted_season, shifted_episode))
|
||||
mocked_info.assert_called_once_with(
|
||||
"Setting season shift 1/3 -> 3/8 from show"
|
||||
)
|
||||
|
||||
def test_shift_season_prefers_pattern_mapping_over_show_mapping(self):
|
||||
pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
|
||||
self.shifted_season_controller.addShiftedSeason(
|
||||
showId=1,
|
||||
shiftedSeasonObj={
|
||||
"original_season": 1,
|
||||
"first_episode": 1,
|
||||
"last_episode": 10,
|
||||
"season_offset": 2,
|
||||
"episode_offset": 5,
|
||||
},
|
||||
)
|
||||
self.shifted_season_controller.addShiftedSeason(
|
||||
patternId=pattern_id,
|
||||
shiftedSeasonObj={
|
||||
"original_season": 1,
|
||||
"first_episode": 1,
|
||||
"last_episode": 10,
|
||||
"season_offset": 1,
|
||||
"episode_offset": -2,
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(self.context["logger"], "info") as mocked_info:
|
||||
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
|
||||
showId=1,
|
||||
patternId=pattern_id,
|
||||
season=1,
|
||||
episode=3,
|
||||
)
|
||||
|
||||
self.assertEqual((2, 1), (shifted_season, shifted_episode))
|
||||
mocked_info.assert_called_once_with(
|
||||
"Setting season shift 1/3 -> 2/1 from pattern"
|
||||
)
|
||||
|
||||
def test_shift_season_pattern_zero_offsets_override_show_mapping_to_identity(self):
|
||||
pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
|
||||
self.shifted_season_controller.addShiftedSeason(
|
||||
showId=1,
|
||||
shiftedSeasonObj={
|
||||
"original_season": 1,
|
||||
"first_episode": 1,
|
||||
"last_episode": 10,
|
||||
"season_offset": 2,
|
||||
"episode_offset": 5,
|
||||
},
|
||||
)
|
||||
self.shifted_season_controller.addShiftedSeason(
|
||||
patternId=pattern_id,
|
||||
shiftedSeasonObj={
|
||||
"original_season": 1,
|
||||
"first_episode": 1,
|
||||
"last_episode": 10,
|
||||
"season_offset": 0,
|
||||
"episode_offset": 0,
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(self.context["logger"], "info") as mocked_info:
|
||||
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
|
||||
showId=1,
|
||||
patternId=pattern_id,
|
||||
season=1,
|
||||
episode=3,
|
||||
)
|
||||
|
||||
self.assertEqual((1, 3), (shifted_season, shifted_episode))
|
||||
mocked_info.assert_not_called()
|
||||
|
||||
def test_shift_season_falls_back_to_identity_when_no_rule_matches(self):
|
||||
pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
|
||||
|
||||
with patch.object(self.context["logger"], "info") as mocked_info:
|
||||
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
|
||||
showId=1,
|
||||
patternId=pattern_id,
|
||||
season=4,
|
||||
episode=20,
|
||||
)
|
||||
|
||||
self.assertEqual((4, 20), (shifted_season, shifted_episode))
|
||||
mocked_info.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -56,6 +56,8 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
|
||||
self.assertEqual(3, descriptor.getIndexEpisodeDigits())
|
||||
self.assertEqual(3, descriptor.getIndicatorSeasonDigits())
|
||||
self.assertEqual(4, descriptor.getIndicatorEpisodeDigits())
|
||||
self.assertEqual(0, descriptor.getQuality())
|
||||
self.assertEqual("", descriptor.getNotes())
|
||||
|
||||
def test_show_descriptor_without_context_uses_shared_constants(self):
|
||||
descriptor = ShowDescriptor(id=1, name="Default Show", year=2024)
|
||||
@@ -70,6 +72,18 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
|
||||
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
|
||||
descriptor.getIndicatorEpisodeDigits(),
|
||||
)
|
||||
self.assertEqual(0, descriptor.getQuality())
|
||||
self.assertEqual("", descriptor.getNotes())
|
||||
|
||||
def test_show_descriptor_preserves_explicit_quality(self):
|
||||
descriptor = ShowDescriptor(id=1, name="Quality Show", year=2024, quality=23)
|
||||
|
||||
self.assertEqual(23, descriptor.getQuality())
|
||||
|
||||
def test_show_descriptor_preserves_explicit_notes(self):
|
||||
descriptor = ShowDescriptor(id=1, name="Notes Show", year=2024, notes="show notes")
|
||||
|
||||
self.assertEqual("show notes", descriptor.getNotes())
|
||||
|
||||
def test_episode_basename_uses_configured_digit_defaults_when_omitted(self):
|
||||
basename = getEpisodeFileBasename(
|
||||
|
||||
1001
tests/unit/test_tag_table_screen_state.py
Normal file
1001
tests/unit/test_tag_table_screen_state.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user