Compare commits
56 Commits
12509cd4e2
...
v0.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
46
README.md
46
README.md
@@ -99,6 +99,52 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
||||
|
||||
## Version History
|
||||
|
||||
### 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.2"
|
||||
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
|
||||
381
src/ffx/cli.py
381
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,6 +1134,7 @@ 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
|
||||
@@ -977,9 +1146,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 +1284,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 +1339,7 @@ def convert(ctx,
|
||||
ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs")
|
||||
|
||||
jobIndex = 0
|
||||
showController = ShowController(context)
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
@@ -1180,8 +1371,8 @@ 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,10 +1391,12 @@ def convert(ctx,
|
||||
sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor()
|
||||
|
||||
|
||||
from ffx.attachment_format import AttachmentFormat
|
||||
|
||||
if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks()
|
||||
if smd.getCodec() == TrackCodec.ASS]
|
||||
and [amd for amd in sourceMediaDescriptor.getAttachmentTracks()
|
||||
if amd.getCodec() == TrackCodec.TTF]):
|
||||
if amd.getAttachmentFormat() == AttachmentFormat.TTF]):
|
||||
|
||||
targetFormat = ''
|
||||
targetExtension = 'mkv'
|
||||
@@ -1263,7 +1456,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 +1472,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 +1488,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,
|
||||
season=showSeason,
|
||||
episode=showEpisode)
|
||||
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,
|
||||
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 +1605,30 @@ def convert(ctx,
|
||||
if rename_only:
|
||||
shutil.move(sourcePath, targetPath)
|
||||
else:
|
||||
fc.runJob(sourcePath,
|
||||
targetPath,
|
||||
targetFormat,
|
||||
chainIteration,
|
||||
cropArguments,
|
||||
currentPattern)
|
||||
try:
|
||||
fc.runJob(sourcePath,
|
||||
targetPath,
|
||||
targetFormat,
|
||||
chainIteration,
|
||||
cropArguments,
|
||||
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.2'
|
||||
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:
|
||||
@@ -28,11 +39,13 @@ class FfxApp(App):
|
||||
if self.context['command'] == 'shows':
|
||||
self.push_screen(ShowsScreen())
|
||||
|
||||
if self.context['command'] == 'inspect':
|
||||
self.push_screen(MediaDetailsScreen())
|
||||
if self.context['command'] == 'inspect':
|
||||
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,10 +63,52 @@ 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):
|
||||
out, err, rc = executeProcess(commandSequence, context=self.__context)
|
||||
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}")
|
||||
return out, err, rc
|
||||
@@ -79,10 +123,27 @@ class FfxController():
|
||||
|
||||
# -c:v libx264 -preset slow -crf 17
|
||||
def generateH264Tokens(self, quality, subIndex : int = 0):
|
||||
h264Encoder = self.getSupportedSoftwareH264Encoder()
|
||||
|
||||
return [f"-c:v:{int(subIndex)}", 'libx264',
|
||||
"-preset", "slow",
|
||||
'-crf', str(quality)]
|
||||
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
|
||||
@@ -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,28 +341,35 @@ class FfxController():
|
||||
deinterlaceFilters = [fy for fy in chainIteration if fy['identifier'] == 'bwdif']
|
||||
|
||||
|
||||
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
|
||||
self.__logger.info(f"Setting quality {quality} from command line parameter")
|
||||
elif currentPattern is not None and (quality := currentPattern.quality):
|
||||
self.__logger.info(f"Setting quality {quality} from pattern default")
|
||||
if copyVideo:
|
||||
quality = None
|
||||
self.__context['encoding_metadata_tags'] = {}
|
||||
else:
|
||||
quality = (QualityFilter.DEFAULT_H264_QUALITY
|
||||
if (videoEncoder == VideoEncoder.H264)
|
||||
else QualityFilter.DEFAULT_VP9_QUALITY)
|
||||
self.__logger.info(f"Setting quality {quality} from default")
|
||||
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
|
||||
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")
|
||||
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)
|
||||
else QualityFilter.DEFAULT_VP9_QUALITY)
|
||||
self.__logger.info(f"Setting quality {quality} from default")
|
||||
|
||||
|
||||
preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET
|
||||
self.__context['encoding_metadata_tags'] = self.generateEncodingMetadataTags(
|
||||
videoEncoder,
|
||||
quality,
|
||||
preset,
|
||||
)
|
||||
if not copyVideo:
|
||||
self.__context['encoding_metadata_tags'] = self.generateEncodingMetadataTags(
|
||||
videoEncoder,
|
||||
quality,
|
||||
preset,
|
||||
)
|
||||
|
||||
|
||||
filterParamTokens = []
|
||||
|
||||
if cropArguments:
|
||||
if cropArguments and not copyVideo:
|
||||
|
||||
cropParams = (f"crop="
|
||||
+ f"{cropArguments[CropFilter.OUTPUT_WIDTH_KEY]}"
|
||||
@@ -292,8 +379,9 @@ class FfxController():
|
||||
|
||||
filterParamTokens.append(cropParams)
|
||||
|
||||
filterParamTokens.extend(denoiseFilters[0]['tokens'] if denoiseFilters else [])
|
||||
filterParamTokens.extend(deinterlaceFilters[0]['tokens'] if deinterlaceFilters else [])
|
||||
if not copyVideo:
|
||||
filterParamTokens.extend(denoiseFilters[0]['tokens'] if denoiseFilters else [])
|
||||
filterParamTokens.extend(deinterlaceFilters[0]['tokens'] if deinterlaceFilters else [])
|
||||
|
||||
deinterlaceFilters
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -421,11 +422,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 +501,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 +551,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 +562,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,13 +30,27 @@ 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 [])
|
||||
@@ -119,7 +134,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 +167,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 +179,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 +260,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 +316,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 +343,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 +365,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."))
|
||||
434
src/ffx/media_workflow_screen_base.py
Normal file
434
src/ffx/media_workflow_screen_base.py
Normal file
@@ -0,0 +1,434 @@
|
||||
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._currentPattern.getMediaDescriptor(self.context)
|
||||
if self._currentPattern is not None
|
||||
else None
|
||||
)
|
||||
|
||||
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 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 ''")
|
||||
)
|
||||
@@ -35,6 +35,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)
|
||||
|
||||
|
||||
@@ -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,7 +226,10 @@ class Track(Base):
|
||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex
|
||||
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType()
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec()
|
||||
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()
|
||||
kwargs[TrackDescriptor.TAGS_KEY] = self.getTags()
|
||||
|
||||
@@ -134,7 +134,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(
|
||||
|
||||
@@ -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
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
|
||||
def checkShiftedSeason(self, showId: int, shiftedSeasonObj: dict, shiftedSeasonId: int = 0):
|
||||
def _resolve_owner(self, showId=None, patternId=None):
|
||||
hasShow = showId is not None
|
||||
hasPattern = patternId is not None
|
||||
|
||||
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 if for a particula season
|
||||
|
||||
shiftedSeasonId
|
||||
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():
|
||||
|
||||
siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason
|
||||
siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode()
|
||||
siblingLastEpisode = siblingShiftedSeason.getLastEpisode()
|
||||
|
||||
if (originalSeason == siblingOriginalSeason
|
||||
and lastEpisode >= siblingFirstEpisode
|
||||
and siblingLastEpisode >= firstEpisode):
|
||||
if fields['original_season'] != siblingShiftedSeason.getOriginalSeason():
|
||||
continue
|
||||
|
||||
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),
|
||||
ShiftedSeason.original_season == int(originalSeason),
|
||||
ShiftedSeason.first_episode == int(firstEpisode),
|
||||
ShiftedSeason.last_episode == int(lastEpisode),
|
||||
).first()
|
||||
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()
|
||||
)
|
||||
|
||||
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 shiftedSeason != season or shiftedEpisode != episode:
|
||||
self.context['logger'].info(
|
||||
f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
|
||||
)
|
||||
|
||||
if (season == shiftedSeasonEntry.getOriginalSeason()
|
||||
and (shiftedSeasonEntry.getFirstEpisode() == -1 or episode >= shiftedSeasonEntry.getFirstEpisode())
|
||||
and (shiftedSeasonEntry.getLastEpisode() == -1 or episode <= shiftedSeasonEntry.getLastEpisode())):
|
||||
return shiftedSeason, shiftedEpisode
|
||||
|
||||
shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset()
|
||||
shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset()
|
||||
def resolveShiftSeason(self, showId, season, episode, patternId=None):
|
||||
if season == -1 or episode == -1:
|
||||
return season, episode, "unrecognized"
|
||||
|
||||
self.context['logger'].info(f"Shifting season: {season} episode: {episode} "
|
||||
+f"-> season: {shiftedSeason} episode: {shiftedEpisode}")
|
||||
session = None
|
||||
try:
|
||||
session = self.Session()
|
||||
activeShift = None
|
||||
|
||||
return shiftedSeason, shiftedEpisode
|
||||
|
||||
return season, episode
|
||||
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):
|
||||
@@ -110,7 +214,9 @@ class ShowsScreen(Screen):
|
||||
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
|
||||
|
||||
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.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'}
|
||||
|
||||
|
||||
@@ -43,7 +43,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())),
|
||||
@@ -82,7 +82,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
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
|
||||
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,7 +535,9 @@ class TrackDetailsScreen(Screen):
|
||||
):
|
||||
|
||||
self.query_one("#messagestatic", Static).update(
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set"
|
||||
t(
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.query_one("#messagestatic", Static).update(" ")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -95,8 +96,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 +251,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",
|
||||
|
||||
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()
|
||||
|
||||
@@ -15,6 +15,7 @@ from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # 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 +28,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 +175,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 +389,79 @@ 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_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()
|
||||
@@ -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(
|
||||
|
||||
917
tests/unit/test_tag_table_screen_state.py
Normal file
917
tests/unit/test_tag_table_screen_state.py
Normal file
@@ -0,0 +1,917 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from textual.widgets import Select
|
||||
|
||||
|
||||
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.audio_layout import AudioLayout # noqa: E402
|
||||
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
||||
from ffx.helper import DIFF_ADDED_KEY # noqa: E402
|
||||
from ffx.iso_language import IsoLanguage # noqa: E402
|
||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||
from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402
|
||||
from ffx.i18n import set_current_language # noqa: E402
|
||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||
from ffx.media_edit_screen import MediaEditScreen # noqa: E402
|
||||
from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402
|
||||
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
||||
from ffx.show_details_screen import ShowDetailsScreen # noqa: E402
|
||||
from ffx.shows_screen import ShowsScreen # noqa: E402
|
||||
from ffx.track_codec import TrackCodec # noqa: E402
|
||||
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||
from ffx.track_details_screen import TrackDetailsScreen # noqa: E402
|
||||
from ffx.track_type import TrackType # noqa: E402
|
||||
|
||||
|
||||
class FakeTagTable:
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
self.columns = []
|
||||
self.cursor_coordinate = (0, 0)
|
||||
self._selected_row_key = None
|
||||
self._next_index = 0
|
||||
self._row_order = []
|
||||
|
||||
def clear(self, columns=False):
|
||||
self.rows.clear()
|
||||
self._selected_row_key = None
|
||||
self._row_order.clear()
|
||||
if columns:
|
||||
self.columns.clear()
|
||||
|
||||
def add_column(self, label, *, width=None, key=None, default=None):
|
||||
column_key = key if key is not None else len(self.columns)
|
||||
self.columns.append(
|
||||
{
|
||||
"key": column_key,
|
||||
"label": label,
|
||||
"width": width,
|
||||
"default": default,
|
||||
}
|
||||
)
|
||||
return column_key
|
||||
|
||||
def add_row(self, *values):
|
||||
row_key = f"row-{self._next_index}"
|
||||
self._next_index += 1
|
||||
self.rows[row_key] = tuple(values)
|
||||
self._row_order.append(row_key)
|
||||
if self._selected_row_key is None:
|
||||
self._selected_row_key = row_key
|
||||
return row_key
|
||||
|
||||
def coordinate_to_cell_key(self, _coordinate):
|
||||
return self._selected_row_key, None
|
||||
|
||||
def select_row(self, row_key):
|
||||
self._selected_row_key = row_key
|
||||
|
||||
def get_row_index(self, row_key):
|
||||
return self._row_order.index(row_key)
|
||||
|
||||
def remove_row(self, row_key):
|
||||
self.rows.pop(row_key, None)
|
||||
if row_key in self._row_order:
|
||||
self._row_order.remove(row_key)
|
||||
if self._selected_row_key == row_key:
|
||||
self._selected_row_key = self._row_order[0] if self._row_order else None
|
||||
|
||||
def update_cell(self, row_key, column_key, value):
|
||||
row = list(self.rows[row_key])
|
||||
row[int(column_key)] = value
|
||||
self.rows[row_key] = tuple(row)
|
||||
|
||||
|
||||
class FakeMediaDescriptor:
|
||||
def __init__(self, track_descriptors, tags=None):
|
||||
self._track_descriptors = list(track_descriptors)
|
||||
self._tags = dict(tags or {})
|
||||
|
||||
def getTrackDescriptors(self):
|
||||
return list(self._track_descriptors)
|
||||
|
||||
def getTags(self):
|
||||
return dict(self._tags)
|
||||
|
||||
|
||||
class FakeValueWidget:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
self.disabled = False
|
||||
|
||||
|
||||
class FakeInputWidget:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
class FakeStaticWidget:
|
||||
def __init__(self, value=""):
|
||||
self.value = value
|
||||
|
||||
def update(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
class FakeSelectionListWidget:
|
||||
def __init__(self, selected):
|
||||
self.selected = selected
|
||||
|
||||
def add_option(self, _option):
|
||||
return None
|
||||
|
||||
|
||||
def make_track_descriptor(index, sub_index, track_type):
|
||||
return TrackDescriptor(
|
||||
index=index,
|
||||
sub_index=sub_index,
|
||||
track_type=track_type,
|
||||
codec_name=TrackCodec.UNKNOWN,
|
||||
audio_layout=AudioLayout.LAYOUT_UNDEFINED,
|
||||
)
|
||||
|
||||
|
||||
def make_show_descriptor(show_id, name="Show", year=2000):
|
||||
return ShowDescriptor(
|
||||
id=show_id,
|
||||
name=name,
|
||||
year=year,
|
||||
)
|
||||
|
||||
|
||||
class TagTableScreenStateTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
set_current_language("de")
|
||||
|
||||
def test_track_details_screen_reads_selected_tag_from_raw_row_mapping(self):
|
||||
screen = object.__new__(TrackDetailsScreen)
|
||||
screen.trackTagsTable = FakeTagTable()
|
||||
screen._TrackDetailsScreen__draftTrackTags = {
|
||||
"BPS": "4835",
|
||||
"KEEP_ME": "plain",
|
||||
}
|
||||
screen._TrackDetailsScreen__ignoreTrackKeys = ["KEEP_ME"]
|
||||
screen._TrackDetailsScreen__removeTrackKeys = ["BPS"]
|
||||
screen._TrackDetailsScreen__tagRowData = {}
|
||||
|
||||
screen.updateTags()
|
||||
|
||||
self.assertEqual(
|
||||
("[red]BPS[/red]", "[red]4835[/red]"),
|
||||
screen.trackTagsTable.rows["row-0"],
|
||||
)
|
||||
self.assertEqual(
|
||||
("BPS", "4835"),
|
||||
screen.getSelectedTag(),
|
||||
)
|
||||
|
||||
def test_track_details_screen_reads_select_values_from_widget_state(self):
|
||||
screen = object.__new__(TrackDetailsScreen)
|
||||
screen.context = {"logger": get_ffx_logger()}
|
||||
screen._TrackDetailsScreen__trackDescriptor = None
|
||||
screen._TrackDetailsScreen__patternId = 5
|
||||
screen._TrackDetailsScreen__index = 2
|
||||
screen._TrackDetailsScreen__subIndex = 0
|
||||
screen._TrackDetailsScreen__trackCodec = TrackCodec.UNKNOWN
|
||||
screen._TrackDetailsScreen__draftTrackTags = {"KEEP": "value"}
|
||||
|
||||
widgets = {
|
||||
"#type_select": FakeValueWidget(TrackType.AUDIO),
|
||||
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_STEREO),
|
||||
"#language_select": FakeValueWidget(IsoLanguage.GERMAN),
|
||||
"#title_input": FakeInputWidget("German Audio"),
|
||||
"#dispositions_selection_list": FakeSelectionListWidget({0, 6}),
|
||||
}
|
||||
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
|
||||
|
||||
descriptor = screen.getTrackDescriptorFromInput()
|
||||
|
||||
self.assertEqual(TrackType.AUDIO, descriptor.getType())
|
||||
self.assertEqual(AudioLayout.LAYOUT_STEREO, descriptor.getAudioLayout())
|
||||
self.assertEqual("deu", descriptor.getTags()["language"])
|
||||
self.assertEqual("German Audio", descriptor.getTitle())
|
||||
self.assertEqual("value", descriptor.getTags()["KEEP"])
|
||||
|
||||
def test_track_details_screen_preserves_attachment_format_for_attachment_tracks(self):
|
||||
screen = object.__new__(TrackDetailsScreen)
|
||||
screen.context = {"logger": get_ffx_logger()}
|
||||
screen._TrackDetailsScreen__trackDescriptor = None
|
||||
screen._TrackDetailsScreen__patternId = 5
|
||||
screen._TrackDetailsScreen__index = 4
|
||||
screen._TrackDetailsScreen__subIndex = 0
|
||||
screen._TrackDetailsScreen__trackCodec = TrackCodec.UNKNOWN
|
||||
screen._TrackDetailsScreen__attachmentFormat = AttachmentFormat.TTF
|
||||
screen._TrackDetailsScreen__draftTrackTags = {"filename": "font.ttf", "mimetype": "font/ttf"}
|
||||
|
||||
widgets = {
|
||||
"#type_select": FakeValueWidget(TrackType.ATTACHMENT),
|
||||
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED),
|
||||
"#language_select": FakeValueWidget(Select.NULL),
|
||||
"#title_input": FakeInputWidget(""),
|
||||
"#dispositions_selection_list": FakeSelectionListWidget(set()),
|
||||
}
|
||||
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
|
||||
|
||||
descriptor = screen.getTrackDescriptorFromInput()
|
||||
|
||||
self.assertEqual(TrackType.ATTACHMENT, descriptor.getType())
|
||||
self.assertEqual(AttachmentFormat.TTF, descriptor.getAttachmentFormat())
|
||||
self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec())
|
||||
|
||||
def test_track_details_screen_auto_sets_localized_title_from_selected_language(self):
|
||||
set_current_language("de")
|
||||
screen = object.__new__(TrackDetailsScreen)
|
||||
screen._TrackDetailsScreen__titleAutoManaged = True
|
||||
screen._TrackDetailsScreen__suppressTitleChanged = False
|
||||
screen._TrackDetailsScreen__lastAutoTitle = ""
|
||||
|
||||
widgets = {
|
||||
"#language_select": FakeValueWidget(IsoLanguage.UNDEFINED),
|
||||
"#title_input": FakeInputWidget(""),
|
||||
}
|
||||
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
|
||||
|
||||
widgets["#language_select"].value = IsoLanguage.GERMAN
|
||||
screen._handle_language_selection_changed(IsoLanguage.GERMAN)
|
||||
self.assertEqual("Deutsch", widgets["#title_input"].value)
|
||||
|
||||
widgets["#language_select"].value = IsoLanguage.ENGLISH
|
||||
screen._handle_language_selection_changed(IsoLanguage.ENGLISH)
|
||||
self.assertEqual("Englisch", widgets["#title_input"].value)
|
||||
|
||||
def test_track_details_screen_auto_title_stops_after_manual_title_change(self):
|
||||
set_current_language("de")
|
||||
screen = object.__new__(TrackDetailsScreen)
|
||||
screen._TrackDetailsScreen__titleAutoManaged = True
|
||||
screen._TrackDetailsScreen__suppressTitleChanged = False
|
||||
screen._TrackDetailsScreen__lastAutoTitle = ""
|
||||
|
||||
widgets = {
|
||||
"#language_select": FakeValueWidget(IsoLanguage.GERMAN),
|
||||
"#title_input": FakeInputWidget(""),
|
||||
}
|
||||
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
|
||||
|
||||
screen._handle_language_selection_changed(IsoLanguage.GERMAN)
|
||||
self.assertEqual("Deutsch", widgets["#title_input"].value)
|
||||
|
||||
widgets["#title_input"].value = "Eigener Titel"
|
||||
screen._handle_title_input_changed("Eigener Titel")
|
||||
|
||||
widgets["#language_select"].value = IsoLanguage.ENGLISH
|
||||
screen._handle_language_selection_changed(IsoLanguage.ENGLISH)
|
||||
self.assertEqual("Eigener Titel", widgets["#title_input"].value)
|
||||
|
||||
def test_track_details_screen_does_not_auto_set_title_when_helper_is_inactive(self):
|
||||
set_current_language("de")
|
||||
screen = object.__new__(TrackDetailsScreen)
|
||||
screen._TrackDetailsScreen__titleAutoManaged = False
|
||||
screen._TrackDetailsScreen__suppressTitleChanged = False
|
||||
screen._TrackDetailsScreen__lastAutoTitle = ""
|
||||
|
||||
widgets = {
|
||||
"#language_select": FakeValueWidget(IsoLanguage.UNDEFINED),
|
||||
"#title_input": FakeInputWidget("Preset"),
|
||||
}
|
||||
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
|
||||
|
||||
widgets["#language_select"].value = IsoLanguage.GERMAN
|
||||
screen._handle_language_selection_changed(IsoLanguage.GERMAN)
|
||||
|
||||
self.assertEqual("Preset", widgets["#title_input"].value)
|
||||
|
||||
def test_track_details_screen_metadata_only_mount_shows_normalized_title_preview(self):
|
||||
set_current_language("de")
|
||||
screen = object.__new__(TrackDetailsScreen)
|
||||
screen._TrackDetailsScreen__index = 2
|
||||
screen._TrackDetailsScreen__subIndex = 0
|
||||
screen._TrackDetailsScreen__patternLabel = "demo"
|
||||
screen._TrackDetailsScreen__trackType = TrackType.AUDIO
|
||||
screen._TrackDetailsScreen__audioLayout = AudioLayout.LAYOUT_STEREO
|
||||
screen._TrackDetailsScreen__trackDescriptor = TrackDescriptor(
|
||||
index=2,
|
||||
source_index=2,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
codec_name=TrackCodec.DTS,
|
||||
audio_layout=AudioLayout.LAYOUT_STEREO,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
screen._TrackDetailsScreen__metadataOnly = True
|
||||
screen._TrackDetailsScreen__titleAutoManaged = True
|
||||
screen._TrackDetailsScreen__suppressTitleChanged = False
|
||||
screen._TrackDetailsScreen__lastAutoTitle = ""
|
||||
screen._TrackDetailsScreen__removeTrackKeys = []
|
||||
screen._TrackDetailsScreen__ignoreTrackKeys = []
|
||||
screen._TrackDetailsScreen__draftTrackTags = {}
|
||||
screen._TrackDetailsScreen__tagRowData = {}
|
||||
screen.updateTags = lambda: None
|
||||
|
||||
widgets = {
|
||||
"#index_label": FakeStaticWidget(),
|
||||
"#subindex_label": FakeStaticWidget(),
|
||||
"#pattern_label": FakeStaticWidget(),
|
||||
"#type_select": FakeValueWidget(None),
|
||||
"#audio_layout_select": FakeValueWidget(None),
|
||||
"#dispositions_selection_list": FakeSelectionListWidget(set()),
|
||||
"#language_select": FakeValueWidget(None),
|
||||
"#title_input": FakeInputWidget(""),
|
||||
}
|
||||
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
|
||||
|
||||
screen.on_mount()
|
||||
|
||||
self.assertEqual("Deutsch", widgets["#title_input"].value)
|
||||
|
||||
def test_track_details_screen_language_options_are_sorted_by_localized_label(self):
|
||||
set_current_language("de")
|
||||
|
||||
language_options = TrackDetailsScreen.build_language_options()
|
||||
labels = [label for label, _language in language_options]
|
||||
languages = [_language for _label, _language in language_options]
|
||||
|
||||
self.assertEqual(labels, sorted(labels, key=str.casefold))
|
||||
self.assertNotIn(IsoLanguage.UNDEFINED, languages)
|
||||
|
||||
def test_track_details_screen_uses_blank_select_value_for_undefined_language(self):
|
||||
self.assertEqual(
|
||||
TrackDetailsScreen.language_select_value(IsoLanguage.UNDEFINED),
|
||||
Select.NULL,
|
||||
)
|
||||
self.assertEqual(
|
||||
IsoLanguage.GERMAN,
|
||||
TrackDetailsScreen.language_select_value(IsoLanguage.GERMAN),
|
||||
)
|
||||
|
||||
def test_pattern_details_screen_reads_selected_track_from_row_mapping(self):
|
||||
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
|
||||
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
|
||||
|
||||
screen = object.__new__(PatternDetailsScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._PatternDetailsScreen__draftTracks = [first_track, second_track]
|
||||
screen._PatternDetailsScreen__pattern = None
|
||||
screen._PatternDetailsScreen__trackRowData = {}
|
||||
|
||||
screen.updateTracks()
|
||||
screen.tracksTable.select_row("row-1")
|
||||
|
||||
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
|
||||
|
||||
def test_pattern_details_screen_reads_selected_tag_from_raw_row_mapping(self):
|
||||
screen = object.__new__(PatternDetailsScreen)
|
||||
screen.tagsTable = FakeTagTable()
|
||||
screen._PatternDetailsScreen__pattern = None
|
||||
screen._PatternDetailsScreen__draftTags = {
|
||||
"BPS": "4835",
|
||||
"TITLE": "Deutsch [FN]",
|
||||
}
|
||||
screen._PatternDetailsScreen__ignoreGlobalKeys = ["TITLE"]
|
||||
screen._PatternDetailsScreen__removeGlobalKeys = ["BPS"]
|
||||
screen._PatternDetailsScreen__tagRowData = {}
|
||||
|
||||
screen.updateTags()
|
||||
|
||||
self.assertEqual(
|
||||
("[red]BPS[/red]", "[red]4835[/red]"),
|
||||
screen.tagsTable.rows["row-0"],
|
||||
)
|
||||
self.assertEqual(
|
||||
("BPS", "4835"),
|
||||
screen.getSelectedTag(),
|
||||
)
|
||||
|
||||
def test_media_edit_screen_reads_selected_track_from_row_mapping(self):
|
||||
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
|
||||
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
|
||||
|
||||
screen = object.__new__(MediaEditScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor(
|
||||
[first_track, second_track]
|
||||
)
|
||||
screen._trackRowData = {}
|
||||
|
||||
screen.updateTracks()
|
||||
screen.tracksTable.select_row("row-1")
|
||||
|
||||
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
|
||||
|
||||
def test_media_edit_screen_update_tracks_rebuilds_columns_for_auto_width_recalculation(self):
|
||||
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
|
||||
first_track.getTags()["title"] = "A much longer updated title"
|
||||
|
||||
screen = object.__new__(MediaEditScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([first_track])
|
||||
screen._trackRowData = {}
|
||||
screen._applyNormalization = False
|
||||
|
||||
screen.updateTracks()
|
||||
|
||||
self.assertEqual(9, len(screen.tracksTable.columns))
|
||||
self.assertIn("A much longer updated title", screen.tracksTable.rows["row-0"])
|
||||
|
||||
def test_media_edit_screen_shows_normalized_audio_title_preview(self):
|
||||
set_current_language("de")
|
||||
audio_track = TrackDescriptor(
|
||||
index=1,
|
||||
source_index=1,
|
||||
sub_index=0,
|
||||
track_type=TrackType.AUDIO,
|
||||
codec_name=TrackCodec.DTS,
|
||||
audio_layout=AudioLayout.LAYOUT_STEREO,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
|
||||
screen = object.__new__(MediaEditScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([audio_track])
|
||||
screen._trackRowData = {}
|
||||
screen._applyNormalization = True
|
||||
|
||||
screen.updateTracks()
|
||||
|
||||
self.assertIn("Deutsch", screen.tracksTable.rows["row-0"])
|
||||
|
||||
def test_media_edit_screen_shows_normalized_video_title_preview(self):
|
||||
set_current_language("de")
|
||||
video_track = TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
sub_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
codec_name=TrackCodec.H264,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
|
||||
screen = object.__new__(MediaEditScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([video_track])
|
||||
screen._trackRowData = {}
|
||||
screen._applyNormalization = True
|
||||
|
||||
screen.updateTracks()
|
||||
|
||||
self.assertIn("Deutsch", screen.tracksTable.rows["row-0"])
|
||||
|
||||
def test_media_edit_screen_toggle_normalization_refreshes_tracks(self):
|
||||
screen = object.__new__(MediaEditScreen)
|
||||
screen._applyNormalization = False
|
||||
|
||||
calls = []
|
||||
|
||||
screen.setApplyNormalization = lambda enabled: (
|
||||
setattr(screen, "_applyNormalization", bool(enabled)),
|
||||
calls.append("setApplyNormalization"),
|
||||
)
|
||||
screen.updateToggleButtons = lambda: calls.append("updateToggleButtons")
|
||||
screen.updateTracks = lambda: calls.append("updateTracks")
|
||||
screen.updateDifferences = lambda: calls.append("updateDifferences")
|
||||
screen.setMessage = lambda _message: calls.append("setMessage")
|
||||
|
||||
screen.action_toggle_normalization()
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"setApplyNormalization",
|
||||
"updateToggleButtons",
|
||||
"updateTracks",
|
||||
"updateDifferences",
|
||||
"setMessage",
|
||||
],
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_media_edit_screen_handle_edit_track_updates_draft_descriptor(self):
|
||||
original_track = TrackDescriptor(
|
||||
index=1,
|
||||
source_index=1,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
codec_name=TrackCodec.UNKNOWN,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
context = {"logger": get_ffx_logger()}
|
||||
updated_track = original_track.clone(context=context)
|
||||
updated_track.getTags()["language"] = "eng"
|
||||
|
||||
screen = object.__new__(MediaEditScreen)
|
||||
screen.context = context
|
||||
screen._sourceMediaDescriptor = MediaDescriptor(
|
||||
context=context,
|
||||
track_descriptors=[original_track],
|
||||
)
|
||||
|
||||
calls = []
|
||||
screen.setMessage = lambda _message: calls.append("setMessage")
|
||||
screen.refreshAfterDraftChange = lambda: calls.append("refreshAfterDraftChange")
|
||||
|
||||
screen.handle_edit_track(updated_track)
|
||||
|
||||
self.assertEqual(
|
||||
"eng",
|
||||
screen._sourceMediaDescriptor.getTrackDescriptors()[0].getTags()["language"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["setMessage", "refreshAfterDraftChange"],
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_media_edit_screen_screen_resume_refreshes_draft_tables(self):
|
||||
screen = object.__new__(MediaEditScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
|
||||
calls = []
|
||||
screen.refreshAfterDraftChange = lambda: calls.append("refreshAfterDraftChange")
|
||||
screen.updateToggleButtons = lambda: calls.append("updateToggleButtons")
|
||||
|
||||
screen.on_screen_resume(None)
|
||||
|
||||
self.assertEqual(
|
||||
["refreshAfterDraftChange", "updateToggleButtons"],
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_pattern_details_screen_screen_resume_refreshes_tables(self):
|
||||
screen = object.__new__(PatternDetailsScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen.tagsTable = FakeTagTable()
|
||||
screen.shiftedSeasonsTable = FakeTagTable()
|
||||
screen._PatternDetailsScreen__pattern = object()
|
||||
screen._PatternDetailsScreen__showDescriptor = None
|
||||
widgets = {
|
||||
"#show_quality_hint": FakeStaticWidget(),
|
||||
}
|
||||
screen.query_one = lambda selector, _type=None: widgets[selector]
|
||||
|
||||
calls = []
|
||||
screen.updateTags = lambda: calls.append("updateTags")
|
||||
screen.updateTracks = lambda: calls.append("updateTracks")
|
||||
screen.updateShiftedSeasons = lambda: calls.append("updateShiftedSeasons")
|
||||
|
||||
screen.on_screen_resume(None)
|
||||
|
||||
self.assertEqual(
|
||||
["updateTags", "updateTracks", "updateShiftedSeasons"],
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_pattern_details_screen_on_mount_shows_show_quality_hint_for_new_pattern(self):
|
||||
set_current_language("en")
|
||||
|
||||
screen = object.__new__(PatternDetailsScreen)
|
||||
screen.context = {}
|
||||
screen._PatternDetailsScreen__showDescriptor = ShowDescriptor(
|
||||
id=7,
|
||||
name="Demo",
|
||||
year=1999,
|
||||
quality=23,
|
||||
)
|
||||
screen._PatternDetailsScreen__pattern = None
|
||||
|
||||
widgets = {
|
||||
"#showlabel": FakeStaticWidget(),
|
||||
"#show_quality_hint": FakeStaticWidget(),
|
||||
}
|
||||
screen.query_one = lambda selector, _type=None: widgets[selector]
|
||||
|
||||
screen.on_mount()
|
||||
|
||||
self.assertEqual("7 - Demo (1999)", widgets["#showlabel"].value)
|
||||
self.assertEqual("Show: 23", widgets["#show_quality_hint"].value)
|
||||
|
||||
def test_pattern_details_screen_show_quality_hint_is_hidden_when_pattern_quality_exists(self):
|
||||
set_current_language("en")
|
||||
|
||||
screen = object.__new__(PatternDetailsScreen)
|
||||
screen._PatternDetailsScreen__showDescriptor = ShowDescriptor(
|
||||
id=7,
|
||||
name="Demo",
|
||||
year=1999,
|
||||
quality=23,
|
||||
)
|
||||
screen._PatternDetailsScreen__pattern = type(
|
||||
"_Pattern",
|
||||
(),
|
||||
{"quality": 19},
|
||||
)()
|
||||
|
||||
self.assertEqual("", screen.getShowQualityHintText())
|
||||
|
||||
def test_inspect_details_screen_handle_edit_pattern_refreshes_even_without_result(self):
|
||||
screen = object.__new__(InspectDetailsScreen)
|
||||
|
||||
calls = []
|
||||
screen.reloadProperties = lambda reset_draft=True: calls.append(
|
||||
("reloadProperties", reset_draft)
|
||||
)
|
||||
screen._currentPattern = None
|
||||
screen.updateMediaTags = lambda: calls.append("updateMediaTags")
|
||||
screen.updateTracks = lambda: calls.append("updateTracks")
|
||||
screen.updateDifferences = lambda: calls.append("updateDifferences")
|
||||
|
||||
screen.handle_edit_pattern(None)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
("reloadProperties", True),
|
||||
"updateMediaTags",
|
||||
"updateTracks",
|
||||
"updateDifferences",
|
||||
],
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_pattern_details_screen_reads_selected_shifted_season_from_row_mapping(self):
|
||||
screen = object.__new__(PatternDetailsScreen)
|
||||
screen.shiftedSeasonsTable = FakeTagTable()
|
||||
screen._PatternDetailsScreen__pattern = object()
|
||||
screen._PatternDetailsScreen__shiftedSeasonRowData = {}
|
||||
|
||||
row_key = screen.shiftedSeasonsTable.add_row("9", "1", "3", "1", "0")
|
||||
screen._PatternDetailsScreen__shiftedSeasonRowData[row_key] = {
|
||||
"id": 44,
|
||||
"original_season": 9,
|
||||
"first_episode": 1,
|
||||
"last_episode": 3,
|
||||
"season_offset": 1,
|
||||
"episode_offset": 0,
|
||||
}
|
||||
screen.shiftedSeasonsTable.rows[row_key] = ("broken", "ui", "values", "!", "?")
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"id": 44,
|
||||
"original_season": 9,
|
||||
"first_episode": 1,
|
||||
"last_episode": 3,
|
||||
"season_offset": 1,
|
||||
"episode_offset": 0,
|
||||
},
|
||||
screen.getSelectedShiftedSeasonObjFromInput(),
|
||||
)
|
||||
|
||||
def test_show_details_screen_reads_selected_pattern_from_row_mapping(self):
|
||||
screen = object.__new__(ShowDetailsScreen)
|
||||
screen.patternTable = FakeTagTable()
|
||||
screen._ShowDetailsScreen__showDescriptor = make_show_descriptor(7, "Demo", 1999)
|
||||
screen._ShowDetailsScreen__patternRowData = {}
|
||||
|
||||
row_key = screen._add_pattern_row(pattern_id=11, pattern_text=r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
|
||||
screen.patternTable.rows[row_key] = ("display text changed",)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"id": 11,
|
||||
"show_id": 7,
|
||||
"pattern": r"^demo_(s[0-9]+e[0-9]+)\.mkv$",
|
||||
},
|
||||
screen.getSelectedPatternDescriptor(),
|
||||
)
|
||||
|
||||
def test_show_details_screen_reads_selected_shifted_season_from_row_mapping(self):
|
||||
screen = object.__new__(ShowDetailsScreen)
|
||||
screen.shiftedSeasonsTable = FakeTagTable()
|
||||
screen._ShowDetailsScreen__shiftedSeasonRowData = {}
|
||||
|
||||
row_key = screen.shiftedSeasonsTable.add_row("1", "", "", "0", "0")
|
||||
screen._ShowDetailsScreen__shiftedSeasonRowData[row_key] = {
|
||||
"id": 3,
|
||||
"original_season": 1,
|
||||
"first_episode": -1,
|
||||
"last_episode": -1,
|
||||
"season_offset": 0,
|
||||
"episode_offset": 0,
|
||||
}
|
||||
screen.shiftedSeasonsTable.rows[row_key] = ("bad", "visible", "data", "x", "y")
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"id": 3,
|
||||
"original_season": 1,
|
||||
"first_episode": -1,
|
||||
"last_episode": -1,
|
||||
"season_offset": 0,
|
||||
"episode_offset": 0,
|
||||
},
|
||||
screen.getSelectedShiftedSeasonObjFromInput(),
|
||||
)
|
||||
|
||||
def test_shows_screen_reads_selected_show_id_from_row_mapping(self):
|
||||
screen = object.__new__(ShowsScreen)
|
||||
screen.table = FakeTagTable()
|
||||
screen._ShowsScreen__showRowData = {}
|
||||
|
||||
row_key = screen._add_show_row(make_show_descriptor(4, "Mapped", 2011))
|
||||
screen.table.rows[row_key] = ("999", "Visible", "2099")
|
||||
|
||||
self.assertEqual(4, screen.getSelectedShowId())
|
||||
|
||||
def test_inspect_details_screen_reads_selected_show_from_row_mapping(self):
|
||||
screen = object.__new__(InspectDetailsScreen)
|
||||
screen.showsTable = FakeTagTable()
|
||||
screen._showRowData = {}
|
||||
|
||||
placeholder_key = screen._add_show_row(None)
|
||||
show_key = screen._add_show_row(make_show_descriptor(8, "Real Show", 2020))
|
||||
screen.showsTable.select_row(show_key)
|
||||
screen.showsTable.rows[show_key] = ("oops", "display", "changed")
|
||||
|
||||
selected_show = screen.getSelectedShowDescriptor()
|
||||
|
||||
self.assertIsInstance(selected_show, ShowDescriptor)
|
||||
self.assertEqual(8, selected_show.getId())
|
||||
self.assertEqual(0, screen.getRowIndexFromShowId(-1))
|
||||
self.assertEqual(1, screen.getRowIndexFromShowId(8))
|
||||
|
||||
screen.removeShow(-1)
|
||||
self.assertNotIn(placeholder_key, screen._showRowData)
|
||||
self.assertEqual(0, screen.getRowIndexFromShowId(8))
|
||||
|
||||
def test_inspect_details_screen_update_tracks_shows_target_pattern_tracks(self):
|
||||
source_track = TrackDescriptor(
|
||||
index=1,
|
||||
source_index=1,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
codec_name=TrackCodec.UNKNOWN,
|
||||
tags={"language": "ger", "title": "German Full"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=1,
|
||||
source_index=1,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
codec_name=TrackCodec.UNKNOWN,
|
||||
tags={"language": "eng", "title": "English Full"},
|
||||
)
|
||||
|
||||
screen = object.__new__(InspectDetailsScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([source_track])
|
||||
screen._targetMediaDescriptor = FakeMediaDescriptor([target_track])
|
||||
screen._currentPattern = object()
|
||||
screen._trackRowData = {}
|
||||
screen._applyNormalization = False
|
||||
|
||||
screen.updateTracks()
|
||||
|
||||
self.assertIn("English Full", screen.tracksTable.rows["row-0"])
|
||||
self.assertIs(target_track, screen.getSelectedTrackDescriptor())
|
||||
|
||||
def test_inspect_details_screen_update_tracks_shows_attachment_format_and_blanks_language(self):
|
||||
attachment_track = TrackDescriptor(
|
||||
index=4,
|
||||
source_index=4,
|
||||
sub_index=0,
|
||||
track_type=TrackType.ATTACHMENT,
|
||||
attachment_format=AttachmentFormat.TTF,
|
||||
tags={"filename": "font.ttf", "mimetype": "font/ttf"},
|
||||
)
|
||||
|
||||
screen = object.__new__(InspectDetailsScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([attachment_track])
|
||||
screen._targetMediaDescriptor = None
|
||||
screen._currentPattern = None
|
||||
screen._trackRowData = {}
|
||||
screen._applyNormalization = False
|
||||
|
||||
screen.updateTracks()
|
||||
|
||||
row = screen.tracksTable.rows["row-0"]
|
||||
|
||||
self.assertEqual("4", row[0])
|
||||
self.assertEqual("TTF", row[3])
|
||||
self.assertEqual(" ", row[5])
|
||||
self.assertEqual(" ", row[7])
|
||||
self.assertEqual(" ", row[8])
|
||||
|
||||
def test_inspect_details_screen_update_tracks_shows_unknown_for_unknown_attachment_format(self):
|
||||
attachment_track = TrackDescriptor(
|
||||
index=5,
|
||||
source_index=5,
|
||||
sub_index=0,
|
||||
track_type=TrackType.ATTACHMENT,
|
||||
attachment_format=AttachmentFormat.UNKNOWN,
|
||||
tags={"filename": "blob.bin", "mimetype": "application/octet-stream"},
|
||||
)
|
||||
|
||||
screen = object.__new__(InspectDetailsScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([attachment_track])
|
||||
screen._targetMediaDescriptor = None
|
||||
screen._currentPattern = None
|
||||
screen._trackRowData = {}
|
||||
screen._applyNormalization = False
|
||||
|
||||
screen.updateTracks()
|
||||
|
||||
row = screen.tracksTable.rows["row-0"]
|
||||
|
||||
self.assertEqual("unknown", row[3])
|
||||
self.assertEqual(" ", row[5])
|
||||
|
||||
def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
|
||||
source_track = TrackDescriptor(
|
||||
index=3,
|
||||
source_index=7,
|
||||
sub_index=1,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
codec_name=TrackCodec.UNKNOWN,
|
||||
tags={"language": "ger"},
|
||||
)
|
||||
target_track = TrackDescriptor(
|
||||
index=1,
|
||||
source_index=7,
|
||||
sub_index=0,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
codec_name=TrackCodec.UNKNOWN,
|
||||
tags={"language": "eng"},
|
||||
)
|
||||
|
||||
screen = object.__new__(InspectDetailsScreen)
|
||||
screen.tracksTable = FakeTagTable()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([source_track])
|
||||
screen._targetMediaDescriptor = FakeMediaDescriptor([target_track])
|
||||
screen._currentPattern = object()
|
||||
screen._trackRowData = {}
|
||||
screen._applyNormalization = False
|
||||
|
||||
screen.updateTracks()
|
||||
|
||||
self.assertIs(source_track, screen.getTrackEditSourceDescriptor())
|
||||
|
||||
def test_inspect_details_screen_action_update_pattern_uses_existing_change_set_before_reload(self):
|
||||
class _FakePattern:
|
||||
def getPattern(self):
|
||||
return r"demo_(s[0-9]+e[0-9]+)\.mkv"
|
||||
|
||||
def getId(self):
|
||||
return 9
|
||||
|
||||
class _FakeTagController:
|
||||
def __init__(self, calls):
|
||||
self._calls = calls
|
||||
|
||||
def deleteMediaTagByKey(self, pattern_id, key):
|
||||
self._calls.append(("deleteMediaTagByKey", pattern_id, key))
|
||||
|
||||
calls = []
|
||||
|
||||
screen = object.__new__(InspectDetailsScreen)
|
||||
screen._currentPattern = _FakePattern()
|
||||
screen._mediaChangeSetObj = {
|
||||
"tags": {
|
||||
DIFF_ADDED_KEY: {"TITLE": "Demo"},
|
||||
}
|
||||
}
|
||||
screen._tac = _FakeTagController(calls)
|
||||
screen._tc = type(
|
||||
"_FakeTrackController",
|
||||
(),
|
||||
{
|
||||
"addTrack": staticmethod(lambda *_args, **_kwargs: None),
|
||||
"deleteTrack": staticmethod(lambda *_args, **_kwargs: None),
|
||||
"setDispositionState": staticmethod(lambda *_args, **_kwargs: None),
|
||||
},
|
||||
)()
|
||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([], tags={})
|
||||
screen._targetMediaDescriptor = FakeMediaDescriptor([])
|
||||
screen.getPatternObjFromInput = lambda: {
|
||||
"show_id": 1,
|
||||
"pattern": r"demo_(s[0-9]+e[0-9]+)\.mkv",
|
||||
}
|
||||
screen.reloadProperties = lambda reset_draft=True: calls.append(
|
||||
("reloadProperties", reset_draft)
|
||||
)
|
||||
screen.updateMediaTags = lambda: calls.append("updateMediaTags")
|
||||
screen.updateTracks = lambda: calls.append("updateTracks")
|
||||
screen.updateDifferences = lambda: calls.append("updateDifferences")
|
||||
|
||||
screen.action_update_pattern()
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
("deleteMediaTagByKey", 9, "TITLE"),
|
||||
("reloadProperties", True),
|
||||
"updateMediaTags",
|
||||
"updateTracks",
|
||||
"updateDifferences",
|
||||
],
|
||||
calls,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
25
tests/unit/test_track_codec_identification.py
Normal file
25
tests/unit/test_track_codec_identification.py
Normal file
@@ -0,0 +1,25 @@
|
||||
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.track_codec import TrackCodec # noqa: E402
|
||||
|
||||
|
||||
class TrackCodecIdentificationTests(unittest.TestCase):
|
||||
def test_identify_modern_webm_codecs(self):
|
||||
self.assertEqual(TrackCodec.VP9, TrackCodec.identify("vp9"))
|
||||
self.assertEqual(TrackCodec.OPUS, TrackCodec.identify("opus"))
|
||||
self.assertEqual(TrackCodec.WEBVTT, TrackCodec.identify("webvtt"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
61
tests/unit/test_track_descriptor_probe.py
Normal file
61
tests/unit/test_track_descriptor_probe.py
Normal file
@@ -0,0 +1,61 @@
|
||||
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.attachment_format import AttachmentFormat # 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 TrackDescriptorProbeTests(unittest.TestCase):
|
||||
def test_attachment_without_codec_name_uses_font_metadata_to_identify_ttf(self):
|
||||
descriptor = TrackDescriptor.fromFfprobe(
|
||||
{
|
||||
"index": 4,
|
||||
"codec_type": "attachment",
|
||||
"disposition": {"default": 0},
|
||||
"tags": {
|
||||
"filename": "AmazonEmberTanuki-Italic.ttf",
|
||||
"mimetype": "font/ttf",
|
||||
},
|
||||
},
|
||||
subIndex=0,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(descriptor)
|
||||
self.assertEqual(TrackType.ATTACHMENT, descriptor.getType())
|
||||
self.assertEqual(AttachmentFormat.TTF, descriptor.getAttachmentFormat())
|
||||
self.assertEqual(AttachmentFormat.TTF, descriptor.getFormatDescriptor())
|
||||
self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec())
|
||||
|
||||
def test_attachment_without_codec_name_still_probes_as_unknown_when_not_font(self):
|
||||
descriptor = TrackDescriptor.fromFfprobe(
|
||||
{
|
||||
"index": 9,
|
||||
"codec_type": "attachment",
|
||||
"disposition": {"default": 0},
|
||||
"tags": {
|
||||
"filename": "cover.bin",
|
||||
"mimetype": "application/octet-stream",
|
||||
},
|
||||
},
|
||||
subIndex=0,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(descriptor)
|
||||
self.assertEqual(TrackType.ATTACHMENT, descriptor.getType())
|
||||
self.assertEqual(AttachmentFormat.UNKNOWN, descriptor.getAttachmentFormat())
|
||||
self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user