#425 Codec Enum Klasse

main
Maveno 11 months ago
parent e2b6a4bf7c
commit bc62801949

1
.gitignore vendored

@ -6,3 +6,4 @@ ansible/inventory/hawaii.yml
ansible/inventory/peppermint.yml ansible/inventory/peppermint.yml
ffx_test_report.log ffx_test_report.log
bin/conversiontest.py bin/conversiontest.py

@ -1,57 +0,0 @@
Metadata-Version: 2.1
Name: ffx
Version: 0.2.2
Summary: FFX recoding and metadata managing tool
Home-page: https://gitea.maveno.de/Javanaut/ffx
Author: Javanaut
Author-email: javanaut@maveno.de
Project-URL: Bug Tracker, https://gitea.maveno.de/Javanaut/ffx/issues
Classifier: Operating System :: OS Independent
Requires-Python: >=3.6
Description-Content-Type: text/markdown
Requires-Dist: requests
Requires-Dist: click
Requires-Dist: textual
Requires-Dist: sqlalchemy
# FFX
## Installation
per https:
```sh
pip install https://<URL>/<Releaser>/ffx.git@<Branch>
```
per git:
```sh
pip install git+ssh://<Username>@<URL>/<Releaser>/ffx.git@<Branch>
```
## Version history
### 0.1.1
Bugfixes, TMBD identify shows
### 0.1.2
Bugfixes
### 0.1.3
Subtitle file imports
### 0.2.0
Tests, Config-File
### 0.2.1
Signature, Tags cleaning, Bugfixes, Refactoring
### 0.2.2
CLI-Overrides

@ -1,121 +0,0 @@
README.md
pyproject.toml
setup.cfg
ffx.egg-info/PKG-INFO
ffx.egg-info/SOURCES.txt
ffx.egg-info/dependency_links.txt
ffx.egg-info/entry_points.txt
ffx.egg-info/requires.txt
ffx.egg-info/top_level.txt
src/ffx.py
src/ffx_tests.py
src/ffx/__init__.py
src/ffx/audio_layout.py
src/ffx/configuration_controller.py
src/ffx/constants.py
src/ffx/database.py
src/ffx/ffx_app.py
src/ffx/ffx_controller.py
src/ffx/file_properties.py
src/ffx/help_screen.py
src/ffx/helper.py
src/ffx/iso_language.py
src/ffx/media_controller.py
src/ffx/media_descriptor.py
src/ffx/media_details_screen.py
src/ffx/pattern_controller.py
src/ffx/pattern_delete_screen.py
src/ffx/pattern_details_screen.py
src/ffx/process.py
src/ffx/settings_screen.py
src/ffx/shifted_season_controller.py
src/ffx/shifted_season_delete_screen.py
src/ffx/shifted_season_details_screen.py
src/ffx/show_controller.py
src/ffx/show_delete_screen.py
src/ffx/show_descriptor.py
src/ffx/show_details_screen.py
src/ffx/shows_screen.py
src/ffx/tag_controller.py
src/ffx/tag_delete_screen.py
src/ffx/tag_details_screen.py
src/ffx/tmdb_controller.py
src/ffx/track_controller.py
src/ffx/track_delete_screen.py
src/ffx/track_descriptor.py
src/ffx/track_details_screen.py
src/ffx/track_disposition.py
src/ffx/track_type.py
src/ffx/video_encoder.py
src/ffx/filter/__init__.py
src/ffx/filter/filter.py
src/ffx/filter/nlmeans_filter.py
src/ffx/filter/preset_filter.py
src/ffx/filter/quality_filter.py
src/ffx/filter/scale_filter.py
src/ffx/model/__init__.py
src/ffx/model/media_tag.py
src/ffx/model/pattern.py
src/ffx/model/property.py
src/ffx/model/shifted_season.py
src/ffx/model/show.py
src/ffx/model/track.py
src/ffx/model/track_tag.py
src/ffx/model/conversions/__init__.py
src/ffx/model/conversions/conversion.py
src/ffx/model/conversions/conversion_2_3.py
src/ffx/model/conversions/conversion_3_4.py
src/ffx/test/_basename_combinator_1.py
src/ffx/test/basename_combinator.py
src/ffx/test/basename_combinator_0.py
src/ffx/test/basename_combinator_2.py
src/ffx/test/combinator.py
src/ffx/test/disposition_combinator_2.py
src/ffx/test/disposition_combinator_2_0.py
src/ffx/test/disposition_combinator_2_1.py
src/ffx/test/disposition_combinator_2_2.py
src/ffx/test/disposition_combinator_2_3 .py
src/ffx/test/disposition_combinator_3.py
src/ffx/test/disposition_combinator_3_0.py
src/ffx/test/disposition_combinator_3_1.py
src/ffx/test/disposition_combinator_3_2.py
src/ffx/test/disposition_combinator_3_3.py
src/ffx/test/disposition_combinator_3_4.py
src/ffx/test/helper.py
src/ffx/test/indicator_combinator.py
src/ffx/test/label_combinator.py
src/ffx/test/label_combinator_0.py
src/ffx/test/label_combinator_1.py
src/ffx/test/media_combinator.py
src/ffx/test/media_combinator_0.py
src/ffx/test/media_combinator_1.py
src/ffx/test/media_combinator_2.py
src/ffx/test/media_combinator_3.py
src/ffx/test/media_combinator_4.py
src/ffx/test/media_combinator_5.py
src/ffx/test/media_combinator_6.py
src/ffx/test/media_combinator_7.py
src/ffx/test/media_tag_combinator.py
src/ffx/test/media_tag_combinator_0.py
src/ffx/test/media_tag_combinator_1.py
src/ffx/test/media_tag_combinator_2.py
src/ffx/test/permutation_combinator_2.py
src/ffx/test/permutation_combinator_3.py
src/ffx/test/release_combinator.py
src/ffx/test/scenario.py
src/ffx/test/scenario_1.py
src/ffx/test/scenario_2.py
src/ffx/test/scenario_4.py
src/ffx/test/show_combinator.py
src/ffx/test/title_combinator.py
src/ffx/test/track_tag_combinator_2.py
src/ffx/test/track_tag_combinator_2_0.py
src/ffx/test/track_tag_combinator_2_1.py
src/ffx/test/track_tag_combinator_2_2.py
src/ffx/test/track_tag_combinator_2_3.py
src/ffx/test/track_tag_combinator_3.py
src/ffx/test/track_tag_combinator_3_0.py
src/ffx/test/track_tag_combinator_3_1.py
src/ffx/test/track_tag_combinator_3_2.py
src/ffx/test/track_tag_combinator_3_3.py
src/ffx/test/track_tag_combinator_3_4.py

@ -1,2 +0,0 @@
[console_scripts]
osgw = ffx:ffx

@ -1,4 +0,0 @@
requests
click
textual
sqlalchemy

@ -607,7 +607,6 @@ Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python Classifier: Programming Language :: Python
Description-Content-Type: text/markdown Description-Content-Type: text/markdown
License-File: LICENSE.md License-File: LICENSE.md
Requires-Dist: rich
Requires-Dist: requests Requires-Dist: requests
Requires-Dist: click Requires-Dist: click
Requires-Dist: textual Requires-Dist: textual

@ -3,6 +3,7 @@ README.md
pyproject.toml pyproject.toml
src/ffx/__init__.py src/ffx/__init__.py
src/ffx/audio_layout.py src/ffx/audio_layout.py
src/ffx/codec.py
src/ffx/configuration_controller.py src/ffx/configuration_controller.py
src/ffx/constants.py src/ffx/constants.py
src/ffx/database.py src/ffx/database.py

@ -1,4 +1,3 @@
rich
requests requests
click click
textual textual

@ -54,7 +54,6 @@ def databaseContext(databasePath: str = ''):
def ensureDatabaseVersion(databaseContext): def ensureDatabaseVersion(databaseContext):
currentDatabaseVersion = getDatabaseVersion(databaseContext) currentDatabaseVersion = getDatabaseVersion(databaseContext)
click.echo(f"current database version: {currentDatabaseVersion}")
if currentDatabaseVersion: if currentDatabaseVersion:
if currentDatabaseVersion != DATABASE_VERSION: if currentDatabaseVersion != DATABASE_VERSION:
raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})") raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})")

@ -19,6 +19,7 @@ from ffx.show_descriptor import ShowDescriptor
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.video_encoder import VideoEncoder from ffx.video_encoder import VideoEncoder
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.track_codec import TrackCodec
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.helper import filterFilename from ffx.helper import filterFilename
@ -105,27 +106,14 @@ def inspect(ctx, filename):
app = FfxApp(ctx.obj) app = FfxApp(ctx.obj)
app.run() app.run()
#TODO: TrackCodec Klasse
CODEC_LOOKUP_TABLE = {
'h264': {'format': 'h264', 'extension': 'h264'},
'aac': { 'extension': 'aac'},
'ac3': {'format': 'ac3', 'extension': 'ac3'},
'ass': {'format': 'ass', 'extension': 'ass'},
'hdmv_pgs_subtitle': {'format': 'sup', 'extension': 'sup'}
}
def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, targetDirectory = ''): def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, targetDirectory = ''):
trackCodec = trackDescriptor.getCodec()
if not trackCodec in CODEC_LOOKUP_TABLE.keys():
return []
# executable and input file # executable and input file
commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath] commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath]
trackType = trackDescriptor.getType() trackType = trackDescriptor.getType()
targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix
# mapping # mapping
@ -134,14 +122,15 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
'-c', '-c',
'copy'] 'copy']
# TODO #425: Codec Enum trackCodec = trackDescriptor.getCodec()
# output format # output format
if 'format' in CODEC_LOOKUP_TABLE[trackCodec].keys(): codecFormat = trackCodec.format()
commandTokens += ['-f', CODEC_LOOKUP_TABLE[trackCodec]['format']] if codecFormat is not None:
commandTokens += ['-f', codecFormat]
# TODO #425: Codec enum
# output filename # output filename
commandTokens += [f"{targetPathBase}.{CODEC_LOOKUP_TABLE[trackCodec]['extension']}"] commandTokens += [f"{targetPathBase}.{trackCodec.extension()}"]
return commandTokens return commandTokens
@ -204,7 +193,7 @@ def unmux(ctx,
if not ctx.obj['dry_run']: if not ctx.obj['dry_run']:
#TODO #425: Codec Enum #TODO #425: Codec Enum
ctx.obj['logger'].info(f"Unmuxing stream {trackDescriptor.getIndex()} into file {targetPrefix}.{CODEC_LOOKUP_TABLE[trackDescriptor.getCodec()]['extension']}") ctx.obj['logger'].info(f"Unmuxing stream {trackDescriptor.getIndex()} into file {targetPrefix}.{trackDescriptor.getCodec().extension()}")
ctx.obj['logger'].debug(f"Executing unmuxing sequence") ctx.obj['logger'].debug(f"Executing unmuxing sequence")
@ -212,7 +201,7 @@ def unmux(ctx,
if rc: if rc:
ctx.obj['logger'].error(f"Unmuxing of stream {trackDescriptor.getIndex()} failed with error ({rc}) {err}") ctx.obj['logger'].error(f"Unmuxing of stream {trackDescriptor.getIndex()} failed with error ({rc}) {err}")
else: else:
ctx.obj['logger'].warning(f"Skipping stream with unknown codec {trackDescriptor.getCodec()}") ctx.obj['logger'].warning(f"Skipping stream with unknown codec")
except Exception as ex: except Exception as ex:
ctx.obj['logger'].warning(f"Skipping File {sourcePath} ({ex})") ctx.obj['logger'].warning(f"Skipping File {sourcePath} ({ex})")

@ -7,6 +7,7 @@ from ffx.track_type import TrackType
from ffx.video_encoder import VideoEncoder from ffx.video_encoder import VideoEncoder
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.track_codec import TrackCodec
from ffx.constants import DEFAULT_CROP_START, DEFAULT_CROP_LENGTH from ffx.constants import DEFAULT_CROP_START, DEFAULT_CROP_LENGTH
@ -163,7 +164,7 @@ class FfxController():
#HINT: No dispositions for pgs subtitle tracks that have no external file source #HINT: No dispositions for pgs subtitle tracks that have no external file source
if (td.getExternalSourceFilePath() if (td.getExternalSourceFilePath()
or td.getCodec() != TrackDescriptor.CODEC_PGS): or td.getCodec() != TrackCodec.PGS):
subIndex = td.getSubIndex() subIndex = td.getSubIndex()
streamIndicator = td.getType().indicator() streamIndicator = td.getType().indicator()

@ -6,6 +6,7 @@ from ffx.track_type import TrackType
from ffx.iso_language import IsoLanguage from ffx.iso_language import IsoLanguage
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.track_codec import TrackCodec
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
@ -446,14 +447,14 @@ class MediaDescriptor:
else: else:
if td.getCodec() != TrackDescriptor.CODEC_PGS: if td.getCodec() != TrackCodec.PGS:
inputMappingTokens += [ inputMappingTokens += [
"-map", "-map",
f"0:{trackType.indicator()}:{stdsi}", f"0:{trackType.indicator()}:{stdsi}",
] ]
else: else:
if td.getCodec() != TrackDescriptor.CODEC_PGS: if td.getCodec() != TrackCodec.PGS:
inputMappingTokens += ["-map", f"0:{stdi}"] inputMappingTokens += ["-map", f"0:{stdi}"]
return inputMappingTokens return inputMappingTokens

@ -308,7 +308,7 @@ class MediaDetailsScreen(Screen):
row = (td.getIndex(), row = (td.getIndex(),
trackType.label(), trackType.label(),
typeCounter[trackType], typeCounter[trackType],
td.getCodec(), td.getCodec().label(),
audioLayout.label() if trackType == TrackType.AUDIO audioLayout.label() if trackType == TrackType.AUDIO
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ', and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
td.getLanguage().label(), td.getLanguage().label(),

@ -12,8 +12,8 @@ from ffx.track_disposition import TrackDisposition
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
from ffx.audio_layout import AudioLayout from ffx.audio_layout import AudioLayout
from ffx.track_codec import TrackCodec
import click
class Track(Base): class Track(Base):
""" """
@ -152,8 +152,8 @@ class Track(Base):
def getType(self): def getType(self):
return TrackType.fromIndex(self.track_type) return TrackType.fromIndex(self.track_type)
def getCodec(self): def getCodec(self) -> TrackCodec:
return self.codec_name return TrackCodec.identify(self.codec_name)
def getIndex(self): def getIndex(self):
return int(self.index) if self.index is not None else -1 return int(self.index) if self.index is not None else -1
@ -206,7 +206,7 @@ class Track(Base):
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType() kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType()
kwargs[TrackDescriptor.CODEC_NAME_KEY] = self.getCodec() kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec()
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = self.getDispositionSet() kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = self.getDispositionSet()
kwargs[TrackDescriptor.TAGS_KEY] = self.getTags() kwargs[TrackDescriptor.TAGS_KEY] = self.getTags()

@ -158,7 +158,7 @@ class PatternDetailsScreen(Screen):
row = (td.getIndex(), row = (td.getIndex(),
trackType.label(), trackType.label(),
typeCounter[trackType], typeCounter[trackType],
td.getCodec(), td.getCodec().label(),
audioLayout.label() if trackType == TrackType.AUDIO audioLayout.label() if trackType == TrackType.AUDIO
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ', and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ', trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ',

@ -0,0 +1,39 @@
from enum import Enum
class TrackCodec(Enum):
H265 = {'identifier': 'hevc', 'format': 'h265', 'extension': 'h265' ,'label': 'H.265'}
H264 = {'identifier': 'h264', 'format': 'h264', 'extension': 'h264' ,'label': 'H.264'}
AAC = {'identifier': 'aac', 'format': None, 'extension': 'aac' , 'label': 'AAC'}
AC3 = {'identifier': 'ac3', 'format': 'ac3', 'extension': 'ac3' , 'label': 'AC3'}
DTS = {'identifier': 'dts', 'format': 'dts', 'extension': 'dts' , 'label': 'DTS'}
ASS = {'identifier': 'ass', 'format': 'ass', 'extension': 'ass' , 'label': 'ASS'}
PGS = {'identifier': 'hdmv_pgs_subtitle', 'format': 'sup', 'extension': 'sup' , 'label': 'PGS'}
UNKNOWN = {'identifier': 'unknown', 'format': None, 'extension': None, 'label': 'UNKNOWN'}
def identifier(self):
"""Returns the codec identifier"""
return str(self.value['identifier'])
def label(self):
"""Returns the codec as string"""
return str(self.value['label'])
def format(self):
"""Returns the codec as single letter"""
return str(self.value['format'])
def extension(self):
"""Returns the corresponding extension"""
return int(self.value['extension'])
@staticmethod
def identify(identifier: str):
clist = [c for c in TrackCodec if c.value['identifier'] == str(identifier)]
if clist:
return clist[0]
else:
return TrackCodec.UNKNOWN

@ -29,7 +29,7 @@ class TrackController():
s = self.Session() s = self.Session()
track = Track(pattern_id = patId, track = Track(pattern_id = patId,
track_type = int(trackDescriptor.getType().index()), track_type = int(trackDescriptor.getType().index()),
codec_name = str(trackDescriptor.getCodec()), codec_name = str(trackDescriptor.getCodec().label()),
index = int(trackDescriptor.getIndex()), index = int(trackDescriptor.getIndex()),
source_index = int(trackDescriptor.getSourceIndex()), source_index = int(trackDescriptor.getSourceIndex()),
disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())), disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())),
@ -68,7 +68,7 @@ class TrackController():
track.index = int(trackDescriptor.getIndex()) track.index = int(trackDescriptor.getIndex())
track.track_type = int(trackDescriptor.getType().index()) track.track_type = int(trackDescriptor.getType().index())
track.codec_name = str(trackDescriptor.getCodec()) track.codec_name = str(trackDescriptor.getCodec().identifier())
track.audio_layout = int(trackDescriptor.getAudioLayout().index()) track.audio_layout = int(trackDescriptor.getAudioLayout().index())
track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())) track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet()))

@ -5,6 +5,7 @@ from .iso_language import IsoLanguage
from .track_type import TrackType from .track_type import TrackType
from .audio_layout import AudioLayout from .audio_layout import AudioLayout
from .track_disposition import TrackDisposition from .track_disposition import TrackDisposition
from .track_codec import TrackCodec
from .helper import dictDiff, setDiff from .helper import dictDiff, setDiff
@ -24,14 +25,14 @@ class TrackDescriptor:
TAGS_KEY = "tags" TAGS_KEY = "tags"
TRACK_TYPE_KEY = "track_type" TRACK_TYPE_KEY = "track_type"
CODEC_NAME_KEY = "codec_name" CODEC_KEY = "codec_name"
AUDIO_LAYOUT_KEY = "audio_layout" AUDIO_LAYOUT_KEY = "audio_layout"
FFPROBE_INDEX_KEY = "index" FFPROBE_INDEX_KEY = "index"
FFPROBE_DISPOSITION_KEY = "disposition" FFPROBE_DISPOSITION_KEY = "disposition"
FFPROBE_TAGS_KEY = "tags" FFPROBE_TAGS_KEY = "tags"
FFPROBE_CODEC_TYPE_KEY = "codec_type" FFPROBE_CODEC_TYPE_KEY = "codec_type"
FFPROBE_CODEC_NAME_KEY = "codec_name" FFPROBE_CODEC_KEY = "codec_name"
CODEC_PGS = 'hdmv_pgs_subtitle' CODEC_PGS = 'hdmv_pgs_subtitle'
@ -110,14 +111,14 @@ class TrackDescriptor:
else: else:
self.__trackType = TrackType.UNKNOWN self.__trackType = TrackType.UNKNOWN
if TrackDescriptor.CODEC_NAME_KEY in kwargs.keys(): if TrackDescriptor.CODEC_KEY in kwargs.keys():
if type(kwargs[TrackDescriptor.CODEC_NAME_KEY]) is not str: if type(kwargs[TrackDescriptor.CODEC_KEY]) is not TrackCodec:
raise TypeError( raise TypeError(
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_NAME_KEY} is required to be of type str" f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec"
) )
self.__codecName = kwargs[TrackDescriptor.CODEC_NAME_KEY] self.__trackCodec = kwargs[TrackDescriptor.CODEC_KEY]
else: else:
self.__codecName = '' self.__trackCodec = TrackCodec.UNKNOWN
if TrackDescriptor.TAGS_KEY in kwargs.keys(): if TrackDescriptor.TAGS_KEY in kwargs.keys():
if type(kwargs[TrackDescriptor.TAGS_KEY]) is not dict: if type(kwargs[TrackDescriptor.TAGS_KEY]) is not dict:
@ -214,7 +215,8 @@ class TrackDescriptor:
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = trackType kwargs[TrackDescriptor.TRACK_TYPE_KEY] = trackType
kwargs[TrackDescriptor.CODEC_NAME_KEY] = str(streamObj[TrackDescriptor.FFPROBE_CODEC_NAME_KEY])
kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.identify(streamObj[TrackDescriptor.FFPROBE_CODEC_KEY])
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = ( kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = (
{ {
@ -272,9 +274,9 @@ class TrackDescriptor:
def getType(self): def getType(self):
return self.__trackType return self.__trackType
def getCodec(self): def getCodec(self) -> TrackCodec:
return str(self.__codecName) return self.__trackCodec
def getLanguage(self): def getLanguage(self):
if "language" in self.__trackTags.keys(): if "language" in self.__trackTags.keys():

Loading…
Cancel
Save