Compare commits

..

No commits in common. "d27e1481c33f8d1ac375e547ebe167256b3d3506" and "83458f9f30aa9f390c440959d0908c19dd1c9ee9" have entirely different histories.

16 changed files with 132 additions and 850 deletions

3
.gitignore vendored
View file

@ -1,7 +1,4 @@
!bcao.py !bcao.py
!requirements.txt !requirements.txt
!.gitignore !.gitignore
!mypy.ini
!.run/
!.idea/
* *

8
.idea/.gitignore vendored
View file

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.mypy_cache" />
<excludeFolder url="file://$MODULE_DIR$/samples" />
<excludeFolder url="file://$MODULE_DIR$/test" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.8 (bcao)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View file

@ -1,3 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="lynne" />
</component>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
</component>
</project>

View file

@ -1,7 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="PROJECT_PROFILE" value="Default" />
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (bcao)" project-jdk-type="Python SDK" />
</project>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/bcao.iml" filepath="$PROJECT_DIR$/.idea/bcao.iml" />
</modules>
</component>
</project>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -1,328 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="BranchesTreeState">
<expand>
<path>
<item name="ROOT" type="e8cecc67:BranchNodeDescriptor" />
<item name="LOCAL_ROOT" type="e8cecc67:BranchNodeDescriptor" />
</path>
<path>
<item name="ROOT" type="e8cecc67:BranchNodeDescriptor" />
<item name="REMOTE_ROOT" type="e8cecc67:BranchNodeDescriptor" />
</path>
<path>
<item name="ROOT" type="e8cecc67:BranchNodeDescriptor" />
<item name="REMOTE_ROOT" type="e8cecc67:BranchNodeDescriptor" />
<item name="GROUP_NODE:origin" type="e8cecc67:BranchNodeDescriptor" />
</path>
</expand>
<select />
</component>
<component name="ChangeListManager">
<list default="true" id="f581197a-f26b-4fde-b746-e72c0ed1bb2a" name="Default Changelist" comment="my py dot ini">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bcao.py" beforeDir="false" afterPath="$PROJECT_DIR$/bcao.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="FlaskConsoleOptions" custom-start-script="import sys&#10;sys.path.extend([WORKING_DIR_AND_PYTHON_PATHS])&#10;from flask.cli import ScriptInfo&#10;locals().update(ScriptInfo(create_app=None).load_app().make_shell_context())&#10;print(&quot;Python %s on %s\nApp: %s [%s]\nInstance: %s&quot; % (sys.version, sys.platform, app.import_name, app.env, app.instance_path))">
<envs>
<env key="FLASK_APP" value="app" />
</envs>
<option name="myCustomStartScript" value="import sys&#10;sys.path.extend([WORKING_DIR_AND_PYTHON_PATHS])&#10;from flask.cli import ScriptInfo&#10;locals().update(ScriptInfo(create_app=None).load_app().make_shell_context())&#10;print(&quot;Python %s on %s\nApp: %s [%s]\nInstance: %s&quot; % (sys.version, sys.platform, app.import_name, app.env, app.instance_path))" />
<option name="myEnvs">
<map>
<entry key="FLASK_APP" value="app" />
</map>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="RESET_MODE" value="HARD" />
</component>
<component name="JupyterTrust" id="c9c24a84-69f9-4c1e-bffb-78383de38689" />
<component name="ProjectId" id="1iwv1rbtMpCLK7D695td98N37pr" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">
<property name="RunOnceActivity.OpenProjectViewOnStart" value="true" />
<property name="WebServerToolWindowFactoryState" value="false" />
<property name="com.intellij.ide.scratch.LRUPopupBuilder$1/New Scratch File" value="TEXT" />
<property name="last_opened_file_path" value="$PROJECT_DIR$" />
<property name="node.js.detected.package.eslint" value="true" />
<property name="node.js.detected.package.tslint" value="true" />
<property name="node.js.path.for.package.eslint" value="project" />
<property name="node.js.path.for.package.tslint" value="project" />
<property name="node.js.selected.package.eslint" value="(autodetect)" />
<property name="node.js.selected.package.tslint" value="(autodetect)" />
<property name="settings.editor.selected.configurable" value="preferences.pluginManager" />
</component>
<component name="RunManager" selected="Python.bcao (ceres)">
<configuration default="true" type="PythonConfigurationType" factoryName="Python">
<module name="bcao" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<list>
<item itemvalue="Python.bcao (io)" />
<item itemvalue="Python.bcao (ceres)" />
<item itemvalue="Python.mypy" />
</list>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="f581197a-f26b-4fde-b746-e72c0ed1bb2a" name="Default Changelist" comment="" />
<created>1602831263913</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1602831263913</updated>
<workItem from="1602831267794" duration="18279000" />
<workItem from="1602850978698" duration="7902000" />
<workItem from="1602908398925" duration="34104000" />
<workItem from="1603714609431" duration="5637000" />
<workItem from="1603720261881" duration="6236000" />
</task>
<task id="LOCAL-00001" summary="mp3 support! more helpful interface! better code! yahoo!!">
<created>1602927759343</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1602927759343</updated>
</task>
<task id="LOCAL-00002" summary="fairly major restructuring that should make future format support a lot easier, support for songs with partially or fully incomplete metadata">
<created>1602942890966</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1602942890966</updated>
</task>
<task id="LOCAL-00003" summary="mypy integration">
<created>1603715728224</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1603715728224</updated>
</task>
<task id="LOCAL-00004" summary="put everything in main(), zero mypy issues">
<created>1603716061845</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1603716061845</updated>
</task>
<task id="LOCAL-00005" summary="code cleanup">
<created>1603716273616</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1603716273616</updated>
</task>
<task id="LOCAL-00006" summary="mypy strict support!">
<created>1603717518466</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1603717518466</updated>
</task>
<task id="LOCAL-00007" summary="wav support!">
<created>1603719245013</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1603719245013</updated>
</task>
<task id="LOCAL-00008" summary="my py dot ini">
<created>1603719576507</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1603719576507</updated>
</task>
<task id="LOCAL-00009" summary="added project files, aiff support">
<created>1603720506558</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1603720506558</updated>
</task>
<task id="LOCAL-00010" summary="turns out i didn't need to do anything to add alac support - they work the same as aac m4a files do. although i did find and fix a bug in the m4a handling so that's good at least 0uo">
<created>1603723480563</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1603723480563</updated>
</task>
<task id="LOCAL-00011" summary="remove unneeded file extension">
<created>1603814270091</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1603814270092</updated>
</task>
<option name="localTasksCounter" value="12" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
<option name="oldMeFiltersMigrated" value="true" />
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="mp3 support! more helpful interface! better code! yahoo!!" />
<MESSAGE value="fairly major restructuring that should make future format support a lot easier, support for songs with partially or fully incomplete metadata" />
<MESSAGE value="mypy integration" />
<MESSAGE value="put everything in main(), zero mypy issues" />
<MESSAGE value="code cleanup" />
<MESSAGE value="mypy strict support!" />
<MESSAGE value="wav support!" />
<MESSAGE value="my py dot ini" />
<MESSAGE value="added project files, aiff support" />
<MESSAGE value="turns out i didn't need to do anything to add alac support - they work the same as aac m4a files do. although i did find and fix a bug in the m4a handling so that's good at least 0uo" />
<MESSAGE value="remove unneeded file extension" />
<option name="LAST_COMMIT_MESSAGE" value="remove unneeded file extension" />
</component>
<component name="WindowStateProjectService">
<state x="555" y="188" width="800" height="672" key="#Deployment" timestamp="1602927147820">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="555" y="188" width="800" height="672" key="#Deployment/0.0.1920.1055@0.0.1920.1055" timestamp="1602927147820" />
<state x="811" y="199" width="732" height="632" key="#Inspections" timestamp="1602832260834">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="811" y="199" width="732" height="632" key="#Inspections/0.0.1920.1055@0.0.1920.1055" timestamp="1602832260834" />
<state x="697" y="368" width="516" height="313" key="#Notifications" timestamp="1602831592098">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="697" y="368" width="516" height="313" key="#Notifications/0.0.1920.1055@0.0.1920.1055" timestamp="1602831592098" />
<state x="555" y="170" width="800" height="706" key="#Plugins" timestamp="1603714662919">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state x="555" y="170" width="800" height="706" key="#Plugins/0.0.1920.1054@0.0.1920.1054" timestamp="1603714662919" />
<state x="719" y="227" key="#Python" timestamp="1603720399983">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state x="719" y="227" key="#Python/0.0.1920.1054@0.0.1920.1054" timestamp="1603720399983" />
<state x="418" y="185" width="1084" height="709" key="#com.intellij.execution.impl.EditConfigurationsDialog" timestamp="1603723412351">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state x="418" y="185" width="1084" height="709" key="#com.intellij.execution.impl.EditConfigurationsDialog/0.0.1920.1054@0.0.1920.1054" timestamp="1603723412351" />
<state x="418" y="185" key="#com.intellij.execution.impl.EditConfigurationsDialog/0.0.1920.1055@0.0.1920.1055" timestamp="1602931259631" />
<state x="888" y="193" width="424" height="721" key="#com.intellij.ide.macro.MacrosDialog" timestamp="1602931237546">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="888" y="193" width="424" height="721" key="#com.intellij.ide.macro.MacrosDialog/0.0.1920.1055@0.0.1920.1055" timestamp="1602931237546" />
<state x="707" y="363" width="797" height="527" key="#com.intellij.tools.ToolEditorDialog" timestamp="1602908296483">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="707" y="363" width="797" height="527" key="#com.intellij.tools.ToolEditorDialog/0.0.1920.1055@0.0.1920.1055" timestamp="1602908296483" />
<state x="549" y="98" width="1059" height="853" key="CommitChangelistDialog2" timestamp="1602927110754">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="549" y="98" width="1059" height="853" key="CommitChangelistDialog2/0.0.1920.1055@0.0.1920.1055" timestamp="1602927110754" />
<state x="100" y="99" width="1720" height="856" key="DiffContextDialog" timestamp="1603814233028">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state x="100" y="99" width="1720" height="856" key="DiffContextDialog/0.0.1920.1054@0.0.1920.1054" timestamp="1603814233028" />
<state x="100" y="99" width="1720" height="856" key="DiffContextDialog/0.0.1920.1055@0.0.1920.1055" timestamp="1602915909590" />
<state x="743" y="285" width="424" height="479" key="FileChooserDialogImpl" timestamp="1602850965686">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="743" y="285" width="424" height="479" key="FileChooserDialogImpl/0.0.1920.1055@0.0.1920.1055" timestamp="1602850965686" />
<state width="1878" height="281" key="GridCell.Tab.0.bottom" timestamp="1603723362538">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state width="1878" height="281" key="GridCell.Tab.0.bottom/0.0.1920.1054@0.0.1920.1054" timestamp="1603723362538" />
<state width="1878" height="282" key="GridCell.Tab.0.bottom/0.0.1920.1055@0.0.1920.1055" timestamp="1602942953878" />
<state width="1878" height="281" key="GridCell.Tab.0.center" timestamp="1603723362538">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state width="1878" height="281" key="GridCell.Tab.0.center/0.0.1920.1054@0.0.1920.1054" timestamp="1603723362538" />
<state width="1878" height="282" key="GridCell.Tab.0.center/0.0.1920.1055@0.0.1920.1055" timestamp="1602942953877" />
<state width="1878" height="281" key="GridCell.Tab.0.left" timestamp="1603723362537">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state width="1878" height="281" key="GridCell.Tab.0.left/0.0.1920.1054@0.0.1920.1054" timestamp="1603723362537" />
<state width="1878" height="282" key="GridCell.Tab.0.left/0.0.1920.1055@0.0.1920.1055" timestamp="1602942953877" />
<state width="1878" height="281" key="GridCell.Tab.0.right" timestamp="1603723362538">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state width="1878" height="281" key="GridCell.Tab.0.right/0.0.1920.1054@0.0.1920.1054" timestamp="1603723362538" />
<state width="1878" height="282" key="GridCell.Tab.0.right/0.0.1920.1055@0.0.1920.1055" timestamp="1602942953877" />
<state width="1878" height="347" key="GridCell.Tab.1.bottom" timestamp="1603723347647">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state width="1878" height="347" key="GridCell.Tab.1.bottom/0.0.1920.1054@0.0.1920.1054" timestamp="1603723347647" />
<state width="1878" height="347" key="GridCell.Tab.1.center" timestamp="1603723347647">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state width="1878" height="347" key="GridCell.Tab.1.center/0.0.1920.1054@0.0.1920.1054" timestamp="1603723347647" />
<state width="1878" height="347" key="GridCell.Tab.1.left" timestamp="1603723347647">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state width="1878" height="347" key="GridCell.Tab.1.left/0.0.1920.1054@0.0.1920.1054" timestamp="1603723347647" />
<state width="1878" height="347" key="GridCell.Tab.1.right" timestamp="1603723347647">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state width="1878" height="347" key="GridCell.Tab.1.right/0.0.1920.1054@0.0.1920.1054" timestamp="1603723347647" />
<state x="182" y="88" width="1536" height="869" key="MergeDialog" timestamp="1602851077617">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="182" y="88" width="1536" height="869" key="MergeDialog/0.0.1920.1055@0.0.1920.1055" timestamp="1602851077617" />
<state x="596" y="306" width="718" height="437" key="MultipleFileMergeDialog" timestamp="1602851077619">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="596" y="306" width="718" height="437" key="MultipleFileMergeDialog/0.0.1920.1055@0.0.1920.1055" timestamp="1602851077619" />
<state x="334" y="44" width="1315" height="941" key="SettingsEditor" timestamp="1603776383135">
<screen x="0" y="0" width="1920" height="1054" />
</state>
<state x="334" y="44" key="SettingsEditor/0.0.1920.1054@0.0.1920.1054" timestamp="1603776383135" />
<state x="334" y="44" width="1315" height="941" key="SettingsEditor/0.0.1920.1055@0.0.1920.1055" timestamp="1602927410438" />
<state x="100" y="99" width="1720" height="856" key="com.intellij.history.integration.ui.views.FileHistoryDialog" timestamp="1602937117208">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="100" y="99" width="1720" height="856" key="com.intellij.history.integration.ui.views.FileHistoryDialog/0.0.1920.1055@0.0.1920.1055" timestamp="1602937117208" />
<state x="623" y="232" width="672" height="678" key="search.everywhere.popup" timestamp="1602936893566">
<screen x="0" y="0" width="1920" height="1055" />
</state>
<state x="623" y="232" width="672" height="678" key="search.everywhere.popup/0.0.1920.1055@0.0.1920.1055" timestamp="1602936893566" />
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/bcao$mypy.coverage" NAME="mypy Coverage Results" MODIFIED="1603717428705" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="true" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/bcao$bcao.coverage" NAME="bcao Coverage Results" MODIFIED="1603719196915" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="true" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/bcao$bcao__ceres_.coverage" NAME="bcao (ceres) Coverage Results" MODIFIED="1603723362525" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="true" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
</component>
</project>

View file

@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="bcao (ceres)" type="PythonConfigurationType" factoryName="Python">
<module name="bcao" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="$PROJECT_DIR$/venv/bin/python" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/bcao.py" />
<option name="PARAMETERS" value="&quot;A Cerulean State - As if I remembered something.zip&quot; -d &quot;/tmp/out/&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View file

@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="bcao (io)" type="PythonConfigurationType" factoryName="Python">
<module name="bcao" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="$PROJECT_DIR$/venv/bin/python" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/bcao.py" />
<option name="PARAMETERS" value="&quot;Braxton Burks - Time &amp; Space.zip&quot; -d &quot;$USER_HOME$/Documents&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View file

@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="mypy" type="PythonConfigurationType" factoryName="Python">
<module name="bcao" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/venv/bin/mypy" />
<option name="PARAMETERS" value="bcao.py" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

435
bcao.py
View file

@ -6,245 +6,69 @@
# output: it organises it, adds cover art, puts it in the right place... # output: it organises it, adds cover art, puts it in the right place...
import argparse import argparse
import io import base64
import os import os
import re import re
import sys import subprocess
import tempfile import tempfile
import shutil import shutil
from os import path from zipfile import ZipFile, BadZipFile
from base64 import b64encode
from zipfile import ZipFile
from pathlib import Path
from typing import Optional, Union, List, Dict
# pycharm tells me some of these classes shouldn't be imported because they're not declared in __all__.
# however, the mutagen docs show example code where someone creates a mutagen.flac.Picture by referring to it as
# Picture(), implying that they had imported mutagen.flac.Picture, and therefore i'm right and the computer is WRONG
# https://mutagen.readthedocs.io/en/latest/api/flac.html#mutagen.Picture.data
import mutagen import mutagen
# noinspection PyProtectedMember from mutagen.flac import Picture
from mutagen.flac import Picture, FLAC from mutagen import id3
from mutagen.oggvorbis import OggVorbis
from mutagen.mp3 import MP3
from mutagen.mp4 import MP4, MP4Cover
# noinspection PyProtectedMember
from mutagen.id3 import APIC, PictureType, Frame, TRCK, TPE1, TIT2, TALB, TPE2
from PIL import Image from PIL import Image
fully_supported: List[str] = ["ogg", "flac", "mp3", "m4a", "wav", "aiff"] def log(message: str, importance: int = 0):
MutagenFile = Union[MP3, FLAC, OggVorbis, mutagen.FileType]
MutagenTags = Union[mutagen.id3.ID3Tags, mutagen.mp4.Tags, mutagen.oggvorbis.OggVCommentDict]
args: argparse.Namespace
tmp_dir: tempfile.TemporaryDirectory # type: ignore
format_lookup: Dict[str, str] = {
"mp3": "id3",
"m4a": "m4a",
"ogg": "vorbis",
"flac": "vorbis",
"wav": "id3",
"aiff": "id3"
}
class SongInfo:
tag_lookup: Dict[str, Dict[str, str]] = {
"track": {"id3": "TRCK", "m4a": "trkn", "vorbis": "tracknumber"},
"artist": {"id3": "TPE1", "m4a": "©ART", "vorbis": "artist"},
"title": {"id3": "TIT2", "m4a": "©nam", "vorbis": "title"},
"album": {"id3": "TALB", "m4a": "©alb", "vorbis": "album"},
"album_artist": {"id3": "TPE2", "m4a": "aART", "vorbis": "albumartist"}
}
def __init__(self, file_name: Path):
self.m_file: MutagenFile = mutagen.File(file_name)
self.m_tags: MutagenTags = self.m_file.tags
self.file_name = str(file_name.name)
self.format = path.splitext(file_name)[1][1:]
self.fallback = False
if self.format not in format_lookup:
raise ValueError(f"Unsupported file type: {self.format}")
fallbacks = re.match(
r"^(?P<artist>.+) - (?P<album>.+) - (?P<track>\d{2,}) (?P<title>.+)\.(?:ogg|flac|aiff|wav|mp3|m4a)$",
self.file_name
)
if fallbacks is None:
die("Couldn't determine fallback tags!")
return # needed for mypy
# set default values for the tags, in case the file is missing any (or all!) of them
self.tags: Dict[str, str] = {
"track": str(int(fallbacks.group("track"))), # convert to int and str again to turn e.g. "01" into "1"
"artist": fallbacks.group("artist"),
"title": fallbacks.group("title"),
"album": fallbacks.group("album"),
"album_artist": fallbacks.group("artist")
}
# set list_tags to the default tags in list form
# i.e. for every tag, set list_tags[x] = [tags[x]]
self.list_tags: Dict[str, List[str]] = dict((x[0], [x[1]]) for x in self.tags.items())
if self.m_tags is None:
# file has no tags
# generate empty tags
self.m_file.add_tags()
self.m_tags = self.m_file.tags
self.fallback = True
# write fallback tags to file
for standard_name, tag_set in self.tag_lookup.items():
tag = tag_set[format_lookup[self.format]]
self.m_tags[tag] = self.new_id3_tag(standard_name, self.tags[standard_name])
self.m_file.save()
else:
for standard_name, tag_set in self.tag_lookup.items():
tag = tag_set[format_lookup[self.format]]
if tag not in self.m_tags:
print(f"{tag} not in self.m_tags")
self.fallback = True
continue
value_list = self.m_tags[tag]
if self.format == "m4a" and standard_name == "track":
# every tag in the MP4 file (from what i can tell) is a list
# this includes the track number tag, which is a tuple of ints in a list.
# because every other format is either a non-list, or a list of non-lists, we need to account for this case
# (a list of lists of non-lists) specially, by turning it into a list of non-lists.
value_list = value_list[0]
if not isinstance(value_list, (list, tuple)):
value_list = [value_list]
# convert the list of strings/ID3 frames/ints/whatevers to sanitised strings
value_list = [sanitise(str(val)) for val in value_list]
self.tags[standard_name] = value_list[0]
self.list_tags[standard_name] = value_list
@staticmethod
def new_id3_tag(tag: str, value: str) -> Frame:
if tag == "track":
return TRCK(encoding=3, text=value)
elif tag == "artist":
return TPE1(encoding=3, text=value)
elif tag == "title":
return TIT2(encoding=3, text=value)
elif tag == "album":
return TALB(encoding=3, text=value)
elif tag == "album_artist":
return TPE2(encoding=3, text=value)
else:
raise ValueError(f"Unknown tag type {tag}!")
def get_target_name(self, zeroes: int) -> str:
return f"{self.tags['track'].zfill(zeroes)} {self.tags['title']}.{self.format}"
def has_cover(self) -> bool:
if self.format == "flac":
# needs to be handled separately from ogg, as it doesn't use the vorbis tags for cover art for whatever reason
return len(self.m_file.pictures) != 0
if format_lookup[self.format] == "vorbis":
return "metadata_block_picture" in self.m_tags and len(self.m_tags["metadata_block_picture"]) != 0
if format_lookup[self.format] == "id3":
apics: List[APIC] = self.m_tags.getall("APIC")
for apic in apics:
if apic.type == PictureType.COVER_FRONT:
return True
return False
if format_lookup[self.format] == "m4a":
return 'covr' in self.m_tags and len(self.m_tags['covr']) != 0
raise NotImplementedError("Song format not yet implemented.")
def set_cover(self, to_embed: Union[Picture, APIC, MP4Cover]) -> None:
# embed cover art
if self.format == "flac":
self.m_file.clear_pictures()
self.m_file.add_picture(to_embed)
elif format_lookup[self.format] == "vorbis":
self.m_tags["metadata_block_picture"] = [b64encode(to_embed.write()).decode("ascii")]
elif format_lookup[self.format] == "id3":
self.m_tags.add(to_embed)
elif format_lookup[self.format] == "m4a":
self.m_tags['covr'] = [to_embed]
self.m_file.save()
def __getitem__(self, item: str) -> str:
return self.tags[item]
def log(message: str, importance: int = 0) -> None:
if not args.quiet or importance > 0: if not args.quiet or importance > 0:
print(message) print(message)
def die(message: str, code: int = 1) -> None: def die(message: str, code: int = 1):
print(message) print(message)
if tmp_dir is not None: exit(code)
tmp_dir.cleanup()
sys.exit(code) def get_tag(m: mutagen.FileType, tag: str):
if tag == "title":
return sanitise(m['title'][0])
elif tag == "track":
return int(m['tracknumber'][0])
elif tag == "album":
return sanitise(m['album'][0])
else:
# may as well try
return sanitise(m[tag])
def sanitise(in_str: str) -> str: def sanitise(input: str):
if args.sanitise: if args.sanitise:
return re.sub(r"[?\\/:|*\"<>]", "_", in_str) return re.sub(r"[?\\/:|*\"<>]", "_", input)
return in_str else:
return input
parser = argparse.ArgumentParser(description="Extracts the given zip file downloaded from Bandcamp and organises it.")
def main() -> None: parser.add_argument('zip', help='The zip file to use')
global args, tmp_dir parser.add_argument('-d', '--destination', dest='destination', default='/home/lynne/Music/Music/',
help="The directory to organise the music into. Default: /home/lynne/Music/Music/")
# noinspection PyTypeChecker parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
parser = argparse.ArgumentParser(usage='%(prog)s zip [options]',
formatter_class=argparse.RawTextHelpFormatter,
description="Extracts the given zip file downloaded from Bandcamp and organises it.",
epilog=f"Cover art can only be embedded in files of the following types: {', '.join(fully_supported).upper()}.\n"
"If the song is in any other format, %(prog)s will behave as though you passed '-c n', "
"but will otherwise work normally.\nIf the song files contain no metadata, %(prog)s will attempt "
"to parse the song's filenames to retrieve the artist, album, title, and track number.")
parser.add_argument('zip', help='The zip file to use.')
parser.add_argument('-c', '--add-cover-images', dest='process_cover', default='w', choices=['n', 'a', 'w'],
help="When to embed cover art into songs.\nOptions: [n]ever, [a]lways, [w]hen necessary.\nDefault: %(default)s")
parser.add_argument('-d', '--destination', dest='destination', default='/home/lynne/Music/Music/',
help="The directory to organise the music into.\nDefault: %(default)s")
parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
help='Disable non-error output and assume default artist name.') help='Disable non-error output and assume default artist name.')
parser.add_argument('-u', '--unsanitised', dest='sanitise', action='store_false', parser.add_argument('-u', '--unsanitised', dest='sanitise', action='store_false',
help="Don't replace NTFS-unsafe characters with underscores. Not recommended.") help="Don't replace NTFS-unsafe characters with underscores. Not recommended.")
parser.add_argument('-t', '--threshold', dest='threshold', nargs=1, default=300, parser.add_argument('-t', '--threshold', dest='threshold', nargs=1, default=300,
help="Maximum acceptable file size for cover art, in kilobytes.\nDefault: %(default)s") help="Maximum acceptable cover art file size in kilobytes. Default: 300")
args = parser.parse_args() args = parser.parse_args()
# convert args.threshold to bytes
args.threshold *= 1024
if not path.exists(args.zip): if not os.path.exists(args.zip):
die(f"Couldn't find {args.zip}.", 2) die(f"Couldn't find {args.zip}.", 2)
log("Extracting...") log("Extracting...")
tmp_dir = tempfile.TemporaryDirectory() tmp = tempfile.TemporaryDirectory()
tmp: str = tmp_dir.name cover = None
cover: Optional[str] = None song_names = []
song_names: List[str] = []
with ZipFile(args.zip, 'r') as zip_file: with ZipFile(args.zip, 'r') as zip_file:
for file in zip_file.namelist(): for file in zip_file.namelist():
if re.match(r"^(.+ - ){2}\d{2,} .+\.(ogg|flac|aiff|wav|mp3|m4a)$", file): if re.match(r"^(.+ - ){2}\d{2,} .+\.(ogg|flac|alac|aiff|wav|mp3|opus|m4a|aac|oga)$", file):
# bandcamp zips contains songs with names formatted like "Album - Artist - 01 Song.mp3" # bandcamp zips contains songs with names formatted like "Album - Artist - 01 Song.mp3"
# for example, "King Crimson - In the Wake of Poseidon - 02 Pictures of a City.ogg" # for example, "King Crimson - In the Wake of Poseidon - 02 Pictures of a City.ogg"
# this regex should match only on those, and cut out (hopefully) all of the bonus material stuff, which shouldn't # this regex should match only on those, and cut out (hopefully) all of the bonus material stuff, which shouldn't
@ -256,159 +80,88 @@ def main() -> None:
cover = file cover = file
zip_file.extract(file, tmp) zip_file.extract(file, tmp)
# save the format of the songs (ogg, mp3, etc) # save the format of the songs (ogg, mp3, etc)
# we'll need this to know what metadata format we should write # we'll need this to know what metadata format we should write
song_format: str = path.splitext(song_names[0])[1][1:] song_format = os.path.splitext(song_names[0])[1][1:]
if song_format not in fully_supported:
log(f"Format {song_format} is not fully supported - cover images will not be modified", 1)
args.process_cover = 'n'
if cover is None: log("Resizing album art to embed in songs...")
die("Unable to find cover image!")
return # needed for mypy
if args.process_cover != 'n': with Image.open(os.path.join(tmp, cover)) as image:
log("Resizing album art to embed in songs...") temp_cover = os.path.join(tmp, "cover-lq.jpg")
with Image.open(str(Path(tmp, cover))) as image:
temp_cover: Path = Path(tmp, "cover-lq.jpg")
if image.mode in ["RGBA", "P"]:
# remove alpha channel
image = image.convert("RGB")
image.save(temp_cover, quality=85, optimize=True) image.save(temp_cover, quality=85, optimize=True)
image_smol = image image_smol = image
while path.getsize(temp_cover) > args.threshold:
# keep shrinking the image by 90% until it's less than {args.threshold} kilobytes # keep shrinking the image by 90% until it's less than {args.threshold} kilobytes
ratio = 0.9 while os.path.getsize(temp_cover) / 1024 > args.threshold:
image_smol = image_smol.resize((round(image_smol.size[0] * 0.9), round(image_smol.size[1] * 0.9)))
if path.getsize(temp_cover) > args.threshold * 2:
# if the file size of the cover is more than double the threshold, resize the cover image size by 80% instead
ratio = 0.8
image_smol = image_smol.resize([round(n * ratio) for n in image_smol.size])
image_smol.save(temp_cover, quality=85, optimize=True) image_smol.save(temp_cover, quality=85, optimize=True)
if image_smol.size[0] == 10: if image_smol.size[0] == 10:
# something very bad has happened here
die("Failed to resize image") die("Failed to resize image")
# read the image file to get its raw data
with open(temp_cover, 'r+b') as cover_file: # read the image file to get the file's raw data
with open(temp_cover, 'r+b') as cover_file:
data = cover_file.read() data = cover_file.read()
# it's really strange that the more annoying the file's metadata is, the *less* annoying it is to create cover art with Image.open(temp_cover) as image:
# for it in mutagen. if song_format == "ogg":
# vorbis: open standard, so easy to use that mutagen supplies a bunch of "easy" wrappers around other tag formats to
# make them work more like vorbis comments.
# cover-annoy-o-meter: high. mutagen requires you to specify the width, height, colour depth, etc etc
# id3: well documented, but rather cryptic (which is more understandable, "album_artist" or "TPE2"?).
# cover-annoy-o-meter: not bad at all - at least you get a constructor this time - although it is kinda annoying
# that you have to specify the file encoding, and how you need both a type and a desc.
# m4a: scarce documentation, closed format, half reverse engineered from whatever itunes is doing, exists pretty
# much exclusively in the realm of apple stuff.
# cover-annoy-o-meter: all you need is the file data and the format type.
if format_lookup[song_format] == "vorbis":
# i hate this # i hate this
with Image.open(io.BytesIO(data)) as image: cover = Picture()
embed_cover = Picture() cover.data = data
embed_cover.data = data cover.type = mutagen.id3.PictureType.COVER_FRONT
embed_cover.type = PictureType.COVER_FRONT cover.mime = "image/jpeg"
embed_cover.mime = "image/jpeg" cover.width = image.size[0]
embed_cover.width = image.size[0] cover.height = image.size[1]
embed_cover.height = image.size[1] cover.depth = image.bits
embed_cover.depth = image.bits cover = base64.b64encode(cover.write()).decode("ascii")
else:
log(f"Format {song_format} is not fully supported - cover images will not be modified", 1)
elif format_lookup[song_format] == "id3": artists = []
# apparently APIC files get compressed on save if they are "large": album = None
# https://mutagen.readthedocs.io/en/latest/api/id3_frames.html#mutagen.id3.APIC songs = {}
# i don't know what that means (lossless text compression? automatic JPEG conversion?) and i don't know if or how zeroes = min(len(song_names), 2)
# i can disable it, which kinda sucks... for song in song_names:
# if, for example, mutagen's threshold for "large" is 200KiB, then any file over that size would be reduced to ext = os.path.splitext(song)[1:]
# below it, either by resizing or JPEG quality reduction or whatever, making the -t flag useless for values above m = mutagen.File(os.path.join(tmp, song))
# 200 when saving MP3 files. # add the song's artist to the list, if it hasn't been seen yet
# the most i can tell is that mutagen uses zlib compression in some way or another for reading ID3 tags: [artists.append(sanitise(artist)) for artist in m['artist'] if artist not in artists]
# https://github.com/quodlibet/mutagen/blob/release-1.45.1/mutagen/id3/_frames.py#L265 songs[song] = f"{str(get_tag(m, 'track')).zfill(zeroes)} {get_tag(m, 'title')}.{song_format}"
# however, it seems not to use zlib when *writing* tags, citing itunes incompatibility, in particular with APIC: album = get_tag(m, "album")
# https://github.com/quodlibet/mutagen/blob/release-1.45.1/mutagen/id3/_tags.py#L510 # embed cover art
# given that this is the only reference to compression that i could find in the source code, and it says that if song_format == "ogg":
# ID3v2 compression was disabled for itunes compatibility, i'm going to assume/hope it doesn't do anything weird. m["metadata_block_picture"] = [cover]
# it's worth noting that mutagen has no dependencies outside of python's stdlib, which (currently) doesn't contain m.save()
# any method for JPEG compression, so i'm 99% sure the files won't be mangled.
embed_cover = APIC( if len(artists) > 1 and "Various Artists" not in artists:
encoding=3, # utf-8
mime="image/jpeg",
type=PictureType.COVER_FRONT,
desc='cover',
data=data
)
elif format_lookup[song_format] == "m4a":
embed_cover = MP4Cover(
data=data,
imageformat=MP4Cover.FORMAT_JPEG
)
artists: List[str] = []
album: str = "Unknown album" # it SHOULD be impossible for this value to ever appear
songs: Dict[str, str] = {}
zeroes = min(len(song_names), 2)
first_loop: bool = True
for song_name in song_names:
song = SongInfo(Path(tmp, song_name))
if first_loop:
# the first item in the artists list should be the album artist
artists.append(song["album_artist"])
album = song["album"]
first_loop = False
# add the song's artist(s) to the list
map(artists.append, song.list_tags["artist"])
songs[song_name] = song.get_target_name(zeroes)
if args.process_cover == 'a' or (args.process_cover == 'w' and song.has_cover() is False):
song.set_cover(embed_cover)
# remove duplicate artists
artists = list(dict.fromkeys(artists))
if len(artists) > 1 and "Various Artists" not in artists:
artists.append("Various Artists") artists.append("Various Artists")
artist: Optional[str] = None artist = None
while artist is None: while artist is None:
log("Artist directory:") log("Artist directory:")
for i, artist_name in enumerate(artists): for i in range(len(artists)):
log(f"{i+1}) {artist_name}") log(f"{i+1}) {artists[i]}")
log(f"{len(artists) + 1}) Custom...") log(f"{len(artists) + 1}) Custom...")
choice = "1" if args.quiet else input("> ")
user_choice: str = "1" if args.quiet else input("> ") if choice.isdecimal():
if user_choice.isdecimal(): if int(choice) == len(artists) + 1:
choice: int = int(user_choice) log("Enter the name to use.")
if choice == len(artists) + 1:
log("Enter the name to use:")
artist = input("> ")
else: else:
try: try:
artist = artists[choice - 1] artist = artists[int(choice) - 1]
except KeyError: except KeyError:
log(f"Please choose a number between 1 and {len(artists) + 1}.") log(f"Please choose a number between 1 and {len(artists) + 1}.")
else: else:
log(f"Please choose a number between 1 and {len(artists) + 1}") log(f"Please choose a number between 1 and {len(artists) + 1}")
destination: Path = Path(args.destination, artist, album) destination = os.path.join(args.destination, artist, album)
log(f"Moving files to \"{destination}\"...") log(f"Moving files to {destination}...")
os.makedirs(destination, exist_ok=True) os.makedirs(destination, exist_ok = True)
for source_name, dest_name in songs.items():
shutil.move(os.path.join(tmp, source_name), os.path.join(destination, dest_name))
shutil.move(os.path.join(tmp, "cover.jpg"), os.path.join(destination, "cover.jpg"))
for source_name, dest_name in songs.items(): tmp.cleanup()
shutil.move(str(Path(tmp, source_name)), str(Path(destination, dest_name))) log("Done!")
shutil.move(str(Path(tmp, cover)), str(Path(destination, cover)))
tmp_dir.cleanup()
log("Done!")
if __name__ == "__main__":
main()

View file

@ -1,8 +0,0 @@
[mypy]
strict = True
[mypy-mutagen.*]
ignore_missing_imports = True
[mypy-PIL.*]
ignore_missing_imports = True