changeset 9:5c0397762b53

INCOMPLETE: megacommit :)
author Paper <mrpapersonic@gmail.com>
date Sun, 10 Sep 2023 03:59:16 -0400
parents b1f73678ef61
children 4b198a111713
files .clang-format CMakeLists.txt README.md dep/anitomy/CMakeLists.txt dep/anitomy/LICENSE dep/anitomy/README.md dep/anitomy/anitomy/anitomy.cpp dep/anitomy/anitomy/anitomy.h dep/anitomy/anitomy/element.cpp dep/anitomy/anitomy/element.h dep/anitomy/anitomy/keyword.cpp dep/anitomy/anitomy/keyword.h dep/anitomy/anitomy/options.h dep/anitomy/anitomy/parser.cpp dep/anitomy/anitomy/parser.h dep/anitomy/anitomy/parser_helper.cpp dep/anitomy/anitomy/parser_number.cpp dep/anitomy/anitomy/string.cpp dep/anitomy/anitomy/string.h dep/anitomy/anitomy/token.cpp dep/anitomy/anitomy/token.h dep/anitomy/anitomy/tokenizer.cpp dep/anitomy/anitomy/tokenizer.h dep/anitomy/test/data.json include/core/anime.h include/core/anime_db.h include/core/config.h include/core/date.h include/core/filesystem.h include/core/json.h include/core/session.h include/core/strings.h include/core/time.h include/gui/dialog/information.h include/gui/dialog/settings.h include/gui/pages/anime_list.h include/gui/pages/now_playing.h include/gui/pages/statistics.h include/gui/sidebar.h include/gui/translate/anime.h include/gui/ui_utils.h include/gui/window.h include/services/anilist.h include/sys/osx/dark_theme.h include/sys/osx/filesystem.h include/sys/win32/dark_theme.h src/anilist.cpp src/anime.cpp src/config.cpp src/core/anime.cpp src/core/anime_db.cpp src/core/config.cpp src/core/date.cpp src/core/filesystem.cpp src/core/json.cpp src/core/strings.cpp src/core/time.cpp src/date.cpp src/dialog/information.cpp src/dialog/settings.cpp src/dialog/settings/application.cpp src/dialog/settings/services.cpp src/filesystem.cpp src/gui/dialog/information.cpp src/gui/dialog/settings.cpp src/gui/dialog/settings/application.cpp src/gui/dialog/settings/services.cpp src/gui/pages/anime_list.cpp src/gui/pages/now_playing.cpp src/gui/pages/statistics.cpp src/gui/sidebar.cpp src/gui/translate/anime.cpp src/gui/ui_utils.cpp src/gui/window.cpp src/include/anilist.h src/include/anime.h src/include/anime_list.h src/include/config.h src/include/date.h src/include/filesystem.h src/include/information.h src/include/json.h src/include/now_playing.h src/include/progress.h src/include/session.h src/include/settings.h src/include/sidebar.h src/include/statistics.h src/include/string_utils.h src/include/sys/osx/dark_theme.h src/include/sys/osx/filesystem.h src/include/sys/win32/dark_theme.h src/include/time_utils.h src/include/ui_utils.h src/include/window.h src/json.cpp src/main.cpp src/pages/anime_list.cpp src/pages/now_playing.cpp src/pages/statistics.cpp src/progress.cpp src/services/anilist.cpp src/services/services.cpp src/sidebar.cpp src/string_utils.cpp src/sys/osx/dark_theme.mm src/sys/osx/filesystem.mm src/sys/win32/dark_theme.cpp src/time.cpp src/ui_utils.cpp
diffstat 110 files changed, 8431 insertions(+), 3278 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.clang-format	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,27 @@
+---
+BasedOnStyle: LLVM
+IndentWidth: 4
+TabWidth: 4
+UseTab: Always
+PointerAlignment: Left
+IndentCaseLabels: true
+BreakBeforeBraces: Attach
+BreakStringLiterals: true
+AllowShortIfStatementsOnASingleLine: false
+ColumnLimit: 120
+AlignAfterOpenBracket: Align
+IndentAccessModifiers: true
+AccessModifierOffset: 4
+AlignArrayOfStructures: Left
+AlignConsecutiveMacros:
+  Enabled: true
+  AcrossEmptyLines: true
+  AcrossComments: false
+AllowShortBlocksOnASingleLine: Empty
+AllowShortEnumsOnASingleLine: false
+AllowShortFunctionsOnASingleLine: InlineOnly
+AllowShortCaseLabelsOnASingleLine: true
+
+---
+Language: Cpp
+Standard: Cpp11
--- a/CMakeLists.txt	Sat Aug 26 03:39:34 2023 -0400
+++ b/CMakeLists.txt	Sun Sep 10 03:59:16 2023 -0400
@@ -1,40 +1,56 @@
 cmake_minimum_required(VERSION 3.16)
 project(weeaboo LANGUAGES CXX OBJCXX)
 
+add_subdirectory(dep/anitomy)
+
 set(SRC_FILES
+	# Main entrypoint
 	src/main.cpp
-	src/config.cpp
-	src/filesystem.cpp
-	src/anilist.cpp
-	src/anime.cpp
-	src/json.cpp
-	src/date.cpp
-	src/time.cpp
-	src/sidebar.cpp
-	src/progress.cpp
-	src/pages/anime_list.cpp
-	src/pages/now_playing.cpp
-	src/pages/statistics.cpp
-	src/dialog/settings.cpp
-	src/dialog/information.cpp
-	src/dialog/settings/services.cpp
-	src/dialog/settings/application.cpp
-	src/ui_utils.cpp
-	src/string_utils.cpp
+
+	# Core files and datatype declarations...
+	src/core/anime.cpp
+	src/core/anime_db.cpp
+	src/core/config.cpp
+	src/core/date.cpp
+	src/core/filesystem.cpp
+	src/core/json.cpp
+	src/core/strings.cpp
+	src/core/time.cpp
+
+	# GUI stuff
+	src/gui/window.cpp
+	src/gui/sidebar.cpp
+	src/gui/ui_utils.cpp
+
+	# Dialogs
+	src/gui/dialog/information.cpp
+	src/gui/dialog/settings.cpp
+	src/gui/dialog/settings/application.cpp
+	src/gui/dialog/settings/services.cpp
+
+	# Main window pages
+	src/gui/pages/anime_list.cpp
+	src/gui/pages/now_playing.cpp
+	src/gui/pages/statistics.cpp
+
+	# Services (only AniList for now)
+	src/services/anilist.cpp
+
+	# Qt resources
 	rc/icons.qrc
 	dep/darkstyle/darkstyle.qrc
 )
 
-if(APPLE)
+if(APPLE) # Mac OS X (or OS X (or macOS))
 	list(APPEND SRC_FILES
 		src/sys/osx/dark_theme.mm
 		src/sys/osx/filesystem.mm
 	)
-elseif(WIN32)
+elseif(WIN32) # Windows
 	list(APPEND SRC_FILES src/sys/win32/dark_theme.cpp)
 endif()
 
-add_executable(weeaboo MACOSX_BUNDLE ${SRC_FILES})
+add_executable(weeaboo ${SRC_FILES})
 set_property(TARGET weeaboo PROPERTY CXX_STANDARD 20)
 set_property(TARGET weeaboo PROPERTY AUTOMOC ON)
 set_property(TARGET weeaboo PROPERTY AUTORCC ON)
@@ -45,6 +61,7 @@
 set(LIBRARIES
 	${Qt5Widgets_LIBRARIES}
 	${CURL_LIBRARIES}
+	anitomy
 )
 
 if(APPLE)
@@ -52,9 +69,11 @@
 	list(APPEND LIBRARIES ${COCOA_LIBRARY})
 endif()
 
-target_include_directories(weeaboo PUBLIC ${Qt5Widgets_INCLUDE_DIRS} ${CURL_INCLUDE_DIRS} PRIVATE src/include src/icons)
+target_include_directories(weeaboo PUBLIC ${Qt5Widgets_INCLUDE_DIRS} ${CURL_INCLUDE_DIRS} PRIVATE include)
 target_compile_options(weeaboo PRIVATE -Wall -Wextra -Wsuggest-override)
 if(APPLE)
 	target_compile_definitions(weeaboo PUBLIC MACOSX)
+elseif(WIN32)
+	target_compile_definitions(weeaboo PUBLIC WIN32)
 endif()
 target_link_libraries(weeaboo ${LIBRARIES})
--- a/README.md	Sat Aug 26 03:39:34 2023 -0400
+++ b/README.md	Sun Sep 10 03:59:16 2023 -0400
@@ -1,2 +1,2 @@
-# weeaboo
-A cross-platform anime tracker built with Qt. Inspired by Taiga.
+# Minori
+A cross-platform Taiga clone.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/CMakeLists.txt	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,16 @@
+cmake_minimum_required(VERSION 3.9)
+project(anitomy)
+add_library(anitomy SHARED
+	anitomy/anitomy.cpp
+	anitomy/element.cpp
+	anitomy/keyword.cpp
+	anitomy/parser.cpp
+	anitomy/parser_helper.cpp
+	anitomy/parser_number.cpp
+	anitomy/string.cpp
+	anitomy/token.cpp
+	anitomy/tokenizer.cpp
+)
+set_target_properties(anitomy PROPERTIES
+    PUBLIC_HEADER anitomy/anitomy.h)
+target_include_directories(anitomy PRIVATE src)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/LICENSE	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/README.md	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,141 @@
+# Anitomy
+
+*Anitomy* is a C++ library for parsing anime video filenames. It's accurate, fast, and simple to use.
+
+## Examples
+
+The following filename...
+
+    [TaigaSubs]_Toradora!_(2008)_-_01v2_-_Tiger_and_Dragon_[1280x720_H.264_FLAC][1234ABCD].mkv
+
+...is resolved into these elements:
+
+- Release group: *TaigaSubs*
+- Anime title: *Toradora!*
+- Anime year: *2008*
+- Episode number: *01*
+- Release version: *2*
+- Episode title: *Tiger and Dragon*
+- Video resolution: *1280x720*
+- Video term: *H.264*
+- Audio term: *FLAC*
+- File checksum: *1234ABCD*
+
+Here's an example code snippet...
+
+```cpp
+#include <iostream>
+#include <anitomy/anitomy.h>
+
+int main() {
+  anitomy::Anitomy anitomy;
+  anitomy.Parse(L"[Ouroboros]_Fullmetal_Alchemist_Brotherhood_-_01.mkv");
+
+  const auto& elements = anitomy.elements();
+
+  // Elements are iterable, where each element is a category-value pair
+  for (const auto& element : elements) {
+    std::wcout << element.first << '\t' << element.second << '\n';
+  }
+  std::wcout << '\n';
+
+  // You can access values directly by using get() and get_all() methods
+  std::wcout << elements.get(anitomy::kElementAnimeTitle) << L" #" <<
+                elements.get(anitomy::kElementEpisodeNumber) << L" by " <<
+                elements.get(anitomy::kElementReleaseGroup) << '\n';
+
+  return 0;
+}
+```
+
+...which will output:
+
+```
+12      mkv
+13      [Ouroboros]_Fullmetal_Alchemist_Brotherhood_-_01
+7       01
+2       Fullmetal Alchemist Brotherhood
+16      Ouroboros
+
+Fullmetal Alchemist Brotherhood #01 by Ouroboros
+```
+
+## How does it work?
+
+Suppose that we're working on the following filename:
+
+    "Spice_and_Wolf_Ep01_[1080p,BluRay,x264]_-_THORA.mkv"
+
+The filename is first stripped off of its extension and split into groups. Groups are determined by the position of brackets:
+
+    "Spice_and_Wolf_Ep01_", "1080p,BluRay,x264", "_-_THORA"
+
+Each group is then split into tokens. In our current example, the delimiter for the enclosed group is `,`, while the words in other groups are separated by `_`:
+
+    "Spice", "and", "Wolf", "Ep01", "1080p", "BluRay", "x264", "-", "THORA"
+
+Note that brackets and delimiters are actually stored as tokens. Here, identified tokens are omitted for our convenience.
+
+Once the tokenizer is done, the parser comes into effect. First, all tokens are compared against a set of known patterns and keywords. This process generally leaves us with nothing but the release group, anime title, episode number and episode title:
+
+    "Spice", "and", "Wolf", "Ep01", "-"
+
+The next step is to look for the episode number. Each token that contains a number is analyzed. Here, `Ep01` is identified because it begins with a known episode prefix:
+
+    "Spice", "and", "Wolf", "-"
+
+Finally, remaining tokens are combined to form the anime title, which is `Spice and Wolf`. The complete list of elements identified by *Anitomy* is as follows:
+
+- Anime title: *Spice and Wolf*
+- Episode number: *01*
+- Video resolution: *1080p*
+- Source: *BluRay*
+- Video term: *x264*
+- Release group: *THORA*
+
+## Why should I use it?
+
+Anime video files are commonly named in a format where the anime title is followed by the episode number, and all the technical details are enclosed within brackets. However, fansub groups tend to use their own naming conventions, and the problem is more complicated than it first appears:
+
+- Element order is not always the same.
+- Technical information is not guaranteed to be enclosed.
+- Brackets and parentheses may be grouping symbols or a part of the anime/episode title.
+- Space and underscore are not the only delimiters in use.
+- A single filename may contain multiple delimiters.
+
+There are so many cases to cover that it's simply not possible to parse all filenames solely with regular expressions. *Anitomy* tries a different approach, and it succeeds: It's able to parse tens of thousands of filenames per second, with great accuracy.
+
+The following projects make use of *Anitomy*:
+
+- [Taiga](https://github.com/erengy/taiga)
+- [MAL Updater OS X](https://github.com/chikorita157/malupdaterosx-cocoa)
+- [Hachidori](https://github.com/chikorita157/hachidori)
+- [Shinjiru](https://github.com/Kazakuri/Shinjiru)
+
+See [other repositories](https://github.com/search?utf8=%E2%9C%93&q=anitomy) for related projects (e.g. interfaces, ports, wrappers).
+
+## Are there any exceptions?
+
+Yes, unfortunately. *Anitomy* fails to identify the anime title and episode number on rare occasions, mostly due to bad naming conventions. See the examples below.
+
+    Arigatou.Shuffle!.Ep08.[x264.AAC][D6E43829].mkv
+
+Here, *Anitomy* would report that this file is the 8th episode of `Arigatou Shuffle!`, where `Arigatou` is actually the name of the fansub group.
+
+    Spice and Wolf 2
+
+Is this the 2nd episode of `Spice and Wolf`, or a batch release of `Spice and Wolf 2`? Without a file extension, there's no way to know. It's up to you consider both cases.
+
+## Suggestions to fansub groups
+
+Please consider abiding by these simple rules before deciding on your naming convention:
+
+- Don't enclose anime title, episode number and episode title within brackets. Enclose everything else, including the name of your group.
+- Don't use parentheses to enclose release information; use square brackets instead. Parentheses should only be used if they are a part of the anime/episode title.
+- Don't use multiple delimiters in a single filename. If possible, stick with either space or underscore.
+- Use a separator (e.g. a dash) between anime title and episode number. There are anime titles that end with a number, which creates ambiguity.
+- Indicate the episode interval in batch releases.
+
+## License
+
+*Anitomy* is licensed under [Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/FAQ/).
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/anitomy.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,91 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include "anitomy.h"
+#include "keyword.h"
+#include "parser.h"
+#include "string.h"
+#include "tokenizer.h"
+
+namespace anitomy {
+
+bool Anitomy::Parse(string_t filename) {
+	elements_.clear();
+	tokens_.clear();
+
+	if (options_.parse_file_extension) {
+		string_t extension;
+		if (RemoveExtensionFromFilename(filename, extension))
+			elements_.insert(kElementFileExtension, extension);
+	}
+
+	if (!options_.ignored_strings.empty())
+		RemoveIgnoredStrings(filename);
+
+	if (filename.empty())
+		return false;
+	elements_.insert(kElementFileName, filename);
+
+	Tokenizer tokenizer(filename, elements_, options_, tokens_);
+	if (!tokenizer.Tokenize())
+		return false;
+
+	Parser parser(elements_, options_, tokens_);
+	if (!parser.Parse())
+		return false;
+
+	return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool Anitomy::RemoveExtensionFromFilename(string_t& filename, string_t& extension) const {
+	const size_t position = filename.find_last_of(L'.');
+
+	if (position == string_t::npos)
+		return false;
+
+	extension = filename.substr(position + 1);
+
+	const size_t max_length = 4;
+	if (extension.length() > max_length)
+		return false;
+
+	if (!IsAlphanumericString(extension))
+		return false;
+
+	auto keyword = keyword_manager.Normalize(extension);
+	if (!keyword_manager.Find(kElementFileExtension, keyword))
+		return false;
+
+	filename.resize(position);
+
+	return true;
+}
+
+void Anitomy::RemoveIgnoredStrings(string_t& filename) const {
+	for (const auto& str : options_.ignored_strings) {
+		EraseString(filename, str);
+	}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+Elements& Anitomy::elements() {
+	return elements_;
+}
+
+Options& Anitomy::options() {
+	return options_;
+}
+
+const token_container_t& Anitomy::tokens() const {
+	return tokens_;
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/anitomy.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,35 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#pragma once
+
+#include "element.h"
+#include "options.h"
+#include "string.h"
+#include "token.h"
+
+namespace anitomy {
+
+class Anitomy {
+	public:
+		bool Parse(string_t filename);
+
+		Elements& elements();
+		Options& options();
+		const token_container_t& tokens() const;
+
+	private:
+		bool RemoveExtensionFromFilename(string_t& filename, string_t& extension) const;
+		void RemoveIgnoredStrings(string_t& filename) const;
+
+		Elements elements_;
+		Options options_;
+		token_container_t tokens_;
+};
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/element.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,137 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include <algorithm>
+
+#include "element.h"
+
+namespace anitomy {
+
+bool Elements::empty() const {
+	return elements_.empty();
+}
+
+size_t Elements::size() const {
+	return elements_.size();
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+element_iterator_t Elements::begin() {
+	return elements_.begin();
+}
+
+element_const_iterator_t Elements::begin() const {
+	return elements_.begin();
+}
+
+element_const_iterator_t Elements::cbegin() const {
+	return elements_.begin();
+}
+
+element_iterator_t Elements::end() {
+	return elements_.end();
+}
+
+element_const_iterator_t Elements::end() const {
+	return elements_.end();
+}
+
+element_const_iterator_t Elements::cend() const {
+	return elements_.end();
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+element_pair_t& Elements::at(size_t position) {
+	return elements_.at(position);
+}
+
+const element_pair_t& Elements::at(size_t position) const {
+	return elements_.at(position);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+string_t Elements::get(ElementCategory category) const {
+	auto element = find(category);
+	return element != elements_.end() ? element->second : string_t();
+}
+
+std::vector<string_t> Elements::get_all(ElementCategory category) const {
+	std::vector<string_t> elements;
+
+	for (const auto& element : elements_)
+		if (element.first == category)
+			elements.push_back(element.second);
+
+	return elements;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void Elements::clear() {
+	elements_.clear();
+}
+
+void Elements::insert(ElementCategory category, const string_t& value) {
+	if (!value.empty())
+		elements_.push_back({category, value});
+}
+
+void Elements::erase(ElementCategory category) {
+	auto iterator = std::remove_if(elements_.begin(), elements_.end(),
+								   [&](const element_pair_t& element) { return element.first == category; });
+	elements_.erase(iterator, elements_.end());
+}
+
+element_iterator_t Elements::erase(element_iterator_t iterator) {
+	return elements_.erase(iterator);
+}
+
+void Elements::set(ElementCategory category, const string_t& value) {
+	auto element = find(category);
+
+	if (element == elements_.end()) {
+		elements_.push_back({category, value});
+	} else {
+		element->second = value;
+	}
+}
+
+string_t& Elements::operator[](ElementCategory category) {
+	auto element = find(category);
+
+	if (element == elements_.end())
+		element = elements_.insert(elements_.end(), {category, string_t()});
+
+	return element->second;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+size_t Elements::count(ElementCategory category) const {
+	return std::count_if(elements_.begin(), elements_.end(),
+						 [&](const element_pair_t& element) { return element.first == category; });
+}
+
+bool Elements::empty(ElementCategory category) const {
+	return find(category) == elements_.end();
+}
+
+element_iterator_t Elements::find(ElementCategory category) {
+	return std::find_if(elements_.begin(), elements_.end(),
+						[&](const element_pair_t& element) { return element.first == category; });
+}
+
+element_const_iterator_t Elements::find(ElementCategory category) const {
+	return std::find_if(elements_.cbegin(), elements_.cend(),
+						[&](const element_pair_t& element) { return element.first == category; });
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/element.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,93 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#pragma once
+
+#include <vector>
+
+#include "string.h"
+
+namespace anitomy {
+
+enum ElementCategory {
+	kElementIterateFirst,
+	kElementAnimeSeason = kElementIterateFirst,
+	kElementAnimeSeasonPrefix,
+	kElementAnimeTitle,
+	kElementAnimeType,
+	kElementAnimeYear,
+	kElementAudioTerm,
+	kElementDeviceCompatibility,
+	kElementEpisodeNumber,
+	kElementEpisodeNumberAlt,
+	kElementEpisodePrefix,
+	kElementEpisodeTitle,
+	kElementFileChecksum,
+	kElementFileExtension,
+	kElementFileName,
+	kElementLanguage,
+	kElementOther,
+	kElementReleaseGroup,
+	kElementReleaseInformation,
+	kElementReleaseVersion,
+	kElementSource,
+	kElementSubtitles,
+	kElementVideoResolution,
+	kElementVideoTerm,
+	kElementVolumeNumber,
+	kElementVolumePrefix,
+	kElementIterateLast,
+	kElementUnknown = kElementIterateLast
+};
+
+using element_pair_t = std::pair<ElementCategory, string_t>;
+using element_container_t = std::vector<element_pair_t>;
+using element_iterator_t = element_container_t::iterator;
+using element_const_iterator_t = element_container_t::const_iterator;
+
+class Elements {
+	public:
+		// Capacity
+		bool empty() const;
+		size_t size() const;
+
+		// Iterators
+		element_iterator_t begin();
+		element_const_iterator_t begin() const;
+		element_const_iterator_t cbegin() const;
+		element_iterator_t end();
+		element_const_iterator_t end() const;
+		element_const_iterator_t cend() const;
+
+		// Element access
+		element_pair_t& at(size_t position);
+		const element_pair_t& at(size_t position) const;
+
+		// Value access
+		string_t get(ElementCategory category) const;
+		std::vector<string_t> get_all(ElementCategory category) const;
+
+		// Modifiers
+		void clear();
+		void insert(ElementCategory category, const string_t& value);
+		void erase(ElementCategory category);
+		element_iterator_t erase(element_iterator_t iterator);
+		void set(ElementCategory category, const string_t& value);
+		string_t& operator[](ElementCategory category);
+
+		// Lookup
+		size_t count(ElementCategory category) const;
+		bool empty(ElementCategory category) const;
+		element_iterator_t find(ElementCategory category);
+		element_const_iterator_t find(ElementCategory category) const;
+
+	private:
+		element_container_t elements_;
+};
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/keyword.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,171 @@
+/*
+** Copyright (c) 2014-2018, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include <algorithm>
+
+#include "keyword.h"
+#include "token.h"
+
+namespace anitomy {
+
+KeywordManager keyword_manager;
+
+KeywordManager::KeywordManager() {
+	const KeywordOptions options_default;
+	const KeywordOptions options_invalid{true, true, false};
+	const KeywordOptions options_unidentifiable{false, true, true};
+	const KeywordOptions options_unidentifiable_invalid{false, true, false};
+	const KeywordOptions options_unidentifiable_unsearchable{false, false, true};
+
+	Add(kElementAnimeSeasonPrefix, options_unidentifiable, {L"SAISON", L"SEASON"});
+
+	Add(kElementAnimeType, options_unidentifiable,
+		{L"GEKIJOUBAN", L"MOVIE", L"OAD", L"OAV", L"ONA", L"OVA", L"SPECIAL", L"SPECIALS", L"TV"});
+	Add(kElementAnimeType, options_unidentifiable_unsearchable, {L"SP"}); // e.g. "Yumeiro Patissiere SP Professional"
+	Add(kElementAnimeType, options_unidentifiable_invalid,
+		{L"ED", L"ENDING", L"NCED", L"NCOP", L"OP", L"OPENING", L"PREVIEW", L"PV"});
+
+	Add(kElementAudioTerm, options_default,
+		{// Audio channels
+		 L"2.0CH", L"2CH", L"5.1", L"5.1CH", L"DTS", L"DTS-ES", L"DTS5.1", L"TRUEHD5.1",
+		 // Audio codec
+		 L"AAC", L"AACX2", L"AACX3", L"AACX4", L"AC3", L"EAC3", L"E-AC-3", L"FLAC", L"FLACX2", L"FLACX3", L"FLACX4",
+		 L"LOSSLESS", L"MP3", L"OGG", L"VORBIS",
+		 // Audio language
+		 L"DUALAUDIO", L"DUAL AUDIO"});
+
+	Add(kElementDeviceCompatibility, options_default, {L"IPAD3", L"IPHONE5", L"IPOD", L"PS3", L"XBOX", L"XBOX360"});
+	Add(kElementDeviceCompatibility, options_unidentifiable, {L"ANDROID"});
+
+	Add(kElementEpisodePrefix, options_default,
+		{L"EP", L"EP.", L"EPS", L"EPS.", L"EPISODE", L"EPISODE.", L"EPISODES", L"CAPITULO", L"EPISODIO",
+		 L"EPIS\u00F3DIO", L"FOLGE"});
+	Add(kElementEpisodePrefix, options_invalid,
+		{L"E", L"\x7B2C"}); // single-letter episode keywords are not valid tokens
+
+	Add(kElementFileExtension, options_default,
+		{L"3GP", L"AVI", L"DIVX", L"FLV", L"M2TS", L"MKV", L"MOV", L"MP4", L"MPG", L"OGM", L"RM", L"RMVB", L"TS",
+		 L"WEBM", L"WMV"});
+	Add(kElementFileExtension, options_invalid,
+		{L"AAC", L"AIFF", L"FLAC", L"M4A", L"MP3", L"MKA", L"OGG", L"WAV", L"WMA", L"7Z", L"RAR", L"ZIP", L"ASS",
+		 L"SRT"});
+
+	Add(kElementLanguage, options_default, {L"ENG", L"ENGLISH", L"ESPANOL", L"JAP", L"PT-BR", L"SPANISH", L"VOSTFR"});
+	Add(kElementLanguage, options_unidentifiable, {L"ESP", L"ITA"}); // e.g. "Tokyo ESP", "Bokura ga Ita"
+
+	Add(kElementOther, options_default,
+		{L"REMASTER", L"REMASTERED", L"UNCENSORED", L"UNCUT", L"TS", L"VFR", L"WIDESCREEN", L"WS"});
+
+	Add(kElementReleaseGroup, options_default, {L"THORA"});
+
+	Add(kElementReleaseInformation, options_default, {L"BATCH", L"COMPLETE", L"PATCH", L"REMUX"});
+	Add(kElementReleaseInformation, options_unidentifiable,
+		{L"END", L"FINAL"}); // e.g. "The End of Evangelion", "Final Approach"
+
+	Add(kElementReleaseVersion, options_default, {L"V0", L"V1", L"V2", L"V3", L"V4"});
+
+	Add(kElementSource, options_default,
+		{L"BD",		 L"BDRIP",	 L"BLURAY",	 L"BLU-RAY", L"DVD",	 L"DVD5",	L"DVD9",
+		 L"DVD-R2J", L"DVDRIP",	 L"DVD-RIP", L"R2DVD",	 L"R2J",	 L"R2JDVD", L"R2JDVDRIP",
+		 L"HDTV",	 L"HDTVRIP", L"TVRIP",	 L"TV-RIP",	 L"WEBCAST", L"WEBRIP"});
+
+	Add(kElementSubtitles, options_default,
+		{L"ASS", L"BIG5", L"DUB", L"DUBBED", L"HARDSUB", L"HARDSUBS", L"RAW", L"SOFTSUB", L"SOFTSUBS", L"SUB",
+		 L"SUBBED", L"SUBTITLED"});
+
+	Add(kElementVideoTerm, options_default,
+		{// Frame rate
+		 L"23.976FPS", L"24FPS", L"29.97FPS", L"30FPS", L"60FPS", L"120FPS",
+		 // Video codec
+		 L"8BIT", L"8-BIT", L"10BIT", L"10BITS", L"10-BIT", L"10-BITS", L"HI10", L"HI10P", L"HI444", L"HI444P",
+		 L"HI444PP", L"H264", L"H265", L"H.264", L"H.265", L"X264", L"X265", L"X.264", L"AVC", L"HEVC", L"HEVC2",
+		 L"DIVX", L"DIVX5", L"DIVX6", L"XVID", L"AV1",
+		 // Video format
+		 L"AVI", L"RMVB", L"WMV", L"WMV3", L"WMV9",
+		 // Video quality
+		 L"HQ", L"LQ",
+		 // Video resolution
+		 L"HD", L"SD"});
+
+	Add(kElementVolumePrefix, options_default, {L"VOL", L"VOL.", L"VOLUME"});
+}
+
+void KeywordManager::Add(ElementCategory category, const KeywordOptions& options,
+						 const std::initializer_list<string_t>& keywords) {
+	auto& keys = GetKeywordContainer(category);
+	for (const auto& keyword : keywords) {
+		if (keyword.empty())
+			continue;
+		if (keys.find(keyword) != keys.end())
+			continue;
+		keys.insert(std::make_pair(keyword, Keyword{category, options}));
+	}
+}
+
+bool KeywordManager::Find(ElementCategory category, const string_t& str) const {
+	const auto& keys = GetKeywordContainer(category);
+	auto it = keys.find(str);
+	if (it != keys.end() && it->second.category == category)
+		return true;
+
+	return false;
+}
+
+bool KeywordManager::Find(const string_t& str, ElementCategory& category, KeywordOptions& options) const {
+	const auto& keys = GetKeywordContainer(category);
+	auto it = keys.find(str);
+	if (it != keys.end()) {
+		if (category == kElementUnknown) {
+			category = it->second.category;
+		} else if (it->second.category != category) {
+			return false;
+		}
+		options = it->second.options;
+		return true;
+	}
+
+	return false;
+}
+
+string_t KeywordManager::Normalize(const string_t& str) const {
+	return StringToUpperCopy(str);
+}
+
+KeywordManager::keyword_container_t& KeywordManager::GetKeywordContainer(ElementCategory category) const {
+	return category == kElementFileExtension ? const_cast<keyword_container_t&>(file_extensions_)
+											 : const_cast<keyword_container_t&>(keys_);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void KeywordManager::Peek(const string_t& filename, const TokenRange& range, Elements& elements,
+						  std::vector<TokenRange>& preidentified_tokens) const {
+	using entry_t = std::pair<ElementCategory, std::vector<string_t>>;
+	static const std::vector<entry_t> entries{
+		{kElementAudioTerm,		{L"Dual Audio"}					   },
+		{kElementVideoTerm,		{L"H264", L"H.264", L"h264", L"h.264"}},
+		{kElementVideoResolution, {L"480p", L"720p", L"1080p"}		  },
+		{kElementSource,			 {L"Blu-Ray"}							 }
+	  };
+
+	auto it_begin = filename.begin() + range.offset;
+	auto it_end = it_begin + range.size;
+
+	for (const auto& entry : entries) {
+		for (const auto& keyword : entry.second) {
+			auto it = std::search(it_begin, it_end, keyword.begin(), keyword.end());
+			if (it != it_end) {
+				const auto offset = static_cast<size_t>(std::distance(filename.begin(), it));
+				elements.insert(entry.first, keyword);
+				preidentified_tokens.push_back(TokenRange{offset, keyword.size()});
+			}
+		}
+	}
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/keyword.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,59 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#pragma once
+
+#include <initializer_list>
+#include <map>
+#include <vector>
+
+#include "element.h"
+#include "string.h"
+
+namespace anitomy {
+
+struct TokenRange;
+
+struct KeywordOptions {
+		bool identifiable = true;
+		bool searchable = true;
+		bool valid = true;
+};
+
+struct Keyword {
+		ElementCategory category;
+		KeywordOptions options;
+};
+
+class KeywordManager {
+	public:
+		KeywordManager();
+
+		void Add(ElementCategory category, const KeywordOptions& options,
+				 const std::initializer_list<string_t>& keywords);
+
+		bool Find(ElementCategory category, const string_t& str) const;
+		bool Find(const string_t& str, ElementCategory& category, KeywordOptions& options) const;
+
+		void Peek(const string_t& filename, const TokenRange& range, Elements& elements,
+				  std::vector<TokenRange>& preidentified_tokens) const;
+
+		string_t Normalize(const string_t& str) const;
+
+	private:
+		using keyword_container_t = std::map<string_t, Keyword>;
+
+		keyword_container_t& GetKeywordContainer(ElementCategory category) const;
+
+		keyword_container_t file_extensions_;
+		keyword_container_t keys_;
+};
+
+extern KeywordManager keyword_manager;
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/options.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,27 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#pragma once
+
+#include <vector>
+
+#include "string.h"
+
+namespace anitomy {
+
+struct Options {
+		string_t allowed_delimiters = L" _.&+,|";
+		std::vector<string_t> ignored_strings;
+
+		bool parse_episode_number = true;
+		bool parse_episode_title = true;
+		bool parse_file_extension = true;
+		bool parse_release_group = true;
+};
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/parser.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,325 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include <algorithm>
+
+#include "keyword.h"
+#include "parser.h"
+#include "string.h"
+
+namespace anitomy {
+
+Parser::Parser(Elements& elements, const Options& options, token_container_t& tokens)
+	: elements_(elements), options_(options), tokens_(tokens) {
+}
+
+bool Parser::Parse() {
+	SearchForKeywords();
+
+	SearchForIsolatedNumbers();
+
+	if (options_.parse_episode_number)
+		SearchForEpisodeNumber();
+
+	SearchForAnimeTitle();
+
+	if (options_.parse_release_group && elements_.empty(kElementReleaseGroup))
+		SearchForReleaseGroup();
+
+	if (options_.parse_episode_title && !elements_.empty(kElementEpisodeNumber))
+		SearchForEpisodeTitle();
+
+	ValidateElements();
+
+	return !elements_.empty(kElementAnimeTitle);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void Parser::SearchForKeywords() {
+	for (auto it = tokens_.begin(); it != tokens_.end(); ++it) {
+		auto& token = *it;
+
+		if (token.category != kUnknown)
+			continue;
+
+		auto word = token.content;
+		TrimString(word, L" -");
+
+		if (word.empty())
+			continue;
+		// Don't bother if the word is a number that cannot be CRC
+		if (word.size() != 8 && IsNumericString(word))
+			continue;
+
+		// Performs better than making a case-insensitive Find
+		auto keyword = keyword_manager.Normalize(word);
+		ElementCategory category = kElementUnknown;
+		KeywordOptions options;
+
+		if (keyword_manager.Find(keyword, category, options)) {
+			if (!options_.parse_release_group && category == kElementReleaseGroup)
+				continue;
+			if (!IsElementCategorySearchable(category) || !options.searchable)
+				continue;
+			if (IsElementCategorySingular(category) && !elements_.empty(category))
+				continue;
+			if (category == kElementAnimeSeasonPrefix) {
+				CheckAnimeSeasonKeyword(it);
+				continue;
+			} else if (category == kElementEpisodePrefix) {
+				if (options.valid)
+					CheckExtentKeyword(kElementEpisodeNumber, it);
+				continue;
+			} else if (category == kElementReleaseVersion) {
+				word = word.substr(1); // number without "v"
+			} else if (category == kElementVolumePrefix) {
+				CheckExtentKeyword(kElementVolumeNumber, it);
+				continue;
+			}
+		} else {
+			if (elements_.empty(kElementFileChecksum) && IsCrc32(word)) {
+				category = kElementFileChecksum;
+			} else if (elements_.empty(kElementVideoResolution) && IsResolution(word)) {
+				category = kElementVideoResolution;
+			}
+		}
+
+		if (category != kElementUnknown) {
+			elements_.insert(category, word);
+			if (options.identifiable)
+				token.category = kIdentifier;
+		}
+	}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void Parser::SearchForEpisodeNumber() {
+	// List all unknown tokens that contain a number
+	std::vector<size_t> tokens;
+	for (size_t i = 0; i < tokens_.size(); ++i) {
+		auto& token = tokens_.at(i);
+		if (token.category == kUnknown)
+			if (FindNumberInString(token.content) != token.content.npos)
+				tokens.push_back(i);
+	}
+	if (tokens.empty())
+		return;
+
+	found_episode_keywords_ = !elements_.empty(kElementEpisodeNumber);
+
+	// If a token matches a known episode pattern, it has to be the episode number
+	if (SearchForEpisodePatterns(tokens))
+		return;
+
+	if (!elements_.empty(kElementEpisodeNumber))
+		return; // We have previously found an episode number via keywords
+
+	// From now on, we're only interested in numeric tokens
+	auto not_numeric_string = [&](size_t index) -> bool { return !IsNumericString(tokens_.at(index).content); };
+	tokens.erase(std::remove_if(tokens.begin(), tokens.end(), not_numeric_string), tokens.end());
+
+	if (tokens.empty())
+		return;
+
+	// e.g. "01 (176)", "29 (04)"
+	if (SearchForEquivalentNumbers(tokens))
+		return;
+
+	// e.g. " - 08"
+	if (SearchForSeparatedNumbers(tokens))
+		return;
+
+	// e.g. "[12]", "(2006)"
+	if (SearchForIsolatedNumbers(tokens))
+		return;
+
+	// Consider using the last number as a last resort
+	SearchForLastNumber(tokens);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void Parser::SearchForAnimeTitle() {
+	bool enclosed_title = false;
+
+	// Find the first non-enclosed unknown token
+	auto token_begin = FindToken(tokens_.begin(), tokens_.end(), kFlagNotEnclosed | kFlagUnknown);
+
+	// If that doesn't work, find the first unknown token in the second enclosed
+	// group, assuming that the first one is the release group
+	if (token_begin == tokens_.end()) {
+		enclosed_title = true;
+		token_begin = tokens_.begin();
+		bool skipped_previous_group = false;
+		do {
+			token_begin = FindToken(token_begin, tokens_.end(), kFlagUnknown);
+			if (token_begin == tokens_.end())
+				break;
+			// Ignore groups that are composed of non-Latin characters
+			if (IsMostlyLatinString(token_begin->content))
+				if (skipped_previous_group)
+					break; // Found it
+			// Get the first unknown token of the next group
+			token_begin = FindToken(token_begin, tokens_.end(), kFlagBracket);
+			token_begin = FindToken(token_begin, tokens_.end(), kFlagUnknown);
+			skipped_previous_group = true;
+		} while (token_begin != tokens_.end());
+	}
+	if (token_begin == tokens_.end())
+		return;
+
+	// Continue until an identifier (or a bracket, if the title is enclosed)
+	// is found
+	auto token_end =
+		FindToken(token_begin, tokens_.end(), kFlagIdentifier | (enclosed_title ? kFlagBracket : kFlagNone));
+
+	// If within the interval there's an open bracket without its matching pair,
+	// move the upper endpoint back to the bracket
+	if (!enclosed_title) {
+		auto last_bracket = token_end;
+		bool bracket_open = false;
+		for (auto token = token_begin; token != token_end; ++token) {
+			if (token->category == kBracket) {
+				last_bracket = token;
+				bracket_open = !bracket_open;
+			}
+		}
+		if (bracket_open)
+			token_end = last_bracket;
+	}
+
+	// If the interval ends with an enclosed group (e.g. "Anime Title [Fansub]"),
+	// move the upper endpoint back to the beginning of the group. We ignore
+	// parentheses in order to keep certain groups (e.g. "(TV)") intact.
+	if (!enclosed_title) {
+		auto token = FindPreviousToken(tokens_, token_end, kFlagNotDelimiter);
+		while (CheckTokenCategory(token, kBracket) && token->content.front() != ')') {
+			token = FindPreviousToken(tokens_, token, kFlagBracket);
+			if (token != tokens_.end()) {
+				token_end = token;
+				token = FindPreviousToken(tokens_, token_end, kFlagNotDelimiter);
+			}
+		}
+	}
+
+	// Build anime title
+	BuildElement(kElementAnimeTitle, false, token_begin, token_end);
+}
+
+void Parser::SearchForReleaseGroup() {
+	auto token_begin = tokens_.begin();
+	auto token_end = tokens_.begin();
+
+	do {
+		// Find the first enclosed unknown token
+		token_begin = FindToken(token_end, tokens_.end(), kFlagEnclosed | kFlagUnknown);
+		if (token_begin == tokens_.end())
+			return;
+
+		// Continue until a bracket or identifier is found
+		token_end = FindToken(token_begin, tokens_.end(), kFlagBracket | kFlagIdentifier);
+		if (token_end == tokens_.end() || token_end->category != kBracket)
+			continue;
+
+		// Ignore if it's not the first non-delimiter token in group
+		auto previous_token = FindPreviousToken(tokens_, token_begin, kFlagNotDelimiter);
+		if (previous_token != tokens_.end() && previous_token->category != kBracket) {
+			continue;
+		}
+
+		// Build release group
+		BuildElement(kElementReleaseGroup, true, token_begin, token_end);
+		return;
+	} while (token_begin != tokens_.end());
+}
+
+void Parser::SearchForEpisodeTitle() {
+	auto token_begin = tokens_.begin();
+	auto token_end = tokens_.begin();
+
+	do {
+		// Find the first non-enclosed unknown token
+		token_begin = FindToken(token_end, tokens_.end(), kFlagNotEnclosed | kFlagUnknown);
+		if (token_begin == tokens_.end())
+			return;
+
+		// Continue until a bracket or identifier is found
+		token_end = FindToken(token_begin, tokens_.end(), kFlagBracket | kFlagIdentifier);
+
+		// Ignore if it's only a dash
+		if (std::distance(token_begin, token_end) <= 2 && IsDashCharacter(token_begin->content)) {
+			continue;
+		}
+
+		// Build episode title
+		BuildElement(kElementEpisodeTitle, false, token_begin, token_end);
+		return;
+	} while (token_begin != tokens_.end());
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void Parser::SearchForIsolatedNumbers() {
+	for (auto token = tokens_.begin(); token != tokens_.end(); ++token) {
+		if (token->category != kUnknown || !IsNumericString(token->content) || !IsTokenIsolated(token))
+			continue;
+
+		auto number = StringToInt(token->content);
+
+		// Anime year
+		if (number >= kAnimeYearMin && number <= kAnimeYearMax) {
+			if (elements_.empty(kElementAnimeYear)) {
+				elements_.insert(kElementAnimeYear, token->content);
+				token->category = kIdentifier;
+				continue;
+			}
+		}
+
+		// Video resolution
+		if (number == 480 || number == 720 || number == 1080) {
+			// If these numbers are isolated, it's more likely for them to be the
+			// video resolution rather than the episode number. Some fansub groups
+			// use these without the "p" suffix.
+			if (elements_.empty(kElementVideoResolution)) {
+				elements_.insert(kElementVideoResolution, token->content);
+				token->category = kIdentifier;
+				continue;
+			}
+		}
+	}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void Parser::ValidateElements() {
+	// Validate anime type and episode title
+	if (!elements_.empty(kElementAnimeType) && !elements_.empty(kElementEpisodeTitle)) {
+		// Here we check whether the episode title contains an anime type
+		const auto episode_title = elements_.get(kElementEpisodeTitle);
+		for (auto it = elements_.begin(); it != elements_.end();) {
+			if (it->first == kElementAnimeType) {
+				if (IsInString(episode_title, it->second)) {
+					if (episode_title.size() == it->second.size()) {
+						elements_.erase(kElementEpisodeTitle); // invalid episode title
+					} else {
+						const auto keyword = keyword_manager.Normalize(it->second);
+						if (keyword_manager.Find(kElementAnimeType, keyword)) {
+							it = elements_.erase(it); // invalid anime type
+							continue;
+						}
+					}
+				}
+			}
+			++it;
+		}
+	}
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/parser.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,95 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#pragma once
+
+#include "element.h"
+#include "options.h"
+#include "string.h"
+#include "token.h"
+
+namespace anitomy {
+
+class Parser {
+	public:
+		Parser(Elements& elements, const Options& options, token_container_t& tokens);
+
+		Parser(const Parser&) = delete;
+		Parser& operator=(const Parser&) = delete;
+
+		bool Parse();
+
+	private:
+		void SearchForKeywords();
+		void SearchForEpisodeNumber();
+		void SearchForAnimeTitle();
+		void SearchForReleaseGroup();
+		void SearchForEpisodeTitle();
+		void SearchForIsolatedNumbers();
+		void ValidateElements();
+
+		bool SearchForEpisodePatterns(std::vector<size_t>& tokens);
+		bool SearchForEquivalentNumbers(std::vector<size_t>& tokens);
+		bool SearchForIsolatedNumbers(std::vector<size_t>& tokens);
+		bool SearchForSeparatedNumbers(std::vector<size_t>& tokens);
+		bool SearchForLastNumber(std::vector<size_t>& tokens);
+
+		bool NumberComesAfterPrefix(ElementCategory category, Token& token);
+		bool NumberComesBeforeAnotherNumber(const token_iterator_t token);
+
+		bool MatchEpisodePatterns(string_t word, Token& token);
+		bool MatchSingleEpisodePattern(const string_t& word, Token& token);
+		bool MatchMultiEpisodePattern(const string_t& word, Token& token);
+		bool MatchSeasonAndEpisodePattern(const string_t& word, Token& token);
+		bool MatchTypeAndEpisodePattern(const string_t& word, Token& token);
+		bool MatchFractionalEpisodePattern(const string_t& word, Token& token);
+		bool MatchPartialEpisodePattern(const string_t& word, Token& token);
+		bool MatchNumberSignPattern(const string_t& word, Token& token);
+		bool MatchJapaneseCounterPattern(const string_t& word, Token& token);
+
+		bool MatchVolumePatterns(string_t word, Token& token);
+		bool MatchSingleVolumePattern(const string_t& word, Token& token);
+		bool MatchMultiVolumePattern(const string_t& word, Token& token);
+
+		bool IsValidEpisodeNumber(const string_t& number);
+		bool SetEpisodeNumber(const string_t& number, Token& token, bool validate);
+		bool SetAlternativeEpisodeNumber(const string_t& number, Token& token);
+
+		bool IsValidVolumeNumber(const string_t& number);
+		bool SetVolumeNumber(const string_t& number, Token& token, bool validate);
+
+		size_t FindNumberInString(const string_t& str);
+		string_t GetNumberFromOrdinal(const string_t& word);
+		bool IsCrc32(const string_t& str);
+		bool IsDashCharacter(const string_t& str);
+		bool IsResolution(const string_t& str);
+		bool IsElementCategorySearchable(ElementCategory category);
+		bool IsElementCategorySingular(ElementCategory category);
+
+		bool CheckAnimeSeasonKeyword(const token_iterator_t token);
+		bool CheckExtentKeyword(ElementCategory category, const token_iterator_t token);
+
+		void BuildElement(ElementCategory category, bool keep_delimiters, const token_iterator_t token_begin,
+						  const token_iterator_t token_end) const;
+
+		bool CheckTokenCategory(const token_iterator_t token, TokenCategory category) const;
+		bool IsTokenIsolated(const token_iterator_t token) const;
+
+		const int kAnimeYearMin = 1900;
+		const int kAnimeYearMax = 2050;
+		const int kEpisodeNumberMax = kAnimeYearMin - 1;
+		const int kVolumeNumberMax = 20;
+
+		Elements& elements_;
+		const Options& options_;
+		token_container_t& tokens_;
+
+		bool found_episode_keywords_ = false;
+};
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/parser_helper.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,244 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include <algorithm>
+#include <regex>
+
+#include "keyword.h"
+#include "parser.h"
+#include "string.h"
+
+namespace anitomy {
+
+const string_t kDashes = L"-\u2010\u2011\u2012\u2013\u2014\u2015";
+const string_t kDashesWithSpace = L" -\u2010\u2011\u2012\u2013\u2014\u2015";
+
+size_t Parser::FindNumberInString(const string_t& str) {
+	auto it = std::find_if(str.begin(), str.end(), IsNumericChar);
+	return it == str.end() ? str.npos : (it - str.begin());
+}
+
+string_t Parser::GetNumberFromOrdinal(const string_t& word) {
+	static const std::map<string_t, string_t> ordinals{
+		{L"1st",	 L"1"},
+		{L"First",   L"1"},
+		{L"2nd",	 L"2"},
+		{L"Second",	L"2"},
+		   {L"3rd",		L"3"},
+		   {L"Third",	  L"3"},
+		{L"4th",	 L"4"},
+		{L"Fourth",	L"4"},
+		{L"5th",	 L"5"},
+		{L"Fifth",   L"5"},
+		   {L"6th",		L"6"},
+		   {L"Sixth",	  L"6"},
+		{L"7th",	 L"7"},
+		{L"Seventh", L"7"},
+		{L"8th",	 L"8"},
+		{L"Eighth",	L"8"},
+		   {L"9th",		L"9"},
+		   {L"Ninth",	  L"9"},
+	};
+
+	auto it = ordinals.find(word);
+	return it != ordinals.end() ? it->second : string_t();
+}
+
+bool Parser::IsCrc32(const string_t& str) {
+	return str.size() == 8 && IsHexadecimalString(str);
+}
+
+bool Parser::IsDashCharacter(const string_t& str) {
+	if (str.size() != 1)
+		return false;
+
+	auto result = std::find(kDashes.begin(), kDashes.end(), str.front());
+	return result != kDashes.end();
+}
+
+bool Parser::IsResolution(const string_t& str) {
+	// Using a regex such as "\\d{3,4}(p|(x\\d{3,4}))$" would be more elegant,
+	// but it's much slower (e.g. 2.4ms -> 24.9ms).
+
+	const size_t min_width_size = 3;
+	const size_t min_height_size = 3;
+
+	// *###x###*
+	if (str.size() >= min_width_size + 1 + min_height_size) {
+		size_t pos = str.find_first_of(L"xX\u00D7"); // multiplication sign
+		if (pos != str.npos && pos >= min_width_size && pos <= str.size() - (min_height_size + 1)) {
+			for (size_t i = 0; i < str.size(); i++)
+				if (i != pos && !IsNumericChar(str.at(i)))
+					return false;
+			return true;
+		}
+
+		// *###p
+	} else if (str.size() >= min_height_size + 1) {
+		if (str.back() == L'p' || str.back() == L'P') {
+			for (size_t i = 0; i < str.size() - 1; i++)
+				if (!IsNumericChar(str.at(i)))
+					return false;
+			return true;
+		}
+	}
+
+	return false;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool Parser::CheckAnimeSeasonKeyword(const token_iterator_t token) {
+	auto set_anime_season = [&](token_iterator_t first, token_iterator_t second, const string_t& content) {
+		elements_.insert(kElementAnimeSeason, content);
+		first->category = kIdentifier;
+		second->category = kIdentifier;
+	};
+
+	auto previous_token = FindPreviousToken(tokens_, token, kFlagNotDelimiter);
+	if (previous_token != tokens_.end()) {
+		auto number = GetNumberFromOrdinal(previous_token->content);
+		if (!number.empty()) {
+			set_anime_season(previous_token, token, number);
+			return true;
+		}
+	}
+
+	auto next_token = FindNextToken(tokens_, token, kFlagNotDelimiter);
+	if (next_token != tokens_.end() && IsNumericString(next_token->content)) {
+		set_anime_season(token, next_token, next_token->content);
+		return true;
+	}
+
+	return false;
+}
+
+bool Parser::CheckExtentKeyword(ElementCategory category, const token_iterator_t token) {
+	auto next_token = FindNextToken(tokens_, token, kFlagNotDelimiter);
+
+	if (CheckTokenCategory(next_token, kUnknown)) {
+		if (FindNumberInString(next_token->content) == 0) {
+			switch (category) {
+				case kElementEpisodeNumber:
+					if (!MatchEpisodePatterns(next_token->content, *next_token))
+						SetEpisodeNumber(next_token->content, *next_token, false);
+					break;
+				case kElementVolumeNumber:
+					if (!MatchVolumePatterns(next_token->content, *next_token))
+						SetVolumeNumber(next_token->content, *next_token, false);
+					break;
+				default: return false;
+			}
+			token->category = kIdentifier;
+			return true;
+		}
+	}
+
+	return false;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool Parser::IsElementCategorySearchable(ElementCategory category) {
+	switch (category) {
+		case kElementAnimeSeasonPrefix:
+		case kElementAnimeType:
+		case kElementAudioTerm:
+		case kElementDeviceCompatibility:
+		case kElementEpisodePrefix:
+		case kElementFileChecksum:
+		case kElementLanguage:
+		case kElementOther:
+		case kElementReleaseGroup:
+		case kElementReleaseInformation:
+		case kElementReleaseVersion:
+		case kElementSource:
+		case kElementSubtitles:
+		case kElementVideoResolution:
+		case kElementVideoTerm:
+		case kElementVolumePrefix: return true;
+		default: break;
+	}
+
+	return false;
+}
+
+bool Parser::IsElementCategorySingular(ElementCategory category) {
+	switch (category) {
+		case kElementAnimeSeason:
+		case kElementAnimeType:
+		case kElementAudioTerm:
+		case kElementDeviceCompatibility:
+		case kElementEpisodeNumber:
+		case kElementLanguage:
+		case kElementOther:
+		case kElementReleaseInformation:
+		case kElementSource:
+		case kElementVideoTerm: return false;
+		default: break;
+	}
+
+	return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void Parser::BuildElement(ElementCategory category, bool keep_delimiters, const token_iterator_t token_begin,
+						  const token_iterator_t token_end) const {
+	string_t element;
+
+	for (auto token = token_begin; token != token_end; ++token) {
+		switch (token->category) {
+			case kUnknown:
+				element += token->content;
+				token->category = kIdentifier;
+				break;
+			case kBracket: element += token->content; break;
+			case kDelimiter: {
+				auto delimiter = token->content.front();
+				if (keep_delimiters) {
+					element.push_back(delimiter);
+				} else if (token != token_begin && token != token_end) {
+					switch (delimiter) {
+						case L',':
+						case L'&': element.push_back(delimiter); break;
+						default: element.push_back(L' '); break;
+					}
+				}
+				break;
+			}
+			default: break;
+		}
+	}
+
+	if (!keep_delimiters)
+		TrimString(element, kDashesWithSpace.c_str());
+
+	if (!element.empty())
+		elements_.insert(category, element);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool Parser::CheckTokenCategory(const token_iterator_t token, TokenCategory category) const {
+	return token != tokens_.end() && token->category == category;
+}
+
+bool Parser::IsTokenIsolated(const token_iterator_t token) const {
+	auto previous_token = FindPreviousToken(tokens_, token, kFlagNotDelimiter);
+	if (!CheckTokenCategory(previous_token, kBracket))
+		return false;
+
+	auto next_token = FindNextToken(tokens_, token, kFlagNotDelimiter);
+	if (!CheckTokenCategory(next_token, kBracket))
+		return false;
+
+	return true;
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/parser_number.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,506 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include <algorithm>
+#include <regex>
+
+#include "element.h"
+#include "keyword.h"
+#include "parser.h"
+#include "string.h"
+
+namespace anitomy {
+
+bool Parser::IsValidEpisodeNumber(const string_t& number) {
+	return StringToInt(number) <= kEpisodeNumberMax;
+}
+
+bool Parser::SetEpisodeNumber(const string_t& number, Token& token, bool validate) {
+	if (validate && !IsValidEpisodeNumber(number))
+		return false;
+
+	token.category = kIdentifier;
+
+	auto category = kElementEpisodeNumber;
+
+	// Handle equivalent numbers
+	if (found_episode_keywords_) {
+		for (auto& element : elements_) {
+			if (element.first != kElementEpisodeNumber)
+				continue;
+			// The larger number gets to be the alternative one
+			const int comparison = StringToInt(number) - StringToInt(element.second);
+			if (comparison > 0) {
+				category = kElementEpisodeNumberAlt;
+			} else if (comparison < 0) {
+				element.first = kElementEpisodeNumberAlt;
+			} else {
+				return false; // No need to add the same number twice
+			}
+			break;
+		}
+	}
+
+	elements_.insert(category, number);
+	return true;
+}
+
+bool Parser::SetAlternativeEpisodeNumber(const string_t& number, Token& token) {
+	elements_.insert(kElementEpisodeNumberAlt, number);
+	token.category = kIdentifier;
+
+	return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool Parser::IsValidVolumeNumber(const string_t& number) {
+	return StringToInt(number) <= kVolumeNumberMax;
+}
+
+bool Parser::SetVolumeNumber(const string_t& number, Token& token, bool validate) {
+	if (validate)
+		if (!IsValidVolumeNumber(number))
+			return false;
+
+	elements_.insert(kElementVolumeNumber, number);
+	token.category = kIdentifier;
+	return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool Parser::NumberComesAfterPrefix(ElementCategory category, Token& token) {
+	size_t number_begin = FindNumberInString(token.content);
+	auto prefix = keyword_manager.Normalize(token.content.substr(0, number_begin));
+
+	if (keyword_manager.Find(category, prefix)) {
+		auto number = token.content.substr(number_begin, token.content.length() - number_begin);
+		switch (category) {
+			case kElementEpisodePrefix:
+				if (!MatchEpisodePatterns(number, token))
+					SetEpisodeNumber(number, token, false);
+				return true;
+			case kElementVolumePrefix:
+				if (!MatchVolumePatterns(number, token))
+					SetVolumeNumber(number, token, false);
+				return true;
+			default: break;
+		}
+	}
+
+	return false;
+}
+
+bool Parser::NumberComesBeforeAnotherNumber(const token_iterator_t token) {
+	auto separator_token = FindNextToken(tokens_, token, kFlagNotDelimiter);
+
+	if (separator_token != tokens_.end()) {
+		static const std::vector<std::pair<string_t, bool>> separators{
+			{L"&",  true },
+			{L"of", false},
+		};
+		for (const auto& separator : separators) {
+			if (IsStringEqualTo(separator_token->content, separator.first)) {
+				auto other_token = FindNextToken(tokens_, separator_token, kFlagNotDelimiter);
+				if (other_token != tokens_.end() && IsNumericString(other_token->content)) {
+					SetEpisodeNumber(token->content, *token, false);
+					if (separator.second)
+						SetEpisodeNumber(other_token->content, *other_token, false);
+					separator_token->category = kIdentifier;
+					other_token->category = kIdentifier;
+					return true;
+				}
+			}
+		}
+	}
+
+	return false;
+}
+
+bool Parser::SearchForEpisodePatterns(std::vector<size_t>& tokens) {
+	for (const auto& token_index : tokens) {
+		auto token = tokens_.begin() + token_index;
+		bool numeric_front = IsNumericChar(token->content.front());
+
+		if (!numeric_front) {
+			// e.g. "EP.1", "Vol.1"
+			if (NumberComesAfterPrefix(kElementEpisodePrefix, *token))
+				return true;
+			if (NumberComesAfterPrefix(kElementVolumePrefix, *token))
+				continue;
+		} else {
+			// e.g. "8 & 10", "01 of 24"
+			if (NumberComesBeforeAnotherNumber(token))
+				return true;
+		}
+		// Look for other patterns
+		if (MatchEpisodePatterns(token->content, *token))
+			return true;
+	}
+
+	return false;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+using regex_t = std::basic_regex<char_t>;
+using regex_match_results_t = std::match_results<string_t::const_iterator>;
+
+bool Parser::MatchSingleEpisodePattern(const string_t& word, Token& token) {
+	static const regex_t pattern(L"(\\d{1,4})[vV](\\d)");
+	regex_match_results_t match_results;
+
+	if (std::regex_match(word, match_results, pattern)) {
+		SetEpisodeNumber(match_results[1].str(), token, false);
+		elements_.insert(kElementReleaseVersion, match_results[2].str());
+		return true;
+	}
+
+	return false;
+}
+
+bool Parser::MatchMultiEpisodePattern(const string_t& word, Token& token) {
+	static const regex_t pattern(L"(\\d{1,4})(?:[vV](\\d))?[-~&+]"
+								 L"(\\d{1,4})(?:[vV](\\d))?");
+	regex_match_results_t match_results;
+
+	if (std::regex_match(word, match_results, pattern)) {
+		auto lower_bound = match_results[1].str();
+		auto upper_bound = match_results[3].str();
+		// Avoid matching expressions such as "009-1" or "5-2"
+		if (StringToInt(lower_bound) < StringToInt(upper_bound)) {
+			if (SetEpisodeNumber(lower_bound, token, true)) {
+				SetEpisodeNumber(upper_bound, token, false);
+				if (match_results[2].matched)
+					elements_.insert(kElementReleaseVersion, match_results[2].str());
+				if (match_results[4].matched)
+					elements_.insert(kElementReleaseVersion, match_results[4].str());
+				return true;
+			}
+		}
+	}
+
+	return false;
+}
+
+bool Parser::MatchSeasonAndEpisodePattern(const string_t& word, Token& token) {
+	static const regex_t pattern(L"S?"
+								 L"(\\d{1,2})(?:-S?(\\d{1,2}))?"
+								 L"(?:x|[ ._-x]?E)"
+								 L"(\\d{1,4})(?:-E?(\\d{1,4}))?"
+								 L"(?:[vV](\\d))?",
+								 std::regex_constants::icase);
+	regex_match_results_t match_results;
+
+	if (std::regex_match(word, match_results, pattern)) {
+		if (StringToInt(match_results[1]) == 0)
+			return false;
+		elements_.insert(kElementAnimeSeason, match_results[1]);
+		if (match_results[2].matched)
+			elements_.insert(kElementAnimeSeason, match_results[2]);
+		SetEpisodeNumber(match_results[3], token, false);
+		if (match_results[4].matched)
+			SetEpisodeNumber(match_results[4], token, false);
+		return true;
+	}
+
+	return false;
+}
+
+bool Parser::MatchTypeAndEpisodePattern(const string_t& word, Token& token) {
+	size_t number_begin = FindNumberInString(word);
+	auto prefix = word.substr(0, number_begin);
+
+	ElementCategory category = kElementAnimeType;
+	KeywordOptions options;
+
+	if (keyword_manager.Find(keyword_manager.Normalize(prefix), category, options)) {
+		elements_.insert(kElementAnimeType, prefix);
+		auto number = word.substr(number_begin);
+		if (MatchEpisodePatterns(number, token) || SetEpisodeNumber(number, token, true)) {
+			auto it = std::find(tokens_.begin(), tokens_.end(), token);
+			if (it != tokens_.end()) {
+				// Split token (we do this last in order to avoid invalidating our
+				// token reference earlier)
+				token.content = number;
+				tokens_.insert(it, Token(options.identifiable ? kIdentifier : kUnknown, prefix, token.enclosed));
+			}
+			return true;
+		}
+	}
+
+	return false;
+}
+
+bool Parser::MatchFractionalEpisodePattern(const string_t& word, Token& token) {
+	// We don't allow any fractional part other than ".5", because there are cases
+	// where such a number is a part of the anime title (e.g. "Evangelion: 1.11",
+	// "Tokyo Magnitude 8.0") or a keyword (e.g. "5.1").
+	static const regex_t pattern(L"\\d+\\.5");
+
+	if (std::regex_match(word, pattern))
+		if (SetEpisodeNumber(word, token, true))
+			return true;
+
+	return false;
+}
+
+bool Parser::MatchPartialEpisodePattern(const string_t& word, Token& token) {
+	auto it = std::find_if_not(word.begin(), word.end(), IsNumericChar);
+	auto suffix_length = std::distance(it, word.end());
+
+	auto is_valid_suffix = [](const char_t c) { return (c >= L'A' && c <= L'C') || (c >= L'a' && c <= L'c'); };
+
+	if (suffix_length == 1 && is_valid_suffix(*it))
+		if (SetEpisodeNumber(word, token, true))
+			return true;
+
+	return false;
+}
+
+bool Parser::MatchNumberSignPattern(const string_t& word, Token& token) {
+	if (word.front() != L'#')
+		return false;
+
+	static const regex_t pattern(L"#(\\d{1,4})(?:[-~&+](\\d{1,4}))?(?:[vV](\\d))?");
+	regex_match_results_t match_results;
+
+	if (std::regex_match(word, match_results, pattern)) {
+		if (SetEpisodeNumber(match_results[1].str(), token, true)) {
+			if (match_results[2].matched)
+				SetEpisodeNumber(match_results[2].str(), token, false);
+			if (match_results[3].matched)
+				elements_.insert(kElementReleaseVersion, match_results[3].str());
+			return true;
+		}
+	}
+
+	return false;
+}
+
+bool Parser::MatchJapaneseCounterPattern(const string_t& word, Token& token) {
+	if (word.back() != L'\u8A71')
+		return false;
+
+	static const regex_t pattern(L"(\\d{1,4})\u8A71");
+	regex_match_results_t match_results;
+
+	if (std::regex_match(word, match_results, pattern)) {
+		SetEpisodeNumber(match_results[1].str(), token, false);
+		return true;
+	}
+
+	return false;
+}
+
+bool Parser::MatchEpisodePatterns(string_t word, Token& token) {
+	// All patterns contain at least one non-numeric character
+	if (IsNumericString(word))
+		return false;
+
+	TrimString(word, L" -");
+
+	const bool numeric_front = IsNumericChar(word.front());
+	const bool numeric_back = IsNumericChar(word.back());
+
+	// e.g. "01v2"
+	if (numeric_front && numeric_back)
+		if (MatchSingleEpisodePattern(word, token))
+			return true;
+	// e.g. "01-02", "03-05v2"
+	if (numeric_front && numeric_back)
+		if (MatchMultiEpisodePattern(word, token))
+			return true;
+	// e.g. "2x01", "S01E03", "S01-02xE001-150"
+	if (numeric_back)
+		if (MatchSeasonAndEpisodePattern(word, token))
+			return true;
+	// e.g. "ED1", "OP4a", "OVA2"
+	if (!numeric_front)
+		if (MatchTypeAndEpisodePattern(word, token))
+			return true;
+	// e.g. "07.5"
+	if (numeric_front && numeric_back)
+		if (MatchFractionalEpisodePattern(word, token))
+			return true;
+	// e.g. "4a", "111C"
+	if (numeric_front && !numeric_back)
+		if (MatchPartialEpisodePattern(word, token))
+			return true;
+	// e.g. "#01", "#02-03v2"
+	if (numeric_back)
+		if (MatchNumberSignPattern(word, token))
+			return true;
+	// U+8A71 is used as counter for stories, episodes of TV series, etc.
+	if (numeric_front)
+		if (MatchJapaneseCounterPattern(word, token))
+			return true;
+
+	return false;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool Parser::MatchSingleVolumePattern(const string_t& word, Token& token) {
+	static const regex_t pattern(L"(\\d{1,2})[vV](\\d)");
+	regex_match_results_t match_results;
+
+	if (std::regex_match(word, match_results, pattern)) {
+		SetVolumeNumber(match_results[1].str(), token, false);
+		elements_.insert(kElementReleaseVersion, match_results[2].str());
+		return true;
+	}
+
+	return false;
+}
+
+bool Parser::MatchMultiVolumePattern(const string_t& word, Token& token) {
+	static const regex_t pattern(L"(\\d{1,2})[-~&+](\\d{1,2})(?:[vV](\\d))?");
+	regex_match_results_t match_results;
+
+	if (std::regex_match(word, match_results, pattern)) {
+		auto lower_bound = match_results[1].str();
+		auto upper_bound = match_results[2].str();
+		if (StringToInt(lower_bound) < StringToInt(upper_bound)) {
+			if (SetVolumeNumber(lower_bound, token, true)) {
+				SetVolumeNumber(upper_bound, token, false);
+				if (match_results[3].matched)
+					elements_.insert(kElementReleaseVersion, match_results[3].str());
+				return true;
+			}
+		}
+	}
+
+	return false;
+}
+
+bool Parser::MatchVolumePatterns(string_t word, Token& token) {
+	// All patterns contain at least one non-numeric character
+	if (IsNumericString(word))
+		return false;
+
+	TrimString(word, L" -");
+
+	const bool numeric_front = IsNumericChar(word.front());
+	const bool numeric_back = IsNumericChar(word.back());
+
+	// e.g. "01v2"
+	if (numeric_front && numeric_back)
+		if (MatchSingleVolumePattern(word, token))
+			return true;
+	// e.g. "01-02", "03-05v2"
+	if (numeric_front && numeric_back)
+		if (MatchMultiVolumePattern(word, token))
+			return true;
+
+	return false;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool Parser::SearchForEquivalentNumbers(std::vector<size_t>& tokens) {
+	for (auto token_index = tokens.begin(); token_index != tokens.end(); ++token_index) {
+		auto token = tokens_.begin() + *token_index;
+
+		if (IsTokenIsolated(token) || !IsValidEpisodeNumber(token->content))
+			continue;
+
+		// Find the first enclosed, non-delimiter token
+		auto next_token = FindNextToken(tokens_, token, kFlagNotDelimiter);
+		if (!CheckTokenCategory(next_token, kBracket))
+			continue;
+		next_token = FindNextToken(tokens_, next_token, kFlagEnclosed | kFlagNotDelimiter);
+		if (!CheckTokenCategory(next_token, kUnknown))
+			continue;
+
+		// Check if it's an isolated number
+		if (!IsTokenIsolated(next_token) || !IsNumericString(next_token->content) ||
+			!IsValidEpisodeNumber(next_token->content))
+			continue;
+
+		auto minmax = std::minmax(token, next_token, [](const token_iterator_t& a, const token_iterator_t& b) {
+			return StringToInt(a->content) < StringToInt(b->content);
+		});
+		SetEpisodeNumber(minmax.first->content, *minmax.first, false);
+		SetAlternativeEpisodeNumber(minmax.second->content, *minmax.second);
+
+		return true;
+	}
+
+	return false;
+}
+
+bool Parser::SearchForIsolatedNumbers(std::vector<size_t>& tokens) {
+	for (auto token_index = tokens.begin(); token_index != tokens.end(); ++token_index) {
+		auto token = tokens_.begin() + *token_index;
+
+		if (!token->enclosed || !IsTokenIsolated(token))
+			continue;
+
+		if (SetEpisodeNumber(token->content, *token, true))
+			return true;
+	}
+
+	return false;
+}
+
+bool Parser::SearchForSeparatedNumbers(std::vector<size_t>& tokens) {
+	for (auto token_index = tokens.begin(); token_index != tokens.end(); ++token_index) {
+		auto token = tokens_.begin() + *token_index;
+		auto previous_token = FindPreviousToken(tokens_, token, kFlagNotDelimiter);
+
+		// See if the number has a preceding "-" separator
+		if (CheckTokenCategory(previous_token, kUnknown) && IsDashCharacter(previous_token->content)) {
+			if (SetEpisodeNumber(token->content, *token, true)) {
+				previous_token->category = kIdentifier;
+				return true;
+			}
+		}
+	}
+
+	return false;
+}
+
+bool Parser::SearchForLastNumber(std::vector<size_t>& tokens) {
+	for (auto it = tokens.rbegin(); it != tokens.rend(); ++it) {
+		size_t token_index = *it;
+		auto token = tokens_.begin() + token_index;
+
+		// Assuming that episode number always comes after the title, first token
+		// cannot be what we're looking for
+		if (token_index == 0)
+			continue;
+
+		// An enclosed token is unlikely to be the episode number at this point
+		if (token->enclosed)
+			continue;
+
+		// Ignore if it's the first non-enclosed, non-delimiter token
+		if (std::all_of(tokens_.begin(), token,
+						[](const Token& token) { return token.enclosed || token.category == kDelimiter; }))
+			continue;
+
+		// Ignore if the previous token is "Movie" or "Part"
+		auto previous_token = FindPreviousToken(tokens_, token, kFlagNotDelimiter);
+		if (CheckTokenCategory(previous_token, kUnknown)) {
+			if (IsStringEqualTo(previous_token->content, L"Movie") ||
+				IsStringEqualTo(previous_token->content, L"Part")) {
+				continue;
+			}
+		}
+
+		// We'll use this number after all
+		if (SetEpisodeNumber(token->content, *token, true))
+			return true;
+	}
+
+	return false;
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/string.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,124 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include <algorithm>
+#include <cwctype>
+#include <functional>
+
+#include "string.h"
+
+namespace anitomy {
+
+bool IsAlphanumericChar(const char_t c) {
+	return (c >= L'0' && c <= L'9') || (c >= L'A' && c <= L'Z') || (c >= L'a' && c <= L'z');
+}
+
+bool IsHexadecimalChar(const char_t c) {
+	return (c >= L'0' && c <= L'9') || (c >= L'A' && c <= L'F') || (c >= L'a' && c <= L'f');
+}
+
+bool IsLatinChar(const char_t c) {
+	// We're just checking until the end of Latin Extended-B block, rather than
+	// all the blocks that belong to the Latin script.
+	return c <= L'\u024F';
+}
+
+bool IsNumericChar(const char_t c) {
+	return c >= L'0' && c <= L'9';
+}
+
+bool IsAlphanumericString(const string_t& str) {
+	return !str.empty() && std::all_of(str.begin(), str.end(), IsAlphanumericChar);
+}
+
+bool IsHexadecimalString(const string_t& str) {
+	return !str.empty() && std::all_of(str.begin(), str.end(), IsHexadecimalChar);
+}
+
+bool IsMostlyLatinString(const string_t& str) {
+	double length = str.empty() ? 1.0 : str.length();
+	return std::count_if(str.begin(), str.end(), IsLatinChar) / length >= 0.5;
+}
+
+bool IsNumericString(const string_t& str) {
+	return !str.empty() && std::all_of(str.begin(), str.end(), IsNumericChar);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+inline wchar_t ToLower(const wchar_t c) {
+	return (c >= L'a' && c <= L'z')	  ? c
+		   : (c >= L'A' && c <= L'Z') ? (c + (L'a' - L'A'))
+									  : static_cast<wchar_t>(std::towlower(c));
+}
+
+inline wchar_t ToUpper(const wchar_t c) {
+	return (c >= L'A' && c <= L'Z')	  ? c
+		   : (c >= L'a' && c <= L'z') ? (c + (L'A' - L'a'))
+									  : static_cast<wchar_t>(std::towupper(c));
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+bool IsInString(const string_t& str1, const string_t& str2) {
+	return std::search(str1.begin(), str1.end(), str2.begin(), str2.end()) != str1.end();
+}
+
+inline bool IsCharEqualTo(const char_t c1, const char_t c2) {
+	return ToLower(c1) == ToLower(c2);
+}
+
+bool IsStringEqualTo(const string_t& str1, const string_t& str2) {
+	return str1.size() == str2.size() && std::equal(str1.begin(), str1.end(), str2.begin(), IsCharEqualTo);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+int StringToInt(const string_t& str) {
+	return static_cast<int>(std::wcstol(str.c_str(), nullptr, 10));
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void EraseString(string_t& str, const string_t& erase_this) {
+	if (erase_this.empty() || str.size() < erase_this.size())
+		return;
+
+	auto pos = str.find(erase_this);
+	while (pos != string_t::npos) {
+		str.erase(pos, erase_this.size());
+		pos = str.find(erase_this);
+	}
+}
+
+void StringToUpper(string_t& str) {
+	std::transform(str.begin(), str.end(), str.begin(), ToUpper);
+}
+
+string_t StringToUpperCopy(string_t str) {
+	StringToUpper(str);
+	return str;
+}
+
+void TrimString(string_t& str, const char_t trim_chars[]) {
+	if (str.empty())
+		return;
+
+	const auto pos_begin = str.find_first_not_of(trim_chars);
+	const auto pos_end = str.find_last_not_of(trim_chars);
+
+	if (pos_begin == string_t::npos || pos_end == string_t::npos) {
+		str.clear();
+		return;
+	}
+
+	str.erase(pos_end + 1, str.length() - pos_end + 1);
+	str.erase(0, pos_begin);
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/string.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,36 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#pragma once
+
+#include <string>
+
+namespace anitomy {
+
+using char_t = wchar_t;
+using string_t = std::basic_string<char_t>;
+
+bool IsAlphanumericChar(const char_t c);
+bool IsHexadecimalChar(const char_t c);
+bool IsLatinChar(const char_t c);
+bool IsNumericChar(const char_t c);
+bool IsAlphanumericString(const string_t& str);
+bool IsHexadecimalString(const string_t& str);
+bool IsMostlyLatinString(const string_t& str);
+bool IsNumericString(const string_t& str);
+
+bool IsInString(const string_t& str1, const string_t& str2);
+bool IsStringEqualTo(const string_t& str1, const string_t& str2);
+
+int StringToInt(const string_t& str);
+
+void EraseString(string_t& str, const string_t& erase_this);
+string_t StringToUpperCopy(string_t str);
+void TrimString(string_t& str, const char_t trim_chars[] = L" ");
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/token.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,76 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include <algorithm>
+
+#include "token.h"
+
+namespace anitomy {
+
+Token::Token() : category(kUnknown), enclosed(false) {
+}
+
+Token::Token(TokenCategory category, const string_t& content, bool enclosed)
+	: category(category), content(content), enclosed(enclosed) {
+}
+
+bool Token::operator==(const Token& token) const {
+	return category == token.category && content == token.content && enclosed == token.enclosed;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+static bool CheckTokenFlags(const Token& token, unsigned int flags) {
+	auto check_flag = [&flags](unsigned int flag) { return (flags & flag) == flag; };
+
+	if (flags & kFlagMaskEnclosed) {
+		bool success = check_flag(kFlagEnclosed) ? token.enclosed : !token.enclosed;
+		if (!success)
+			return false;
+	}
+
+	if (flags & kFlagMaskCategories) {
+		bool success = false;
+		auto check_category = [&](TokenFlag fe, TokenFlag fn, TokenCategory c) {
+			if (!success)
+				success = check_flag(fe) ? token.category == c : check_flag(fn) ? token.category != c : false;
+		};
+		check_category(kFlagBracket, kFlagNotBracket, kBracket);
+		check_category(kFlagDelimiter, kFlagNotDelimiter, kDelimiter);
+		check_category(kFlagIdentifier, kFlagNotIdentifier, kIdentifier);
+		check_category(kFlagUnknown, kFlagNotUnknown, kUnknown);
+		check_category(kFlagNotValid, kFlagValid, kInvalid);
+		if (!success)
+			return false;
+	}
+
+	return true;
+}
+
+template <class iterator_t> static iterator_t FindTokenBase(iterator_t first, iterator_t last, unsigned int flags) {
+	return std::find_if(first, last, [&](const Token& token) { return CheckTokenFlags(token, flags); });
+}
+
+token_iterator_t FindToken(token_iterator_t first, token_iterator_t last, unsigned int flags) {
+	return FindTokenBase(first, last, flags);
+}
+
+token_reverse_iterator_t FindToken(token_reverse_iterator_t first, token_reverse_iterator_t last, unsigned int flags) {
+	return FindTokenBase(first, last, flags);
+}
+
+token_iterator_t FindPreviousToken(token_container_t& tokens, token_iterator_t first, unsigned int flags) {
+	auto it = FindToken(std::reverse_iterator<token_iterator_t>(first), tokens.rend(), flags);
+	return it == tokens.rend() ? tokens.end() : (++it).base();
+}
+
+token_iterator_t FindNextToken(token_container_t& tokens, token_iterator_t first, unsigned int flags) {
+	return FindToken(++first, tokens.end(), flags);
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/token.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,67 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#pragma once
+
+#include <vector>
+
+#include "string.h"
+
+namespace anitomy {
+
+enum TokenCategory { kUnknown, kBracket, kDelimiter, kIdentifier, kInvalid };
+
+enum TokenFlag {
+	kFlagNone,
+	// Categories
+	kFlagBracket = 1 << 0,
+	kFlagNotBracket = 1 << 1,
+	kFlagDelimiter = 1 << 2,
+	kFlagNotDelimiter = 1 << 3,
+	kFlagIdentifier = 1 << 4,
+	kFlagNotIdentifier = 1 << 5,
+	kFlagUnknown = 1 << 6,
+	kFlagNotUnknown = 1 << 7,
+	kFlagValid = 1 << 8,
+	kFlagNotValid = 1 << 9,
+	// Enclosed
+	kFlagEnclosed = 1 << 10,
+	kFlagNotEnclosed = 1 << 11,
+	// Masks
+	kFlagMaskCategories = kFlagBracket | kFlagNotBracket | kFlagDelimiter | kFlagNotDelimiter | kFlagIdentifier |
+						  kFlagNotIdentifier | kFlagUnknown | kFlagNotUnknown | kFlagValid | kFlagNotValid,
+	kFlagMaskEnclosed = kFlagEnclosed | kFlagNotEnclosed,
+};
+
+struct TokenRange {
+		size_t offset = 0;
+		size_t size = 0;
+};
+
+class Token {
+	public:
+		Token();
+		Token(TokenCategory category, const string_t& content, bool enclosed);
+
+		bool operator==(const Token& token) const;
+
+		TokenCategory category;
+		string_t content;
+		bool enclosed;
+};
+
+using token_container_t = std::vector<Token>;
+using token_iterator_t = token_container_t::iterator;
+using token_reverse_iterator_t = token_container_t::reverse_iterator;
+
+token_iterator_t FindToken(token_iterator_t first, token_iterator_t last, unsigned int flags);
+token_reverse_iterator_t FindToken(token_reverse_iterator_t first, token_reverse_iterator_t last, unsigned int flags);
+token_iterator_t FindPreviousToken(token_container_t& tokens, token_iterator_t first, unsigned int flags);
+token_iterator_t FindNextToken(token_container_t& tokens, token_iterator_t first, unsigned int flags);
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/tokenizer.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,240 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#include <algorithm>
+#include <iterator>
+#include <set>
+
+#include "keyword.h"
+#include "string.h"
+#include "tokenizer.h"
+
+namespace anitomy {
+
+Tokenizer::Tokenizer(const string_t& filename, Elements& elements, const Options& options, token_container_t& tokens)
+	: elements_(elements), filename_(filename), options_(options), tokens_(tokens) {
+}
+
+bool Tokenizer::Tokenize() {
+	tokens_.reserve(32); // Usually there are no more than 20 tokens
+
+	TokenizeByBrackets();
+
+	return !tokens_.empty();
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void Tokenizer::AddToken(TokenCategory category, bool enclosed, const TokenRange& range) {
+	tokens_.push_back(Token(category, filename_.substr(range.offset, range.size), enclosed));
+}
+
+void Tokenizer::TokenizeByBrackets() {
+	static const std::vector<std::pair<char_t, char_t>> brackets{
+		{L'(',	   L')'	   }, // U+0028-U+0029 Parenthesis
+		{L'[',	   L']'	   }, // U+005B-U+005D Square bracket
+		{L'{',	   L'}'	   }, // U+007B-U+007D Curly bracket
+		{L'\u300C', L'\u300D'}, // Corner bracket
+		{L'\u300E', L'\u300F'}, // White corner bracket
+		{L'\u3010', L'\u3011'}, // Black lenticular bracket
+		{L'\uFF08', L'\uFF09'}, // Fullwidth parenthesis
+	};
+
+	bool is_bracket_open = false;
+	char_t matching_bracket = L'\0';
+
+	auto char_begin = filename_.begin();
+	const auto char_end = filename_.end();
+
+	// This is basically std::find_first_of() customized to our needs
+	auto find_first_bracket = [&]() -> string_t::const_iterator {
+		for (auto it = char_begin; it != char_end; ++it) {
+			for (const auto& bracket_pair : brackets) {
+				if (*it == bracket_pair.first) {
+					matching_bracket = bracket_pair.second;
+					return it;
+				}
+			}
+		}
+		return char_end;
+	};
+
+	auto current_char = char_begin;
+
+	while (current_char != char_end && char_begin != char_end) {
+		if (!is_bracket_open) {
+			current_char = find_first_bracket();
+		} else {
+			// Looking for the matching bracket allows us to better handle some rare
+			// cases with nested brackets.
+			current_char = std::find(char_begin, char_end, matching_bracket);
+		}
+
+		const TokenRange range{static_cast<size_t>(std::distance(filename_.begin(), char_begin)),
+							   static_cast<size_t>(std::distance(char_begin, current_char))};
+
+		if (range.size > 0) // Found unknown token
+			TokenizeByPreidentified(is_bracket_open, range);
+
+		if (current_char != char_end) { // Found bracket
+			AddToken(kBracket, true, TokenRange{range.offset + range.size, 1});
+			is_bracket_open = !is_bracket_open;
+			char_begin = ++current_char;
+		}
+	}
+}
+
+void Tokenizer::TokenizeByPreidentified(bool enclosed, const TokenRange& range) {
+	std::vector<TokenRange> preidentified_tokens;
+	keyword_manager.Peek(filename_, range, elements_, preidentified_tokens);
+
+	size_t offset = range.offset;
+	TokenRange subrange{range.offset, 0};
+
+	while (offset < range.offset + range.size) {
+		for (const auto& preidentified_token : preidentified_tokens) {
+			if (offset == preidentified_token.offset) {
+				if (subrange.size > 0)
+					TokenizeByDelimiters(enclosed, subrange);
+				AddToken(kIdentifier, enclosed, preidentified_token);
+				subrange.offset = preidentified_token.offset + preidentified_token.size;
+				offset = subrange.offset - 1; // It's going to be incremented below
+				break;
+			}
+		}
+		subrange.size = ++offset - subrange.offset;
+	}
+
+	// Either there was no preidentified token range, or we're now about to
+	// process the tail of our current range.
+	if (subrange.size > 0)
+		TokenizeByDelimiters(enclosed, subrange);
+}
+
+void Tokenizer::TokenizeByDelimiters(bool enclosed, const TokenRange& range) {
+	const string_t delimiters = GetDelimiters(range);
+
+	if (delimiters.empty()) {
+		AddToken(kUnknown, enclosed, range);
+		return;
+	}
+
+	auto char_begin = filename_.begin() + range.offset;
+	const auto char_end = char_begin + range.size;
+	auto current_char = char_begin;
+
+	while (current_char != char_end) {
+		current_char = std::find_first_of(current_char, char_end, delimiters.begin(), delimiters.end());
+
+		const TokenRange subrange{static_cast<size_t>(std::distance(filename_.begin(), char_begin)),
+								  static_cast<size_t>(std::distance(char_begin, current_char))};
+
+		if (subrange.size > 0) // Found unknown token
+			AddToken(kUnknown, enclosed, subrange);
+
+		if (current_char != char_end) { // Found delimiter
+			AddToken(kDelimiter, enclosed, TokenRange{subrange.offset + subrange.size, 1});
+			char_begin = ++current_char;
+		}
+	}
+
+	ValidateDelimiterTokens();
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+string_t Tokenizer::GetDelimiters(const TokenRange& range) const {
+	string_t delimiters;
+
+	auto is_delimiter = [&](const char_t& c) {
+		if (!IsAlphanumericChar(c))
+			if (options_.allowed_delimiters.find(c) != string_t::npos)
+				if (delimiters.find(c) == string_t::npos)
+					return true;
+		return false;
+	};
+
+	std::copy_if(filename_.begin() + range.offset, filename_.begin() + range.offset + range.size,
+				 std::back_inserter(delimiters), is_delimiter);
+
+	return delimiters;
+}
+
+void Tokenizer::ValidateDelimiterTokens() {
+	auto is_delimiter_token = [&](token_iterator_t it) { return it != tokens_.end() && it->category == kDelimiter; };
+	auto is_unknown_token = [&](token_iterator_t it) { return it != tokens_.end() && it->category == kUnknown; };
+	auto is_single_character_token = [&](token_iterator_t it) {
+		return is_unknown_token(it) && it->content.size() == 1 && it->content.front() != L'-';
+	};
+	auto append_token_to = [](token_iterator_t token, token_iterator_t append_to) {
+		append_to->content.append(token->content);
+		token->category = kInvalid;
+	};
+
+	for (auto token = tokens_.begin(); token != tokens_.end(); ++token) {
+		if (token->category != kDelimiter)
+			continue;
+		auto delimiter = token->content.front();
+		auto prev_token = FindPreviousToken(tokens_, token, kFlagValid);
+		auto next_token = FindNextToken(tokens_, token, kFlagValid);
+
+		// Check for single-character tokens to prevent splitting group names,
+		// keywords, episode number, etc.
+		if (delimiter != L' ' && delimiter != L'_') {
+			if (is_single_character_token(prev_token)) {
+				append_token_to(token, prev_token);
+				while (is_unknown_token(next_token)) {
+					append_token_to(next_token, prev_token);
+					next_token = FindNextToken(tokens_, next_token, kFlagValid);
+					if (is_delimiter_token(next_token) && next_token->content.front() == delimiter) {
+						append_token_to(next_token, prev_token);
+						next_token = FindNextToken(tokens_, next_token, kFlagValid);
+					}
+				}
+				continue;
+			}
+			if (is_single_character_token(next_token)) {
+				append_token_to(token, prev_token);
+				append_token_to(next_token, prev_token);
+				continue;
+			}
+		}
+
+		// Check for adjacent delimiters
+		if (is_unknown_token(prev_token) && is_delimiter_token(next_token)) {
+			auto next_delimiter = next_token->content.front();
+			if (delimiter != next_delimiter && delimiter != ',') {
+				if (next_delimiter == ' ' || next_delimiter == '_') {
+					append_token_to(token, prev_token);
+				}
+			}
+		} else if (is_delimiter_token(prev_token) && is_delimiter_token(next_token)) {
+			const auto prev_delimiter = prev_token->content.front();
+			const auto next_delimiter = next_token->content.front();
+			if (prev_delimiter == next_delimiter && prev_delimiter != delimiter) {
+				token->category = kUnknown; // e.g. "&" in "_&_"
+			}
+		}
+
+		// Check for other special cases
+		if (delimiter == '&' || delimiter == '+') {
+			if (is_unknown_token(prev_token) && is_unknown_token(next_token)) {
+				if (IsNumericString(prev_token->content) && IsNumericString(next_token->content)) {
+					append_token_to(token, prev_token);
+					append_token_to(next_token, prev_token); // e.g. "01+02"
+				}
+			}
+		}
+	}
+
+	auto remove_if_invalid = std::remove_if(tokens_.begin(), tokens_.end(),
+											[](const Token& token) -> bool { return token.category == kInvalid; });
+	tokens_.erase(remove_if_invalid, tokens_.end());
+}
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/anitomy/tokenizer.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,42 @@
+/*
+** Copyright (c) 2014-2017, Eren Okka
+**
+** This Source Code Form is subject to the terms of the Mozilla Public
+** License, v. 2.0. If a copy of the MPL was not distributed with this
+** file, You can obtain one at https://mozilla.org/MPL/2.0/.
+*/
+
+#pragma once
+
+#include "element.h"
+#include "options.h"
+#include "string.h"
+#include "token.h"
+
+namespace anitomy {
+
+class Tokenizer {
+	public:
+		Tokenizer(const string_t& filename, Elements& elements, const Options& options, token_container_t& tokens);
+
+		Tokenizer(const Tokenizer&) = delete;
+		Tokenizer& operator=(const Tokenizer&) = delete;
+
+		bool Tokenize();
+
+	private:
+		void AddToken(TokenCategory category, bool enclosed, const TokenRange& range);
+		void TokenizeByBrackets();
+		void TokenizeByPreidentified(bool enclosed, const TokenRange& range);
+		void TokenizeByDelimiters(bool enclosed, const TokenRange& range);
+
+		string_t GetDelimiters(const TokenRange& range) const;
+		void ValidateDelimiterTokens();
+
+		Elements& elements_;
+		const string_t& filename_;
+		const Options& options_;
+		token_container_t& tokens_;
+};
+
+} // namespace anitomy
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dep/anitomy/test/data.json	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,1976 @@
+[
+	{
+		"anime_title": "Toradora!",
+		"anime_year": "2008",
+		"audio_term": "FLAC",
+		"episode_number": "01",
+		"episode_title": "Tiger and Dragon",
+		"file_checksum": "1234ABCD",
+		"file_extension": "mkv",
+		"file_name": "[TaigaSubs]_Toradora!_(2008)_-_01v2_-_Tiger_and_Dragon_[1280x720_H.264_FLAC][1234ABCD].mkv",
+		"id": 4224,
+		"release_group": "TaigaSubs",
+		"release_version": "2",
+		"video_resolution": "1280x720",
+		"video_term": "H.264"
+	},
+	{
+		"anime_title": "Princess Lover!",
+		"episode_number": "01",
+		"file_checksum": "2048A39A",
+		"file_extension": "mkv",
+		"file_name": "[ANBU]_Princess_Lover!_-_01_[2048A39A].mkv",
+		"id": 6201,
+		"release_group": "ANBU"
+	},
+	{
+		"anime_title": "Canaan",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "12F00E89",
+		"file_extension": "mkv",
+		"file_name": "[ANBU-Menclave]_Canaan_-_01_[1024x576_H.264_AAC][12F00E89].mkv",
+		"id": 5356,
+		"release_group": "ANBU-Menclave",
+		"video_resolution": "1024x576",
+		"video_term": "H.264"
+	},
+	{
+		"anime_title": "Haiyoru! Nyaru-Ani",
+		"file_checksum": "596DD8E6",
+		"file_extension": "mkv",
+		"file_name": "[ANBU-umai]_Haiyoru!_Nyaru-Ani_[596DD8E6].mkv",
+		"id": 7596,
+		"release_group": "ANBU-umai"
+	},
+	{
+		"anime_title": "Special A",
+		"episode_number": "01",
+		"file_checksum": "C83164B9",
+		"file_extension": "mkv",
+		"file_name": "[BakaWolf-m.3.3.w] Special A 01 (H.264) [C83164B9].mkv",
+		"id": 3470,
+		"release_group": "BakaWolf-m.3.3.w",
+		"video_term": "H.264"
+	},
+	{
+		"anime_title": "Seikon no Qwaser",
+		"episode_number": "13",
+		"file_checksum": "988DB090",
+		"file_extension": "mkv",
+		"file_name": "[chibi-Doki] Seikon no Qwaser - 13v0 (Uncensored Director's Cut) [988DB090].mkv",
+		"id": 6500,
+		"other": "Uncensored",
+		"release_group": "chibi-Doki",
+		"release_version": "0"
+	},
+	{
+		"anime_title": "Kono Aozora ni Yakusoku Wo",
+		"episode_number": "10",
+		"file_checksum": "C83D206B",
+		"file_extension": "mkv",
+		"file_name": "[Chihiro]_Kono_Aozora_ni_Yakusoku_Wo_10_v2_[DVD][h264][C83D206B].mkv",
+		"id": 2155,
+		"release_group": "Chihiro",
+		"release_version": "2",
+		"source": "DVD",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "Toradora ED",
+		"anime_type": "ED",
+		"audio_term": "AAC",
+		"episode_number": "2",
+		"file_checksum": "3B65D1E6",
+		"file_extension": "mkv",
+		"file_name": "[Coalgirls]_Toradora_ED2_(704x480_DVD_AAC)_[3B65D1E6].mkv",
+		"id": 4224,
+		"release_group": "Coalgirls",
+		"source": "DVD",
+		"video_resolution": "704x480"
+	},
+	{
+		"anime_title": "Mobile Suit Gundam 00 S2",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "4863FBE8",
+		"file_extension": "mkv",
+		"file_name": "[Conclave-Mendoi]_Mobile_Suit_Gundam_00_S2_-_01v2_[1280x720_H.264_AAC][4863FBE8].mkv",
+		"id": 3927,
+		"release_group": "Conclave-Mendoi",
+		"release_version": "2",
+		"video_resolution": "1280x720",
+		"video_term": "H.264"
+	},
+	{
+		"anime_title": "Bleach",
+		"episode_number": "225",
+		"file_checksum": "C63D149C",
+		"file_extension": "avi",
+		"file_name": "[DB]_Bleach_225_[C63D149C].avi",
+		"id": 269,
+		"release_group": "DB"
+	},
+	{
+		"anime_title": "Nodame Cantabile Finale",
+		"episode_number": "00",
+		"file_checksum": "73AD0735",
+		"file_extension": "mkv",
+		"file_name": "[Frostii]_Nodame_Cantabile_Finale_-_00_[73AD0735].mkv",
+		"id": 5690,
+		"release_group": "Frostii"
+	},
+	{
+		"anime_title": "FullMetalAlchemist",
+		"episode_number": "09",
+		"file_extension": "rmvb",
+		"file_name": "[Hard-Boiled FS]FullMetalAlchemist_09.rmvb",
+		"id": 121,
+		"release_group": "Hard-Boiled FS"
+	},
+	{
+		"anime_title": "Tower of Druaga - Sword of Uruk",
+		"episode_number": "04",
+		"file_extension": "mkv",
+		"file_name": "[HorribleSubs] Tower of Druaga - Sword of Uruk - 04 [480p].mkv",
+		"id": 4726,
+		"release_group": "HorribleSubs",
+		"video_resolution": "480p"
+	},
+	{
+		"anime_title": "Juuni Kokki",
+		"audio_term": "OGG",
+		"episode_number": "24",
+		"file_extension": "mkv",
+		"file_name": "[Juuni.Kokki]-(Les.12.Royaumes)-[Ep.24]-[x264+OGG]-[JAP+FR+Sub.FR]-[Chap]-[AzF].mkv",
+		"id": 153,
+		"language": "JAP",
+		"subtitles": "Sub",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "One Piece Movie 9",
+		"anime_type": "Movie",
+		"file_extension": "avi",
+		"file_name": "[KAF-TEAM]_One_Piece_Movie_9_vostfr_HD.avi",
+		"id": 3848,
+		"language": "vostfr",
+		"release_group": "KAF-TEAM",
+		"video_term": "HD"
+	},
+	{
+		"anime_title": "Nazca",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "[kito].Nazca.episode.01.DVDRip.[x264.He-aac.{Jpn}+Sub{Fr}].mkv",
+		"id": 1775,
+		"release_group": "kito",
+		"source": "DVDRip",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Umineko no Naku Koro ni",
+		"audio_term": "AAC",
+		"episode_number": "11",
+		"file_checksum": "943106AD",
+		"file_extension": "mkv",
+		"file_name": "[Lambda-Delta]_Umineko_no_Naku_Koro_ni_-_11_[848x480_H.264_AAC][943106AD].mkv",
+		"id": 4896,
+		"release_group": "Lambda-Delta",
+		"video_resolution": "848x480",
+		"video_term": "H.264"
+	},
+	{
+		"anime_title": "Kemono no Souja Erin",
+		"episode_number": "12",
+		"file_checksum": "0F5F884F",
+		"file_extension": "mkv",
+		"file_name": "[SS]_Kemono_no_Souja_Erin_-_12_(1280x720_h264)_[0F5F884F].mkv",
+		"id": 5420,
+		"release_group": "SS",
+		"video_resolution": "1280x720",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "Fullmetal Alchemist",
+		"anime_year": "2009",
+		"episode_number": "04",
+		"file_checksum": "40F2A957",
+		"file_extension": "mp4",
+		"file_name": "[Taka]_Fullmetal_Alchemist_(2009)_04_[720p][40F2A957].mp4",
+		"id": 5114,
+		"release_group": "Taka",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_title": "Summer Wars",
+		"audio_term": "TrueHD5.1",
+		"file_checksum": "9F311DAB",
+		"file_extension": "mkv",
+		"file_name": "[UTW-TMD]_Summer_Wars_[BD][h264-720p][TrueHD5.1][9F311DAB].mkv",
+		"id": 5681,
+		"release_group": "UTW-TMD",
+		"source": "BD",
+		"video_resolution": "720p",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "First Squad The Morment Of Truth",
+		"file_extension": "mkv",
+		"file_name": "[ValdikSS]_First_Squad_The_Morment_Of_Truth_[720x576_h264_dvdscr_eng_hardsub].mkv",
+		"id": 5178,
+		"language": "eng",
+		"release_group": "ValdikSS",
+		"subtitles": "hardsub",
+		"video_resolution": "720x576",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "Code Geass R2 TV",
+		"anime_type": "TV",
+		"episode_number": "20",
+		"file_extension": "mkv",
+		"file_name": "Code_Geass_R2_TV_[20_of_25]_[ru_jp]_[HDTV]_[Varies_&_Cuba77_&_AnimeReactor_RU].mkv",
+		"id": 2904,
+		"release_group": "Varies & Cuba77 & AnimeReactor RU",
+		"source": "HDTV"
+	},
+	{
+		"anime_title": "Evangelion 1.11 You Are (Not) Alone",
+		"anime_year": "2009",
+		"audio_term": "DTS-ES",
+		"file_extension": "mkv",
+		"file_name": "Evangelion_1.11_You_Are_(Not)_Alone_(2009)_[1080p,BluRay,x264,DTS-ES]_-_THORA.mkv",
+		"id": 2759,
+		"release_group": "THORA",
+		"source": "BluRay",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Evangelion 1.11 You Are (Not) Alone",
+		"audio_term": "DTS-ES",
+		"file_extension": "mkv",
+		"file_name": "Evangelion_1.11_You_Are_(Not)_Alone_[1080p,BluRay,x264,DTS-ES]_-_THORA.mkv",
+		"id": 2759,
+		"release_group": "THORA",
+		"source": "BluRay",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Eve no Jikan",
+		"episode_number": "2",
+		"file_checksum": "88F4F7F0",
+		"file_extension": "mkv",
+		"file_name": "Eve no Jikan 2 [88F4F7F0].mkv",
+		"id": 3167
+	},
+	{
+		"anime_title": "Gin'iro no Kami no Agito",
+		"anime_year": "2006",
+		"audio_term": "DTS",
+		"file_extension": "mkv",
+		"file_name": "Gin'iro_no_Kami_no_Agito_(2006)_[1080p,BluRay,x264,DTS]_-_THORA.mkv",
+		"id": 1140,
+		"release_group": "THORA",
+		"source": "BluRay",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Magical Girl Lyrical Nanoha A's",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "7A8A7769",
+		"file_extension": "mkv",
+		"file_name": "Magical Girl Lyrical Nanoha A's - 01.DVD[H264.AAC][DGz][7A8A7769].mkv",
+		"id": 77,
+		"release_group": "DGz",
+		"source": "DVD",
+		"video_term": "H264"
+	},
+	{
+		"anime_season": "2",
+		"anime_title": "Mobile Suit Gundam 00",
+		"episode_number": "07",
+		"episode_title": "A Reunion and a Parting",
+		"file_extension": "mkv",
+		"file_name": "Mobile_Suit_Gundam_00_Season_2_Ep07_A_Reunion_and_a_Parting_[1080p,BluRay,x264]_-_THORA.mkv",
+		"id": 3927,
+		"release_group": "THORA",
+		"source": "BluRay",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Noein",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "Noein_[01_of_24]_[ru_jp]_[bodlerov_&_torrents_ru].mkv",
+		"id": 584,
+		"release_group": "bodlerov & torrents ru"
+	},
+	{
+		"anime_title": "ponyo on the cliff by the sea",
+		"audio_term": "dts",
+		"file_extension": "mkv",
+		"file_name": "ponyo_on_the_cliff_by_the_sea[h264.dts][niizk].mkv",
+		"id": 2890,
+		"release_group": "niizk",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "AIKa ZERO OVA",
+		"anime_type": "OVA",
+		"audio_term": "Flac",
+		"episode_number": "01",
+		"file_checksum": "6730D40A",
+		"file_extension": "mkv",
+		"file_name": "[Seto_Otaku]_AIKa_ZERO_OVA_-_01_[BD][1920x1080_H264-Flac][6730D40A].mkv",
+		"id": 6443,
+		"release_group": "Seto_Otaku",
+		"source": "BD",
+		"video_resolution": "1920x1080",
+		"video_term": "H264"
+	},
+	{
+		"anime_title": "R.O.D the TV",
+		"anime_type": "TV",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "[a4e]R.O.D_the_TV_01[divx5.2.1].mkv",
+		"id": 209,
+		"release_group": "a4e"
+	},
+	{
+		"anime_title": "Ghost in the Shell Stand Alone Complex 2nd GIG",
+		"audio_term": [
+			"AAC",
+			"5.1"
+		],
+		"episode_number": "05",
+		"episode_title": "EXCAVATION",
+		"file_extension": "mkv",
+		"file_name": "Ghost_in_the_Shell_Stand_Alone_Complex_2nd_GIG_Ep05v2_EXCAVATION_[720p,HDTV,x264,AAC_5.1]_-_THORA.mkv",
+		"id": 801,
+		"release_group": "THORA",
+		"release_version": "2",
+		"source": "HDTV",
+		"video_resolution": "720p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Ghost in the Shell Stand Alone Complex 2nd GIG",
+		"audio_term": [
+			"AAC",
+			"5.1"
+		],
+		"episode_number": "06",
+		"episode_title": "Pu239",
+		"file_extension": "mkv",
+		"file_name": "Ghost_in_the_Shell_Stand_Alone_Complex_2nd_GIG_Ep06_Pu239_[720p,HDTV,x264,AAC_5.1]_-_THORA.mkv",
+		"id": 801,
+		"release_group": "THORA",
+		"source": "HDTV",
+		"video_resolution": "720p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Fate Stay Night",
+		"episode_number": "05",
+		"episode_title": "The Two Magi Part1",
+		"file_extension": "mkv",
+		"file_name": "Fate_Stay_Night_Ep05_The_Two_Magi_Part1_[720p,BluRay,x264]_-_THORA.mkv",
+		"id": 356,
+		"release_group": "THORA",
+		"source": "BluRay",
+		"video_resolution": "720p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Mezzo(DSA)",
+		"audio_term": "ogg",
+		"episode_number": "05",
+		"file_checksum": "585d9971",
+		"file_extension": "mkv",
+		"file_name": "[RaX]Mezzo(DSA)_-_05_-_[x264_ogg]_[585d9971].mkv",
+		"id": 222,
+		"release_group": "RaX",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Fullmetal Alchemist",
+		"anime_year": "2009",
+		"audio_term": "AAC",
+		"episode_number": "02",
+		"file_checksum": "7B2C5E8B",
+		"file_extension": "mkv",
+		"file_name": "[AKH-SWE]_Fullmetal_Alchemist_(2009)_02v2_[H.264.AAC][7B2C5E8B].mkv",
+		"id": 5114,
+		"release_group": "AKH-SWE",
+		"release_version": "2",
+		"video_term": "H.264"
+	},
+	{
+		"anime_title": "Sayonara Zetsubou Sensei",
+		"audio_term": "AC3",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "[FuktLogik][Sayonara_Zetsubou_Sensei][01][DVDRip][x264_AC3].mkv",
+		"id": 2605,
+		"release_group": "FuktLogik",
+		"source": "DVDRip",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Kiddy Grade 2",
+		"audio_term": "AC3",
+		"episode_title": "Pilot",
+		"file_checksum": "650B731B",
+		"file_extension": "mkv",
+		"file_name": "[Ayu]_Kiddy_Grade_2_-_Pilot_[H264_AC3][650B731B].mkv",
+		"release_group": "Ayu",
+		"video_term": "H264"
+	},
+	{
+		"anime_title": "Tatakau Shisho - The Book of Bantorra",
+		"audio_term": "MP3",
+		"file_extension": "mkv",
+		"file_name": "[Darksoul-Subs] Tatakau Shisho - The Book of Bantorra [848x480 XVID_MP3].mkv",
+		"id": 6758,
+		"release_group": "Darksoul-Subs",
+		"video_resolution": "848x480",
+		"video_term": "XVID"
+	},
+	{
+		"anime_title": "Neon Genesis Evangelion - Platinum",
+		"episode_number": "06",
+		"episode_title": "Showdown in Tokyo 3",
+		"file_checksum": "CBDB8577",
+		"file_extension": "mkv",
+		"file_name": "[ACX]Neon_Genesis_Evangelion_-_Platinum_-_06_-_Showdown_in_Tokyo_3_[SaintDeath]_[CBDB8577].mkv",
+		"id": 30,
+		"release_group": "ACX"
+	},
+	{
+		"anime_title": "Sora no Woto",
+		"episode_number": "01",
+		"file_checksum": "E83AD672",
+		"file_extension": "mkv",
+		"file_name": "[Himatsubushi]_Sora_no_Woto_-_01_-_H264_-_720p_-_E83AD672.mkv",
+		"id": 6802,
+		"release_group": "Himatsubushi",
+		"video_resolution": "720p",
+		"video_term": "H264"
+	},
+	{
+		"anime_title": "Nurse Witch Komugi-chan Magikarte",
+		"episode_number": "02.5",
+		"file_checksum": "902BB314",
+		"file_extension": "mkv",
+		"file_name": "[EroGaKi-Team]_Nurse_Witch_Komugi-chan_Magikarte_02.5_[902BB314].mkv",
+		"id": 920,
+		"release_group": "EroGaKi-Team"
+	},
+	{
+		"anime_title": "Ookiku Furikabutte S2",
+		"episode_number": "09",
+		"file_checksum": "BD841253",
+		"file_extension": "mkv",
+		"file_name": "Ookiku Furikabutte S2 - 09 (Central Anime) [BD841253].mkv",
+		"id": 7720,
+		"release_group": "Central Anime"
+	},
+	{
+		"anime_title": "HEROMAN",
+		"episode_number": "10",
+		"file_extension": "mkv",
+		"file_name": "[HorribleSubs] HEROMAN - 10_(XviD_AnimeSenshi).mkv",
+		"id": 4334,
+		"release_group": "HorribleSubs",
+		"video_term": "XviD"
+	},
+	{
+		"anime_title": "Detective Conan",
+		"episode_number": [
+			"316",
+			"317"
+		],
+		"file_checksum": "2411959B",
+		"file_extension": "mkv",
+		"file_name": "Detective Conan - 316-317 [DCTP][2411959B].mkv",
+		"id": 235,
+		"release_group": "DCTP"
+	},
+	{
+		"anime_title": "Angel Beats",
+		"episode_number": "9",
+		"file_extension": "mkv",
+		"file_name": "[N LogN Fansubs] Angel Beats (9).mkv",
+		"id": 6547,
+		"release_group": "N LogN Fansubs"
+	},
+	{
+		"anime_title": "To Aru Kagaku no Railgun",
+		"episode_number": [
+			"13",
+			"15"
+		],
+		"file_name": "To_Aru_Kagaku_no_Railgun_13-15_[BD_1080p][AtsA]",
+		"id": 6213,
+		"release_group": "AtsA",
+		"source": "BD",
+		"video_resolution": "1080p"
+	},
+	{
+		"anime_title": "Juuousei",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "803DA487",
+		"file_extension": "mkv",
+		"file_name": "Juuousei_-_01_[Black_Sheep][HDTV_H264_AAC][803DA487].mkv",
+		"id": 953,
+		"release_group": "Black_Sheep",
+		"source": "HDTV",
+		"video_term": "H264"
+	},
+	{
+		"anime_title": "Sakura Taisen New York NY",
+		"episode_number": "2",
+		"file_checksum": "1590D378",
+		"file_extension": "avi",
+		"file_name": "[RNA]_Sakura_Taisen_New_York_NY_Ep_2_[1590D378].avi",
+		"id": 2168,
+		"release_group": "RNA"
+	},
+	{
+		"anime_season": "2",
+		"anime_title": "Hayate no Gotoku",
+		"episode_number": "24",
+		"file_name": "Hayate no Gotoku 2nd Season 24 (Blu-Ray 1080p) [Chihiro]",
+		"id": 4192,
+		"release_group": "Chihiro",
+		"source": "Blu-Ray",
+		"video_resolution": "1080p"
+	},
+	{
+		"anime_title": "Blue Submarine No.6",
+		"audio_term": "Dual Audio",
+		"file_name": "[BluDragon] Blue Submarine No.6 (DVD, R2, Dual Audio) V3",
+		"id": 1051,
+		"release_group": "BluDragon",
+		"release_version": "3",
+		"source": "DVD"
+	},
+	{
+		"anime_title": "Chrono Crusade",
+		"episode_number": [
+			"1",
+			"5"
+		],
+		"file_name": "Chrono Crusade ep. 1-5",
+		"id": 60
+	},
+	{
+		"anime_season": "2",
+		"anime_title": "Kimi ni Todoke",
+		"episode_number": "00",
+		"file_checksum": "BF735BC4",
+		"file_extension": "mkv",
+		"file_name": "[gg]_Kimi_ni_Todoke_2nd_Season_-_00_[BF735BC4].mkv",
+		"id": 9656,
+		"release_group": "gg"
+	},
+	{
+		"anime_title": "K-ON!",
+		"episode_number": "03",
+		"episode_title": "Training!",
+		"file_extension": "mkv",
+		"file_name": "K-ON!_Ep03_Training!_[1080p,BluRay,x264]_-_THORA.mkv",
+		"id": 5680,
+		"release_group": "THORA",
+		"source": "BluRay",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "K-ON!!",
+		"episode_number": "08",
+		"episode_title": "Career Plan!",
+		"file_extension": "mkv",
+		"file_name": "K-ON!!_Ep08_Career_Plan!_[1080p,BluRay,x264]_-_THORA.mkv",
+		"id": 7791,
+		"release_group": "THORA",
+		"source": "BluRay",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Queen's Blade S2",
+		"file_name": "[SFW]_Queen's_Blade_S2",
+		"id": 6633,
+		"release_group": "SFW"
+	},
+	{
+		"anime_title": "Evangelion 1.0 You Are [Not] Alone",
+		"file_name": "Evangelion_1.0_You_Are_[Not]_Alone_(1080p)_[@Home]",
+		"id": 2759,
+		"release_group": "@Home",
+		"video_resolution": "1080p"
+	},
+	{
+		"anime_title": "Infinite Stratos - IS",
+		"episode_number": "01",
+		"file_checksum": "29675B71",
+		"file_extension": "avi",
+		"file_name": "[Ayako]_Infinite_Stratos_-_IS_-_01v2_[XVID][400p][29675B71].avi",
+		"id": 9041,
+		"release_group": "Ayako",
+		"release_version": "2",
+		"video_resolution": "400p",
+		"video_term": "XVID"
+	},
+	{
+		"anime_title": "Kore wa Zombie desu ka",
+		"anime_type": "TV",
+		"audio_term": "AAC",
+		"episode_number": "03",
+		"file_checksum": "888E4991",
+		"file_extension": "mkv",
+		"file_name": "[E-HARO Raws] Kore wa Zombie desu ka - 03 (TV 1280x720 h264 AAC) [888E4991].mkv",
+		"id": 8841,
+		"release_group": "E-HARO Raws",
+		"video_resolution": "1280x720",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "Kore wa Zombie desu ka",
+		"episode_number": "2",
+		"file_extension": "mkv",
+		"file_name": "[Edomae Subs] Kore wa Zombie desu ka  Episode 2.mkv",
+		"id": 8841,
+		"release_group": "Edomae Subs"
+	},
+	{
+		"anime_title": "Juuni Kokki",
+		"episode_number": "5",
+		"file_extension": "avi",
+		"file_name": "Juuni.Kokki.Ep.5.avi",
+		"id": 153
+	},
+	{
+		"anime_title": "Juuni Kokki",
+		"episode_number": "5",
+		"file_extension": "avi",
+		"file_name": "Juuni Kokki Ep.5.avi",
+		"id": 153
+	},
+	{
+		"anime_title": "Keroro",
+		"audio_term": "mp3",
+		"episode_number": "148",
+		"file_checksum": "FE68D5F1",
+		"file_extension": "avi",
+		"file_name": "[Keroro].148.[Xvid.mp3].[FE68D5F1].avi",
+		"id": 516,
+		"video_term": "Xvid"
+	},
+	{
+		"anime_title": "5 centimeters per second",
+		"audio_term": "flac",
+		"file_extension": "mkv",
+		"file_name": "5_centimeters_per_second[1904x1072.h264.flac][niizk].mkv",
+		"id": 1689,
+		"release_group": "niizk",
+		"video_resolution": "1904x1072",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "009-1",
+		"episode_number": "02",
+		"file_checksum": "36D2444D",
+		"file_extension": "mkv",
+		"file_name": "[Yoroshiku]_009-1_-_02_(H264)_[36D2444D].mkv",
+		"id": 1583,
+		"release_group": "Yoroshiku",
+		"video_term": "H264"
+	},
+	{
+		"anime_season": "1",
+		"anime_title": "After War Gundam X",
+		"episode_number": "03",
+		"episode_title": "My Mount is Fierce!",
+		"file_extension": "mkv",
+		"file_name": "After War Gundam X - 1x03 - My Mount is Fierce!.mkv",
+		"id": 92
+	},
+	{
+		"anime_title": "The World God Only Knows 2",
+		"episode_number": "03",
+		"file_extension": "mkv",
+		"file_name": "[HorribleSubs] The World God Only Knows 2 - 03 [720p].mkv",
+		"id": 10080,
+		"release_group": "HorribleSubs",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_title": "Macross Frontier - Sayonara no Tsubasa",
+		"file_checksum": "46B35E25",
+		"file_extension": "mkv",
+		"file_name": "Macross Frontier - Sayonara no Tsubasa (Central Anime, 720p) [46B35E25].mkv",
+		"id": 7222,
+		"release_group": "Central Anime",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_year": "2012",
+		"anime_title": "Space Battleship Yamato 2199",
+		"audio_term": "AAC",
+		"episode_number": "18",
+		"file_checksum": "BA70BA9C",
+		"file_name": "[Nubles] Space Battleship Yamato 2199 (2012) episode 18 (720p 8 bit AAC)[BA70BA9C]",
+		"id": 12029,
+		"release_group": "Nubles",
+		"video_resolution": "720p",
+		"video_term": "8 bit"
+	},
+	{
+		"anime_year": "2012",
+		"anime_title": "Space Battleship Yamato 2199",
+		"audio_term": "AAC",
+		"episode_number": "18",
+		"file_checksum": "1F56D642",
+		"file_name": "[Nubles] Space Battleship Yamato 2199 (2012) episode 18 (720p 10 bit AAC)[1F56D642]",
+		"id": 12029,
+		"release_group": "Nubles",
+		"video_resolution": "720p",
+		"video_term": "10 bit"
+	},
+	{
+		"anime_title": "Red Data Girl",
+		"episode_number": "10",
+		"file_checksum": "29EA865B",
+		"file_extension": "mkv",
+		"file_name": "[FFF] Red Data Girl - 10v0 [29EA865B].mkv",
+		"id": 14921,
+		"release_group": "FFF",
+		"release_version": "0"
+	},
+	{
+		"anime_title": "Magical☆Star Kanon 100% OVA",
+		"anime_type": "OVA",
+		"file_checksum": "E9F43685",
+		"file_extension": "mkv",
+		"file_name": "[CMS] Magical☆Star Kanon 100% OVA[DVD][E9F43685].mkv",
+		"id": 17725,
+		"release_group": "CMS",
+		"source": "DVD"
+	},
+	{
+		"anime_title": "Ro-Kyu-Bu! SS",
+		"episode_number": "01",
+		"file_checksum": "C1B5CE5D",
+		"file_extension": "mkv",
+		"file_name": "[Doremi].Ro-Kyu-Bu!.SS.01.[C1B5CE5D].mkv",
+		"id": 16051,
+		"release_group": "Doremi"
+	},
+	{
+		"anime_title": "Persona 4 The Animation",
+		"audio_term": "FLAC",
+		"episode_number": "13",
+		"episode_title": "A Stormy Summer Vacation Part 1",
+		"file_checksum": "8A45634B",
+		"file_extension": "mkv",
+		"file_name": "[Raizel] Persona 4 The Animation Episode 13 - A Stormy Summer Vacation Part 1  [BD_1080p_Dual_Audio_FLAC_Hi10p][8A45634B].mkv",
+		"id": 10588,
+		"release_group": "Raizel",
+		"source": "BD",
+		"video_resolution": "1080p",
+		"video_term": "Hi10p"
+	},
+	{
+		"anime_title": "Kotoura-san - Special Short Anime 'Haruka's Room'",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "6B6BE015",
+		"file_extension": "mkv",
+		"file_name": "[Hien] Kotoura-san - Special Short Anime 'Haruka's Room' - 01 [BD 1080p H.264 10-bit AAC][6B6BE015].mkv",
+		"id": 16636,
+		"release_group": "Hien",
+		"source": "BD",
+		"video_resolution": "1080p",
+		"video_term": [
+			"H.264",
+			"10-bit"
+		]
+	},
+	{
+		"anime_title": "Diebuster",
+		"audio_term": "AC3",
+		"episode_number": "1",
+		"file_checksum": "82E36A36",
+		"file_extension": "mkv",
+		"file_name": "[R-R] Diebuster.EP1 (720p.Hi10p.AC3) [82E36A36].mkv",
+		"id": 1002,
+		"release_group": "R-R",
+		"video_resolution": "720p",
+		"video_term": "Hi10p"
+	},
+	{
+		"anime_title": "Aim For The Top! Gunbuster",
+		"audio_term": "FLAC",
+		"episode_number": "1",
+		"file_checksum": "69ECCDCF",
+		"file_extension": "mkv",
+		"file_name": "Aim_For_The_Top!_Gunbuster-ep1.BD(H264.FLAC.10bit)[KAA][69ECCDCF].mkv",
+		"id": 949,
+		"release_group": "KAA",
+		"source": "BD",
+		"video_term": [
+			"H264",
+			"10bit"
+		]
+	},
+	{
+		"anime_title": "Gift ~eternal rainbow~",
+		"audio_term": "vorbis",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "[Rakuda].Gift.~eternal.rainbow~.01.dvd.h.264.vorbis.mkv",
+		"id": 1581,
+		"release_group": "Rakuda",
+		"source": "dvd",
+		"video_term": "h.264"
+	},
+	{
+		"anime_title": "D.C.II Da Capo II",
+		"episode_number": "01",
+		"file_checksum": "a1fc58a7",
+		"file_extension": "mkv",
+		"file_name": "[Jumonji-Giri]_[Shinsen-Subs][ASF]_D.C.II_Da_Capo_II_Ep01_(a1fc58a7).mkv",
+		"id": 2595,
+		"release_group": "Jumonji-Giri"
+	},
+	{
+		"anime_title": "Mobile Suit Gundam Seed Destiny",
+		"audio_term": "AAC",
+		"episode_number": "07",
+		"file_extension": "mp4",
+		"file_name": "[Mobile Suit Gundam Seed Destiny HD REMASTER][07][Big5][720p][AVC_AAC][encoded by SEED].mp4",
+		"id": 94,
+		"other": "REMASTER",
+		"subtitles": "Big5",
+		"release_group": "SEED",
+		"video_resolution": "720p",
+		"video_term": [
+			"HD",
+			"AVC"
+		]
+	},
+	{
+		"anime_title": "「K」",
+		"audio_term": "AAC",
+		"file_extension": "mp4",
+		"file_name": "「K」 Image Blu-ray WHITE & BLACK - Main (BD 1280x720 AVC AAC).mp4",
+		"id": 14467,
+		"source": [
+			"Blu-ray",
+			"BD"
+		],
+		"video_resolution": "1280x720",
+		"video_term": "AVC"
+	},
+	{
+		"anime_title": "Shingeki no Kyojin",
+		"audio_term": "AAC",
+		"episode_number": "05",
+		"file_extension": "mp4",
+		"file_name": "[[Zero-Raws] Shingeki no Kyojin - 05 (MBS 1280x720 x264 AAC).mp4",
+		"id": 16498,
+		"release_group": "Zero-Raws",
+		"video_resolution": "1280x720",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "SlamDunk",
+		"audio_term": "aac",
+		"episode_number": "001",
+		"file_checksum": "7FE2C873",
+		"file_extension": "mkv",
+		"file_name": "[52wy][SlamDunk][001][Jpn_Chs_Cht][x264_aac][DVDRip][7FE2C873].mkv",
+		"id": 170,
+		"release_group": "52wy",
+		"source": "DVDRip",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Last Exile ~Fam, The Silver Wing~",
+		"episode_number": "13",
+		"file_checksum": "AFF9E530",
+		"file_extension": "mkv",
+		"file_name": "[Commie] Last Exile ~Fam, The Silver Wing~ - 13 [AFF9E530].mkv",
+		"id": 10336,
+		"release_group": "Commie"
+	},
+	{
+		"anime_title": "Dragon Ball Z Battle of Gods",
+		"file_extension": "mp4",
+		"file_name": "[Hakugetsu&Speed&MGRT][Dragon_Ball_Z_Battle_of_Gods][BDRIP][BIG5][1280x720].mp4",
+		"id": 14837,
+		"release_group": "Hakugetsu&Speed&MGRT",
+		"source": "BDRIP",
+		"subtitles": "BIG5",
+		"video_resolution": "1280x720"
+	},
+	{
+		"anime_title": "Evangelion 3.0 You Can (Not) Redo",
+		"file_extension": "mp4",
+		"file_name": "[Hakugetsu&MGRT][Evangelion 3.0 You Can (Not) Redo][480P][V0].mp4",
+		"id": 3785,
+		"release_group": "Hakugetsu&MGRT",
+		"release_version": "0",
+		"video_resolution": "480P"
+	},
+	{
+		"anime_title": "Kidou Senshi Gundam UC Unicorn",
+		"audio_term": [
+			"AAC",
+			"5.1ch"
+		],
+		"episode_number": "02",
+		"file_extension": "mp4",
+		"file_name": "[TV-J] Kidou Senshi Gundam UC Unicorn - episode.02 [BD 1920x1080 h264+AAC(5.1ch JP+EN) +Sub(JP-EN-SP-FR-CH) Chap].mp4",
+		"id": 6336,
+		"release_group": "TV-J",
+		"source": "BD",
+		"video_resolution": "1920x1080",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "Fate Zero",
+		"audio_term": "AC3",
+		"episode_number": "01",
+		"file_checksum": "02A0491D",
+		"file_extension": "mkv",
+		"file_name": "[UTW]_Fate_Zero_-_01_[BD][h264-720p_AC3][02A0491D].mkv",
+		"id": 10087,
+		"release_group": "UTW",
+		"source": "BD",
+		"video_resolution": "720p",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "Evangelion 3.33 You Can (Not) Redo",
+		"audio_term": "flac",
+		"file_checksum": "F2060CF5",
+		"file_extension": "mkv",
+		"file_name": "[UTW-THORA] Evangelion 3.33 You Can (Not) Redo [BD][1080p,x264,flac][F2060CF5].mkv",
+		"id": 3785,
+		"release_group": "UTW-THORA",
+		"source": "BD",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Shingeki no Kyojin",
+		"audio_term": "AAC",
+		"episode_number": "25",
+		"file_extension": "mp4",
+		"file_name": "[Zero-Raws] Shingeki no Kyojin - 25 END (MBS 1280x720 x264 AAC).mp4",
+		"id": 16498,
+		"release_group": "Zero-Raws",
+		"release_information": "END",
+		"video_resolution": "1280x720",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Bakemonogatari",
+		"audio_term": "AACx2",
+		"episode_number": "01",
+		"file_extension": "mp4",
+		"file_name": "Bakemonogatari - 01 (BD 1280x720 AVC AACx2).mp4",
+		"id": 5081,
+		"source": "BD",
+		"video_resolution": "1280x720",
+		"video_term": "AVC"
+	},
+	{
+		"anime_title": "Evangelion Shin Gekijouban Q",
+		"anime_type": "Gekijouban",
+		"audio_term": [
+			"FLACx2",
+			"5.1ch"
+		],
+		"file_extension": "mkv",
+		"file_name": "Evangelion Shin Gekijouban Q (BDrip 1920x1080 x264 FLACx2 5.1ch)-ank.mkv",
+		"id": 3785,
+		"release_group": "ank",
+		"source": "BDrip",
+		"video_resolution": "1920x1080",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Evangelion The New Movie Q",
+		"anime_type": "Movie",
+		"audio_term": "AACx2",
+		"file_extension": "mp4",
+		"file_name": "Evangelion The New Movie Q (BD 1280x720 AVC AACx2 [5.1+2.0]).mp4",
+		"id": 3785,
+		"source": "BD",
+		"video_resolution": "1280x720",
+		"video_term": "AVC"
+	},
+	{
+		"anime_title": "Howl's Moving Castle",
+		"anime_year": "2004",
+		"audio_term": [
+			"flac",
+			"dts"
+		],
+		"file_extension": "mkv",
+		"file_name": "Howl's_Moving_Castle_(2004)_[1080p,BluRay,flac,dts,x264]_-_THORA v2.mkv",
+		"id": 431,
+		"release_group": "THORA",
+		"release_version": "2",
+		"source": "BluRay",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Kotonoha no Niwa",
+		"audio_term": "AACx3",
+		"file_extension": "mp4",
+		"file_name": "Kotonoha no Niwa (BD 1280x720 AVC AACx3 [5.1+2.0+2.0] Subx3).mp4",
+		"id": 16782,
+		"source": "BD",
+		"video_resolution": "1280x720",
+		"video_term": "AVC"
+	},
+	{
+		"anime_title": "Queen's Blade Utsukushiki Toushi-tachi - OVA",
+		"anime_type": "OVA",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_extension": "mp4",
+		"file_name": "Queen's Blade Utsukushiki Toushi-tachi - OVA_01 (BD 1280x720 AVC AAC).mp4",
+		"id": 8456,
+		"source": "BD",
+		"video_resolution": "1280x720",
+		"video_term": "AVC"
+	},
+	{
+		"anime_title": "Golden Time",
+		"episode_number": "24",
+		"file_name": "【MMZYSUB】★【Golden Time】[24(END)][GB][720P_MP4]",
+		"id": 17895,
+		"release_group": "MMZYSUB",
+		"release_information": "END",
+		"video_resolution": "720P",
+		"video_term": "MP4"
+	},
+	{
+		"anime_title": "Futsuu no Joshikousei ga [Locodol] Yatte Mita.",
+		"episode_number": "01",
+		"file_checksum": "BAD09C76",
+		"file_extension": "mkv",
+		"file_name": "[FFF] Futsuu no Joshikousei ga [Locodol] Yatte Mita. - 01 [BAD09C76].mkv",
+		"id": 22189,
+		"release_group": "FFF"
+	},
+	{
+		"anime_title": "Black Bullet",
+		"episode_number": "11",
+		"file_extension": "mp4",
+		"file_name": "[異域字幕組][漆黑的子彈][Black Bullet][11][1280x720][繁体].mp4",
+		"id": 20787,
+		"release_group": "異域字幕組",
+		"video_resolution": "1280x720"
+	},
+	{
+		"anime_title": "Mangaka-san to Assistant-san to the Animation",
+		"audio_term": "AAC",
+		"episode_number": "02",
+		"file_extension": "mp4",
+		"file_name": "[AoJiaoZero][Mangaka-san to Assistant-san to the Animation] 02 [BIG][X264_AAC][720P].mp4",
+		"id": 21863,
+		"release_group": "AoJiaoZero",
+		"video_resolution": "720P",
+		"video_term": "X264"
+	},
+	{
+		"file_name": "Vol.01",
+		"volume_number": "01"
+	},
+	{
+		"anime_title": "Rozen Maiden 3",
+		"anime_type": "PV",
+		"file_checksum": "CA57F300",
+		"file_extension": "mkv",
+		"file_name": "[Asenshi] Rozen Maiden 3 - PV [CA57F300].mkv",
+		"id": 18041,
+		"release_group": "Asenshi"
+	},
+	{
+		"anime_title": "Mary Bell",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "Mary Bell (DVD) - 01v2 [h-b].mkv",
+		"id": 2804,
+		"release_group": "h-b",
+		"release_version": "2",
+		"source": "DVD"
+	},
+	{
+		"anime_title": "Mary Bell",
+		"episode_number": "02",
+		"file_extension": "mkv",
+		"file_name": "Mary Bell (DVD) - 02 [h-b].mkv",
+		"id": 2804,
+		"release_group": "h-b",
+		"source": "DVD"
+	},
+	{
+		"anime_title": "Attack on Titan",
+		"episode_number": "3",
+		"episode_title": "A Dim Light Amid Despair / Humanity's Comeback, Part 1",
+		"file_name": "Attack on Titan - Episode 3 - A Dim Light Amid Despair / Humanity's Comeback, Part 1",
+		"id": 16498
+	},
+	{
+		"anime_season": "01",
+		"anime_title": "The Irregular at Magic High School",
+		"episode_number": "01",
+		"episode_title": "Enrollment Part I",
+		"file_extension": "mkv",
+		"file_name": "The Irregular at Magic High School - S01E01- Enrollment Part I.mkv",
+		"id": 20785
+	},
+	{
+		"anime_title": "Aikatsu!",
+		"episode_number": "100",
+		"file_checksum": "D035A39F",
+		"file_extension": "mkv",
+		"file_name": "[Mezashite] Aikatsu! ‒ 100 [D035A39F].mkv",
+		"id": 15061,
+		"release_group": "Mezashite"
+	},
+	{
+		"anime_title": "DRAMAtical Murder",
+		"episode_number": "1",
+		"episode_title": "Data_01_Login",
+		"file_name": "DRAMAtical Murder Episode 1 - Data_01_Login",
+		"option_allowed_delimiters": " ",
+		"id": 23333
+	},
+	{
+		"anime_title": "Today in Class 5-2",
+		"episode_number": "04",
+		"file_extension": "avi",
+		"file_name": "[Triad]_Today_in_Class_5-2_-_04.avi",
+		"id": 837,
+		"release_group": "Triad"
+	},
+	{
+		"anime_title": "BLUE DROP",
+		"episode_number": "10",
+		"file_extension": "avi",
+		"file_name": "__BLUE DROP 10 (1).avi",
+		"id": 2964
+	},
+	{
+		"anime_title": "Death Note",
+		"episode_number": "37",
+		"file_checksum": "6FA7D273",
+		"file_extension": "avi",
+		"file_name": "37 [Ruberia]_Death_Note_-_37v2_[FINAL]_[XviD][6FA7D273].avi",
+		"id": 1535,
+		"release_group": "Ruberia",
+		"release_information": "FINAL",
+		"release_version": "2",
+		"video_term": "XviD"
+	},
+	{
+		"anime_title": "Accel World - EX",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "3E56EE18",
+		"file_extension": "mkv",
+		"file_name": "[UTW]_Accel_World_-_EX01_[BD][h264-720p_AAC][3E56EE18].mkv",
+		"id": 13939,
+		"release_group": "UTW",
+		"source": "BD",
+		"video_resolution": "720p",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "Tsukimonogatari",
+		"episode_number": [
+			"01",
+			"04"
+		],
+		"file_extension": "mkv",
+		"file_name": "[HorribleSubs] Tsukimonogatari - (01-04) [1080p].mkv",
+		"id": 28025,
+		"release_group": "HorribleSubs",
+		"video_resolution": "1080p"
+	},
+	{
+		"anime_title": "Bokura Ga Ita",
+		"audio_term": "AC3",
+		"episode_number": "01",
+		"file_checksum": "BFCE1627",
+		"file_extension": "mkv",
+		"file_name": "[Urusai]_Bokura_Ga_Ita_01_[DVD_h264_AC3]_[BFCE1627][Fixed].mkv",
+		"id": 1222,
+		"release_group": "Urusai",
+		"source": "DVD",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "White Album",
+		"audio_term": "FLAC",
+		"episode_number": [
+			"1",
+			"13"
+		],
+		"file_name": "[Coalgirls]_White_Album_1-13_(1280×720_Blu-Ray_FLAC)",
+		"id": 4720,
+		"release_group": "Coalgirls",
+		"source": "Blu-Ray",
+		"video_resolution": "1280×720"
+	},
+	{
+		"anime_title": "Bakemonogatari OP",
+		"anime_type": "OP",
+		"audio_term": "FLAC",
+		"episode_number": "4a",
+		"file_checksum": "327A2375",
+		"file_extension": "mkv",
+		"file_name": "[Coalgirls]_Bakemonogatari_OP4a_(1280x720_Blu-Ray_FLAC)_[327A2375].mkv",
+		"id": 5081,
+		"release_group": "Coalgirls",
+		"source": "Blu-Ray",
+		"video_resolution": "1280x720"
+	},
+	{
+		"anime_title": "No.6",
+		"episode_number": "01",
+		"file_checksum": "A956075C",
+		"file_extension": "mkv",
+		"file_name": "[Ruri]No.6 01 [720p][H264][A956075C].mkv",
+		"id": 10161,
+		"release_group": "Ruri",
+		"video_resolution": "720p",
+		"video_term": "H264"
+	},
+	{
+		"anime_title": "Sword Art Online Extra Edition",
+		"audio_term": [
+			"Dual Audio",
+			"Vorbis"
+		],
+		"file_name": "[CH] Sword Art Online Extra Edition Dual Audio [BD 480p][10bitH.264+Vorbis]",
+		"id": 20021,
+		"release_group": "CH",
+		"source": "BD",
+		"video_resolution": "480p",
+		"video_term": [
+			"H.264",
+			"10bit"
+		]
+	},
+	{
+		"anime_title": "Akuma no Riddle",
+		"episode_number": "01",
+		"file_checksum": "69A307A2",
+		"file_extension": "mkv",
+		"file_name": "EvoBot.[Watakushi]_Akuma_no_Riddle_-_01v2_[720p][69A307A2].mkv",
+		"id": 19429,
+		"option_ignored_strings": [
+			"EvoBot."
+		],
+		"release_group": "Watakushi",
+		"release_version": "2",
+		"video_resolution": "720p"
+	},
+	{
+		"episode_number": "01",
+		"episode_title": "Land of Visible Pain",
+		"file_extension": "mkv",
+		"file_name": "01 - Land of Visible Pain.mkv"
+	},
+	{
+		"anime_season": "2",
+		"anime_title": "Ore no Imouto ga Konnani Kawaii Wake ga Nai.",
+		"episode_number": "14",
+		"file_name": "Episode 14 Ore no Imouto ga Konnani Kawaii Wake ga Nai. (saison 2) VOSTFR",
+		"id": 13659,
+		"language": "VOSTFR"
+	},
+	{
+		"anime_title": "Machine-Doll wa Kizutsukanai",
+		"episode_number": "01",
+		"episode_title": "Facing ''Cannibal Candy'' I",
+		"file_checksum": "B99C8DED",
+		"file_extension": "mkv",
+		"file_name": "[Zom-B] Machine-Doll wa Kizutsukanai - 01 - Facing ''Cannibal Candy'' I (kuroi, FFF remux) [B99C8DED].mkv",
+		"id": 17247,
+		"release_information": "remux",
+		"release_group": "Zom-B"
+	},
+	{
+		"anime_title": "The iDOLM@STER 765 Pro to Iu Monogatari",
+		"file_extension": "mkv",
+		"file_name": "The iDOLM@STER 765 Pro to Iu Monogatari.mkv",
+		"id": 11889
+	},
+	{
+		"anime_title": "Darker than Black - Gemini of the Meteor",
+		"episode_number": "01",
+		"file_checksum": "65274FDE",
+		"file_extension": "7z",
+		"file_name": "[Yuurisan-Subs]_Darker_than_Black_-_Gemini_of_the_Meteor_-_01v2_[65274FDE].patch.7z",
+		"id": 6573,
+		"release_information": "patch",
+		"release_group": "Yuurisan-Subs",
+		"release_version": "2"
+	},
+	{
+		"anime_title": "Fate Zero OVA",
+		"anime_type": "OVA",
+		"audio_term": "FLAC",
+		"episode_number": "3.5",
+		"file_checksum": "5F5AD026",
+		"file_extension": "mkv",
+		"file_name": "[Coalgirls]_Fate_Zero_OVA3.5_(1280x720_Blu-ray_FLAC)_[5F5AD026].mkv",
+		"id": 10087,
+		"release_group": "Coalgirls",
+		"source": "Blu-ray",
+		"video_resolution": "1280x720"
+	},
+	{
+		"anime_title": "Koi Kaze",
+		"audio_term": "Dual Audio",
+		"episode_number": "01",
+		"file_checksum": "c13cefe0",
+		"file_extension": "mkv",
+		"file_name": "[GrimRipper] Koi Kaze [Dual Audio] Ep01 (c13cefe0).mkv",
+		"id": 634,
+		"release_group": "GrimRipper"
+	},
+	{
+		"anime_title": "Gintama",
+		"episode_number": "111C",
+		"file_extension": "mkv",
+		"file_name": "[HorribleSubs] Gintama - 111C [1080p].mkv",
+		"id": 918,
+		"release_group": "HorribleSubs",
+		"video_resolution": "1080p"
+	},
+	{
+		"anime_title": "Hidamari Sketch x365",
+		"episode_number": "04.1",
+		"file_checksum": "B6CE8458",
+		"file_extension": "mkv",
+		"file_name": "[SpoonSubs]_Hidamari_Sketch_x365_-_04.1_(DVD)[B6CE8458].mkv",
+		"id": 3604,
+		"release_group": "SpoonSubs",
+		"source": "DVD"
+	},
+	{
+		"episode_number": "01",
+		"episode_title": "The Boy in the Iceberg",
+		"file_name": "Ep. 01 - The Boy in the Iceberg"
+	},
+	{
+		"anime_title": "Kuroko no Basuke S3",
+		"episode_number": "01",
+		"episode_number_alt": "51",
+		"file_checksum": "619C57A0",
+		"file_extension": "mkv",
+		"file_name": "[Hatsuyuki]_Kuroko_no_Basuke_S3_-_01_(51)_[720p][10bit][619C57A0].mkv",
+		"id": 24415,
+		"release_group": "Hatsuyuki",
+		"video_resolution": "720p",
+		"video_term": "10bit"
+	},
+	{
+		"anime_title": "Sora no Woto",
+		"audio_term": "AAC",
+		"episode_number": "07.5",
+		"file_checksum": "C37580F8",
+		"file_extension": "mkv",
+		"file_name": "[Elysium]Sora.no.Woto.EP07.5(BD.720p.AAC)[C37580F8].mkv",
+		"id": 6802,
+		"release_group": "Elysium",
+		"source": "BD",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_title": "Sora no Woto",
+		"audio_term": "AAC",
+		"episode_number": "07.5",
+		"episode_title": "Drinking Party - Fortress Battle",
+		"file_checksum": "F7DF16F7",
+		"file_extension": "mkv",
+		"file_name": "[Zurako] Sora no Woto - 07.5 - Drinking Party - Fortress Battle (BD 1080p AAC) [F7DF16F7].mkv",
+		"id": 6802,
+		"release_group": "Zurako",
+		"source": "BD",
+		"video_resolution": "1080p"
+	},
+	{
+		"anime_title": "Maji de Watashi ni Koi Shinasai!!",
+		"episode_number": "02",
+		"file_extension": "mkv",
+		"file_name": "[Hiryuu] Maji de Watashi ni Koi Shinasai!! - 02 [720].mkv",
+		"id": 10213,
+		"release_group": "Hiryuu",
+		"video_resolution": "720"
+	},
+	{
+		"anime_title": "Uchuu no Stellvia",
+		"audio_term": "AAC",
+		"episode_number": "14",
+		"file_checksum": "06EE7355",
+		"file_extension": "mkv",
+		"file_name": "[Kira-Fansub] Uchuu no Stellvia ep 14 (BD H264 1280x960 24fps AAC) [06EE7355].mkv",
+		"id": 113,
+		"release_group": "Kira-Fansub",
+		"source": "BD",
+		"video_resolution": "1280x960",
+		"video_term": [
+			"H264",
+			"24fps"
+		]
+	},
+	{
+		"anime_title": "Yosuga no Sora",
+		"anime_type": "Preview",
+		"audio_term": "FLAC",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "[ANE] Yosuga no Sora - Ep01 Preview (Yorihime ver) [BDRip 1080p x264 FLAC].mkv",
+		"id": 8861,
+		"release_group": "ANE",
+		"source": "BDRip",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Myself Yourself",
+		"audio_term": "DD2.0",
+		"episode_number": "12",
+		"file_checksum": "CB2B37F1",
+		"file_extension": "mkv",
+		"file_name": "[B-G_&_m.3.3.w]_Myself_Yourself_12.DVD(H.264_DD2.0)_[CB2B37F1].mkv",
+		"id": 2926,
+		"release_group": "B-G_&_m.3.3.w",
+		"source": "DVD",
+		"video_term": "H.264"
+	},
+	{
+		"anime_title": "The Animatrix",
+		"audio_term": "DTS",
+		"episode_number": "08",
+		"episode_title": "A Detective Story",
+		"file_extension": "mkv",
+		"file_name": "The.Animatrix.08.A.Detective.Story.720p.BluRay.DTS.x264-ESiR.mkv",
+		"id": 1303,
+		"release_group": "ESiR",
+		"source": "BluRay",
+		"video_resolution": "720p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Oreshura",
+		"episode_number": "01",
+		"episode_title": "The Start Of High School Life Is A War Zone",
+		"file_extension": "mkv",
+		"file_checksum": "211375E6",
+		"file_name": "[DmonHiro] Oreshura #01v2 - The Start Of High School Life Is A War Zone [BD, 720p] [211375E6].mkv",
+		"id": 14749,
+		"release_group": "DmonHiro",
+		"release_version": "2",
+		"source": "BD",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_title": "Abarenbou Rikishi!! Matsutarou",
+		"audio_term": "AAC",
+		"episode_number": "04",
+		"file_extension": "mp4",
+		"file_name": "[모에-Raws] Abarenbou Rikishi!! Matsutarou #04 (ABC 1280x720 x264 AAC).mp4",
+		"id": 22831,
+		"release_group": "모에-Raws",
+		"video_resolution": "1280x720",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Nekomonogatari (Black)",
+		"audio_term": "AAC",
+		"episode_number": [
+			"1",
+			"4"
+		],
+		"file_extension": "mp4",
+		"file_name": "[바카-Raws] Nekomonogatari (Black) #1-4 (BS11 1280x720 x264 AAC).mp4",
+		"id": 15689,
+		"release_group": "바카-Raws",
+		"video_resolution": "1280x720",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Tiger & Bunny",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"episode_title": "All's well that ends well.",
+		"file_checksum": "4A9AB85F",
+		"file_extension": "mkv",
+		"file_name": "[NinjaPanda] Tiger & Bunny #01 All's well that ends well. (v3, 1080p Hi10P, DA AAC) [4A9AB85F].mkv",
+		"id": 9941,
+		"release_group": "NinjaPanda",
+		"release_version": "3",
+		"video_resolution": "1080p",
+		"video_term": "Hi10P"
+	},
+	{
+		"anime_title": "Neko no Ongaeshi",
+		"audio_term": "DualAudio",
+		"file_checksum": "0CDC2145",
+		"file_extension": "mkv",
+		"file_name": "Neko no Ongaeshi - [HQR.remux-DualAudio][NTV.1280x692.h264](0CDC2145).mkv",
+		"id": 597,
+		"video_resolution": "1280x692",
+		"video_term": "h264"
+	},
+	{
+		"anime_title": "Memories Off 3.5",
+		"episode_number": "04",
+		"file_extension": "mkv",
+		"file_name": "[ReDone] Memories Off 3.5 - 04 (DVD 10-bit).mkv",
+		"id": 363,
+		"release_group": "ReDone",
+		"source": "DVD",
+		"video_term": "10-bit"
+	},
+	{
+		"anime_title": "Seirei Tsukai no Blade Dance - SP",
+		"anime_type": "SP",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "F1FF8588",
+		"file_extension": "mkv",
+		"file_name": "[FFF] Seirei Tsukai no Blade Dance - SP01 [BD][720p-AAC][F1FF8588].mkv",
+		"id": 25285,
+		"release_group": "FFF",
+		"source": "BD",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_title": "Byousoku 5 Centimeter",
+		"audio_term": [
+			"2.0ch",
+			"AAC"
+		],
+		"file_name": "Byousoku 5 Centimeter [Blu-Ray][1920x1080 H.264][2.0ch AAC][SOFTSUBS]",
+		"id": 1689,
+		"source": "Blu-Ray",
+		"subtitles": "SOFTSUBS",
+		"video_resolution": "1920x1080",
+		"video_term": "H.264"
+	},
+	{
+		"anime_title": "Swing out Sisters",
+		"audio_term": "AC3",
+		"file_checksum": "3ABD57E6",
+		"file_extension": "mp4",
+		"file_name": "[SubDESU-H] Swing out Sisters Complete Version (720p x264 8bit AC3) [3ABD57E6].mp4",
+		"id": 12143,
+		"release_group": "SubDESU-H",
+		"release_information": "Complete",
+		"video_resolution": "720p",
+		"video_term": [
+			"x264",
+			"8bit"
+		]
+	},
+	{
+		"anime_title": "Cyborg 009",
+		"anime_year": "1968",
+		"episode_number": "06",
+		"file_checksum": "30C15D62",
+		"file_extension": "mp4",
+		"file_name": "Cyborg 009 (1968) [TSHS] episode 06 [30C15D62].mp4",
+		"id": 8394,
+		"release_group": "TSHS"
+	},
+	{
+		"anime_title": "Dragon Ball Kai",
+		"anime_year": "2014",
+		"episode_number": "002",
+		"episode_number_alt": "100",
+		"file_checksum": "DD66AFB7",
+		"file_extension": "mkv",
+		"file_name": "[Hatsuyuki] Dragon Ball Kai (2014) - 002 (100) [1280x720][DD66AFB7].mkv",
+		"id": 22777,
+		"release_group": "Hatsuyuki",
+		"video_resolution": "1280x720"
+	},
+	{
+		"anime_title": "Tegami Bachi (REVERSE) - Letter Bee",
+		"episode_number": "04",
+		"episode_number_alt": "29",
+		"file_checksum": "5203142B",
+		"file_extension": "mkv",
+		"file_name": "[Deep] Tegami Bachi (REVERSE) - Letter Bee - 29 (04) [5203142B].mkv",
+		"id": 8311,
+		"release_group": "Deep"
+	},
+	{
+		"anime_title": "Love Live! The School Idol Movie - PV",
+		"anime_type": [
+			"Movie",
+			"PV"
+		],
+		"file_checksum": "D1A15D2C",
+		"file_extension": "mkv",
+		"file_name": "[FFF] Love Live! The School Idol Movie - PV [D1A15D2C].mkv",
+		"id": 24997,
+		"release_group": "FFF"
+	},
+	{
+		"anime_title": "Tamayura ~graduation photo~ Movie Part 1",
+		"anime_type": "Movie",
+		"file_checksum": "98965607",
+		"file_extension": "mkv",
+		"file_name": "[Nishi-Taku] Tamayura ~graduation photo~ Movie Part 1 [BD][720p][98965607].mkv",
+		"id": 25729,
+		"release_group": "Nishi-Taku",
+		"source": "BD",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_title": "1001 Nights",
+		"anime_year": "1998",
+		"file_name": "[LRL] 1001 Nights (1998) [DVD]",
+		"id": 3914,
+		"release_group": "LRL",
+		"source": "DVD"
+	},
+	{
+		"anime_title": "0",
+		"file_extension": "mkv",
+		"file_name": "[TardRaws] 0 [640x360].mkv",
+		"id": 20707,
+		"release_group": "TardRaws",
+		"video_resolution": "640x360"
+	},
+	{
+		"anime_title": "Crayon Shin-Chan Movie 2 The Secret of Buri Buri Kingdom",
+		"anime_type": "Movie",
+		"anime_year": "1994",
+		"audio_term": "AC3",
+		"file_extension": "avi",
+		"file_name": "[FB] Crayon Shin-Chan Movie 2 The Secret of Buri Buri Kingdom [DivX5 AC3] 1994 [852X480] V2.avi",
+		"id": 3745,
+		"release_group": "FB",
+		"release_version": "2",
+		"video_resolution": "852X480",
+		"video_term": "DivX5"
+	},
+	{
+		"anime_title": "Fairy Tail 2",
+		"episode_number": "52",
+		"episode_number_alt": "227",
+		"file_checksum": "9DF6B8D5",
+		"file_extension": "mkv",
+		"file_name": "[Hatsuyuki-Kaitou]_Fairy_Tail_2_-_52_(227)_[720p][10bit][9DF6B8D5].mkv",
+		"id": 22043,
+		"release_group": "Hatsuyuki-Kaitou",
+		"video_resolution": "720p",
+		"video_term": "10bit"
+	},
+	{
+		"anime_title": "Baby Princess 3D Paradise Love",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "457CC066",
+		"file_extension": "mkv",
+		"file_name": "[FBI] Baby Princess 3D Paradise Love 01v0 [BD][720p-AAC][457CC066].mkv",
+		"id": 10196,
+		"release_group": "FBI",
+		"release_version": "0",
+		"source": "BD",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_title": "Macross Frontier",
+		"episode_number": "01b",
+		"file_checksum": "4D5EC315",
+		"file_extension": "avi",
+		"file_name": "[Shinsen-Subs]_Macross_Frontier_-_01b_[4D5EC315].avi",
+		"id": 3572,
+		"release_group": "Shinsen-Subs"
+	},
+	{
+		"anime_title": "Hidamari Sketch x365",
+		"episode_number": "09a",
+		"file_checksum": "49874745",
+		"file_extension": "mkv",
+		"file_name": "[NamaeNai] Hidamari Sketch x365 - 09a (DVD) [49874745].mkv",
+		"id": 3604,
+		"release_group": "NamaeNai",
+		"source": "DVD"
+	},
+	{
+		"anime_title": "D.Gray-man",
+		"episode_number": "04",
+		"file_extension": "avi",
+		"file_name": "[KLF]_D.Gray-man_04V2.avi",
+		"id": 1482,
+		"release_group": "KLF",
+		"release_version": "2"
+	},
+	{
+		"anime_title": "Bakuman",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "[FaggotryRaws] Bakuman - 01 (NHK E 848x480).mkv",
+		"id": 7674,
+		"release_group": "FaggotryRaws",
+		"video_resolution": "848x480"
+	},
+	{
+		"anime_title": "RWBY",
+		"episode_number": "14",
+		"episode_title": "Forever Fall Part 2",
+		"file_extension": "mp4",
+		"file_name": "[5F] RWBY 14 Forever Fall Part 2 pt-BR.mp4",
+		"id": 0,
+		"language": "pt-BR",
+		"release_group": "5F"
+	},
+	{
+		"anime_title": "Dragon Ball KAI",
+		"episode_number": "01",
+		"file_extension": "mkv",
+		"file_name": "Dragon.Ball.KAI.-.01.-.1080p.BluRay.x264.DHD.mkv",
+		"id": 6033,
+		"source": "BluRay",
+		"video_resolution": "1080p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Ushio to Tora (TV)",
+		"anime_type": "TV",
+		"episode_number": "02",
+		"file_extension": "mkv",
+		"file_name": "[AnimeRG] Ushio to Tora (TV) - 02 [720p] [Xcelent].mkv",
+		"id": 29854,
+		"release_group": "AnimeRG",
+		"video_resolution": "720p"
+	},
+	{
+		"file_name": "[Anime"
+	},
+	{
+		"anime_title": "Gekkan Shoujo Nozaki-kun",
+		"file_name": "Gekkan Shoujo Nozaki-kun [HorribleSubs] (1080p)",
+		"id": 23289,
+		"release_group": "HorribleSubs",
+		"video_resolution": "1080p"
+	},
+	{
+		"anime_title": "Toradora!",
+		"episode_number": "07",
+		"episode_title": "Pool Opening",
+		"file_checksum": "8F59F2BA",
+		"file_name": "[BM&T] Toradora! - 07v2 - Pool Opening [720p Hi10 ] [BD] [8F59F2BA]",
+		"id": 23289,
+		"release_group": "BM&T",
+		"release_version": "2",
+		"source": "BD",
+		"video_term": "Hi10",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_title": "AKB0048",
+		"audio_term": "FLAC",
+		"file_checksum": "C09462E2",
+		"file_name": "[EveTaku] AKB0048 Vol.03 - Making of Kibou-ni-Tsuite Music Video (BDRip 1080i H.264-Hi10P FLAC)[C09462E2]",
+		"id": 12149,
+		"release_group": "EveTaku",
+		"source": "BDRip",
+		"video_term": [
+			"H.264",
+			"Hi10P"
+		],
+		"video_resolution": "1080i",
+		"volume_number": "03"
+	},
+	{
+		"anime_title": "Magi - The Labyrinth Of Magic",
+		"file_name": "[DmonHiro] Magi - The Labyrinth Of Magic - Vol.1v2 (BD, 720p)",
+		"id": 14513,
+		"release_group": "DmonHiro",
+		"release_version": "2",
+		"source": "BD",
+		"video_resolution": "720p",
+		"volume_number": "1"
+	},
+	{
+		"anime_title": "Natsume Yuujinchou Shi",
+		"audio_term": "AAC",
+		"file_name": "[tlacatlc6] Natsume Yuujinchou Shi Vol. 1v2 & Vol. 2 (BD 1280x720 x264 AAC)",
+		"id": 11665,
+		"release_group": "tlacatlc6",
+		"release_version": "2",
+		"source": "BD",
+		"video_term": "x264",
+		"video_resolution": "1280x720",
+		"volume_number": [
+			"1",
+			"2"
+		]
+	},
+	{
+		"anime_title": "Hyouka",
+		"audio_term": "FLAC",
+		"episode_number": [
+			"01",
+			"04"
+		],
+		"file_name": "[Tsundere] Hyouka - 01v2-04 [BDRip h264 1920x1080 10bit FLAC]",
+		"id": 12189,
+		"release_group": "Tsundere",
+		"release_version": "2",
+		"source": "BDRip",
+		"video_resolution": "1920x1080",
+		"video_term": [
+			"h264",
+			"10bit"
+		]
+	},
+	{
+		"anime_title": "Nogizaka Haruka no Himitsu - Purezza",
+		"audio_term": "AAC",
+		"episode_number": [
+			"01",
+			"03"
+		],
+		"file_name": "[Doki] Nogizaka Haruka no Himitsu - Purezza - 01v2-03v2 (1280x720 h264 AAC)",
+		"id": 6023,
+		"release_group": "Doki",
+		"release_version": [
+			"2",
+			"2"
+		],
+		"video_resolution": "1280x720",
+		"video_term": "h264"
+	},
+	{
+		"anime_season": "06",
+		"anime_title": "Fairy Tail",
+		"episode_number": "32",
+		"episode_number_alt": "83",
+		"episode_title": "Tartaros Arc Iron Fist of the Fire Dragon",
+		"file_name": "Fairy Tail - S06E32 - Tartaros Arc Iron Fist of the Fire Dragon [Episode 83]",
+		"id": 6702
+	},
+	{
+		"anime_season": "02",
+		"anime_title": "Noragami",
+		"episode_number": "6",
+		"episode_title": "What Must Be Done",
+		"file_name": "Noragami - S02E06 - What Must Be Done [Episode 6]",
+		"id": 30503
+	},
+	{
+		"anime_title": "Classroom Crisis",
+		"audio_term": "AAC",
+		"file_name": "[Harunatsu] Classroom Crisis - Vol.1 [BD 720p-AAC]",
+		"id": 30383,
+		"release_group": "Harunatsu",
+		"source": "BD",
+		"video_resolution": "720p",
+		"volume_number": "1"
+	},
+	{
+		"anime_title": "Classroom Crisis",
+		"audio_term": "FLAC",
+		"file_name": "[GS] Classroom Crisis Vol.1&2 (BD 1080p 10bit FLAC)",
+		"id": 30383,
+		"release_group": "GS",
+		"source": "BD",
+		"video_resolution": "1080p",
+		"video_term": "10bit",
+		"volume_number": [
+			"1",
+			"2"
+		]
+	},
+	{
+		"anime_title": "Norn9 - Norn + Nonetto",
+		"episode_number": "12",
+		"file_name": "[Infantjedi] Norn9 - Norn + Nonetto - 12",
+		"id": 31452,
+		"release_group": "Infantjedi"
+	},
+	{
+		"anime_title": "Dragon Ball Z Movies",
+		"audio_term": "DTS",
+		"episode_number": [
+			"8",
+			"10"
+		],
+		"file_name": "Dragon_Ball_Z_Movies_8_&_10_[720p,BluRay,DTS,x264]_-_THORA",
+		"id": [
+			901,
+			903
+		],
+		"release_group": "THORA",
+		"source": "BluRay",
+		"video_resolution": "720p",
+		"video_term": "x264"
+	},
+	{
+		"anime_title": "Momokuri",
+		"episode_number": [
+			"01",
+			"02"
+		],
+		"file_name": "[HorribleSubs] Momokuri - 01+02 [720p]",
+		"release_group": "HorribleSubs",
+		"video_resolution": "720p"
+	},
+	{
+		"anime_season": "23",
+		"anime_title": "Nintama Rantarou",
+		"episode_number": "1821",
+		"episode_title": "Buddhist Priest-sama is a Ninja",
+		"file_name": "[(´• ω •`)] Nintama Rantarou - S23E1821 - Buddhist Priest-sama is a Ninja.mkv",
+		"release_group": "(´• ω •`)"
+	},
+	{
+		"anime_season": "01",
+		"anime_title": "Aharen-san wa Hakarenai",
+		"episode_number": "06",
+		"file_name": "[Judas] Aharen-san wa Hakarenai - S01E06v2.mkv",
+		"release_group": "Judas",
+		"release_version": "2"
+	},
+	{
+		"anime_season": "01",
+		"anime_title": "Somali and the Forest Spirit",
+		"audio_term": "AAC",
+		"episode_number": "01",
+		"file_checksum": "BB7C6531",
+		"file_name": "[0x539] Somali and the Forest Spirit - S01E01 (WEB 1080p Hi10P AAC) [BB7C6531].mkv",
+		"release_group": "0x539",
+		"video_resolution": "1080p",
+		"video_term": "Hi10P"
+	}
+]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/anime.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,171 @@
+#ifndef __core__anime_h
+#define __core__anime_h
+#include "core/date.h"
+#include <array>
+#include <map>
+#include <vector>
+
+namespace Anime {
+
+enum class ListStatus {
+	NOT_IN_LIST,
+	CURRENT,
+	PLANNING,
+	COMPLETED,
+	DROPPED,
+	PAUSED
+};
+
+constexpr std::array<ListStatus, 5> ListStatuses{ListStatus::CURRENT, ListStatus::PLANNING, ListStatus::COMPLETED,
+												 ListStatus::DROPPED, ListStatus::PAUSED};
+
+enum class SeriesStatus {
+	UNKNOWN,
+	FINISHED,
+	RELEASING,
+	NOT_YET_RELEASED,
+	CANCELLED,
+	HIATUS
+};
+
+enum class SeriesFormat {
+	UNKNOWN,
+	TV,
+	TV_SHORT,
+	MOVIE,
+	SPECIAL,
+	OVA,
+	ONA,
+	MUSIC,
+	MANGA,
+	NOVEL,
+	ONE_SHOT
+};
+
+enum class SeriesSeason {
+	UNKNOWN,
+	WINTER,
+	SPRING,
+	SUMMER,
+	FALL
+};
+
+enum class TitleLanguage {
+	ROMAJI,
+	NATIVE,
+	ENGLISH
+};
+
+enum class Services {
+	NONE,
+	ANILIST,
+	NB_SERVICES
+};
+
+struct ListInformation {
+	int id = 0;
+	int progress = 0;
+	int score = 0;
+	ListStatus status = ListStatus::NOT_IN_LIST;
+	Date started;
+	Date completed;
+	bool is_private = false;
+	unsigned int rewatched_times = 0;
+	bool rewatching = false;
+	uint64_t updated = 0;
+	std::string notes;
+};
+
+struct SeriesInformation {
+	int id;
+	struct {
+			std::string romaji;
+			std::string english;
+			std::string native;
+	} title;
+	std::vector<std::string> synonyms;
+	int episodes = 0;
+	SeriesStatus status = SeriesStatus::UNKNOWN;
+	Date air_date;
+	std::vector<std::string> genres;
+	std::vector<std::string> producers;
+	SeriesFormat format = SeriesFormat::UNKNOWN;
+	SeriesSeason season = SeriesSeason::UNKNOWN;
+	int audience_score = 0;
+	std::string synopsis;
+	int duration = 0;
+};
+
+class Anime {
+	public:
+		/* User list data */
+		ListStatus GetUserStatus() const;
+		int GetUserProgress() const;
+		int GetUserScore() const;
+		Date GetUserDateStarted() const;
+		Date GetUserDateCompleted() const;
+		bool GetUserIsPrivate() const;
+		unsigned int GetUserRewatchedTimes() const;
+		bool GetUserIsRewatching() const;
+		uint64_t GetUserTimeUpdated() const;
+		std::string GetUserNotes() const;
+
+		void SetUserStatus(ListStatus status);
+		void SetUserProgress(int progress);
+		void SetUserDateStarted(Date const& started);
+		void SetUserDateCompleted(Date const& completed);
+		void SetUserIsPrivate(bool is_private);
+		void SetUserRewatchedTimes(int rewatched);
+		void SetUserIsRewatching(bool rewatching);
+		void SetUserTimeUpdated(uint64_t updated);
+		void SetUserNotes(std::string const& notes);
+
+		/* Series data */
+		int GetId() const;
+		std::string GetRomajiTitle() const;
+		std::string GetEnglishTitle() const;
+		std::string GetNativeTitle() const;
+		std::vector<std::string> GetTitleSynonyms() const;
+		int GetEpisodes() const;
+		SeriesStatus GetAiringStatus() const;
+		Date GetAirDate() const;
+		std::vector<std::string> GetGenres() const;
+		std::vector<std::string> GetProducers() const;
+		SeriesFormat GetFormat() const;
+		SeriesSeason GetSeason() const;
+		int GetAudienceScore() const; /* should be double once MAL and Kitsu are implemented */
+		std::string GetSynopsis() const;
+		int GetDuration() const;
+
+		void SetId(int id);
+		void SetRomajiTitle(std::string const& title);
+		void SetEnglishTitle(std::string const& title);
+		void SetNativeTitle(std::string const& title);
+		void SetTitleSynonyms(std::vector<std::string> const& synonyms);
+		void AddTitleSynonym(std::string const& synonym);
+		void SetEpisodes(int episodes);
+		void SetAiringStatus(SeriesStatus status);
+		void SetAirDate(Date const& date);
+		void SetGenres(std::vector<std::string> const& genres);
+		void SetProducers(std::vector<std::string> const& producers);
+		void SetFormat(SeriesFormat format);
+		void SetSeason(SeriesSeason season);
+		void SetAudienceScore(int audience_score);
+		void SetSynopsis(std::string synopsis);
+		void SetDuration(int duration);
+
+		std::string GetUserPreferredTitle() const;
+
+		/* User stuff */
+		void AddToUserList();
+		bool IsInUserList() const;
+		void RemoveFromUserList();
+
+	private:
+		SeriesInformation info_;
+		std::unique_ptr<struct ListInformation> list_info_;
+};
+
+} // namespace Anime
+
+#endif // __core__anime_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/anime_db.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,23 @@
+#ifndef __core__anime_db_h
+#define __core__anime_db_h
+#include <unordered_map>
+
+namespace Anime {
+
+class Anime;
+
+class Database {
+	public:
+		std::unordered_map<int, Anime> items;
+		int GetTotalAnimeAmount();
+		int GetTotalEpisodeAmount();
+		int GetTotalWatchedAmount();
+		int GetTotalPlannedAmount();
+		double GetAverageScore();
+		double GetScoreDeviation();
+};
+
+inline Database db;
+
+} // namespace Anime
+#endif // __core__anime_db_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/config.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,38 @@
+#ifndef __core__config_h
+#define __core__config_h
+#include "core/anime.h"
+
+enum class Themes {
+	LIGHT,
+	DARK,
+	OS // AKA "Default"
+};
+
+class Config {
+	public:
+		int Load();
+		int Save();
+
+		Anime::Services service;
+		Themes theme;
+
+		struct {
+			public:
+				Anime::TitleLanguage language;
+				bool display_aired_episodes;
+				bool display_available_episodes;
+				bool highlight_anime_if_available;
+				bool highlighted_anime_above_others;
+		} anime_list;
+
+		struct {
+			public:
+				std::string auth_token;
+				std::string username;
+				int user_id;
+		} anilist;
+};
+#define CONFIG_DIR		"weeaboo"
+#define CONFIG_NAME		"config.json"
+#define MAX_LINE_LENGTH 256
+#endif // __core__config_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/date.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,32 @@
+#ifndef __core__date_h
+#define __core__date_h
+#include "json.h"
+#include <QDate>
+#include <cstdint>
+class Date {
+	public:
+		Date();
+		Date(int32_t y);
+		Date(int32_t y, int8_t m, int8_t d);
+		void SetYear(int32_t y);
+		void SetMonth(int8_t m);
+		void SetDay(int8_t d);
+		void VoidYear();
+		void VoidMonth();
+		void VoidDay();
+		int32_t GetYear() const;
+		int8_t GetMonth() const;
+		int8_t GetDay() const;
+		QDate GetAsQDate() const;
+		nlohmann::json GetAsAniListJson() const;
+		bool operator<(const Date& other) const;
+		bool operator>(const Date& other) const;
+		bool operator<=(const Date& other) const;
+		bool operator>=(const Date& other) const;
+
+	private:
+		std::shared_ptr<int32_t> year;
+		std::shared_ptr<int8_t> month;
+		std::shared_ptr<int8_t> day;
+};
+#endif // __core__date_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/filesystem.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,5 @@
+#ifndef __core__filesystem_h
+#define __core__filesystem_h
+#include <filesystem>
+std::filesystem::path get_config_path(void);
+#endif // __core__filesystem_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/json.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,10 @@
+#ifndef __core__json_h
+#define __core__json_h
+#include "../../dep/json/json.h"
+namespace JSON {
+std::string GetString(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, std::string def = "");
+int GetInt(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, int def = 0);
+bool GetBoolean(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, bool def = false);
+double GetDouble(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, double def = 0);
+} // namespace JSON
+#endif // __core__json_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/session.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,17 @@
+#ifndef __core__session_h
+#define __core__session_h
+#include "core/config.h"
+#include <QElapsedTimer>
+
+struct Session {
+		Config config;
+		Session() { timer.start(); }
+		int uptime() { return timer.elapsed(); }
+
+	private:
+		QElapsedTimer timer;
+};
+
+extern Session session;
+
+#endif // __core__session_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/strings.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,18 @@
+#ifndef __core__strings_h
+#define __core__strings_h
+#include <string>
+#include <vector>
+namespace StringUtils {
+/* Implode function: takes a vector of strings and turns it
+   into a string, separated by delimiters. */
+std::string Implode(const std::vector<std::string>& vector, const std::string& delimiter);
+
+/* Substring removal functions */
+std::string ReplaceAll(const std::string& string, const std::string& find, const std::string& replace);
+std::string SanitizeLineEndings(const std::string& string);
+std::string RemoveHtmlTags(const std::string& string);
+
+/* stupid HTML bullshit */
+std::string TextifySynopsis(const std::string& string);
+};	   // namespace StringUtils
+#endif // __core__strings_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/time.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,23 @@
+#ifndef __core__time_h
+#define __core__time_h
+#include <cstdint>
+#include <string>
+namespace Time {
+
+class Duration {
+	public:
+		Duration(int64_t l);
+		int64_t InSeconds();
+		int64_t InMinutes();
+		int64_t InHours();
+		int64_t InDays();
+		std::string AsRelativeString();
+
+	private:
+		int64_t length;
+};
+
+int64_t GetSystemTime();
+
+};	   // namespace Time
+#endif // __core__time_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/dialog/information.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,15 @@
+#ifndef __gui__dialog__information_h
+#define __gui__dialog__information_h
+#include <QDialog>
+#include <functional>
+namespace Anime {
+class Anime;
+}
+
+class InformationDialog : public QDialog {
+		Q_OBJECT
+
+	public:
+		InformationDialog(Anime::Anime& anime, std::function<void()> accept, QWidget* parent = nullptr);
+};
+#endif // __gui__dialog__information_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/dialog/settings.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,65 @@
+#ifndef __gui__dialog__settings_h
+#define __gui__dialog__settings_h
+#include "core/anime.h"
+#include "gui/sidebar.h"
+#include <QComboBox>
+#include <QDialog>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QLineEdit>
+#include <QTabWidget>
+#include <QWidget>
+
+class SettingsPage : public QWidget {
+		Q_OBJECT
+
+	public:
+		SettingsPage(QWidget* parent = nullptr, QString title = "");
+		void SetTitle(QString title);
+		virtual void SaveInfo();
+		void AddTab(QWidget* tab, QString title = "");
+
+	private:
+		QLabel* page_title;
+		QTabWidget* tab_widget;
+};
+
+class SettingsPageServices : public SettingsPage {
+	public:
+		SettingsPageServices(QWidget* parent = nullptr);
+		void SaveInfo() override;
+
+	private:
+		QWidget* CreateMainPage();
+		QWidget* CreateAniListPage();
+		QString username;
+		Anime::Services service;
+};
+
+class SettingsPageApplication : public SettingsPage {
+	public:
+		SettingsPageApplication(QWidget* parent = nullptr);
+		void SaveInfo() override;
+
+	private:
+		QWidget* CreateAnimeListWidget();
+		Anime::TitleLanguage language;
+		bool display_aired_episodes;
+		bool display_available_episodes;
+		bool highlight_anime_if_available;
+		bool highlighted_anime_above_others;
+};
+
+class SettingsDialog : public QDialog {
+		Q_OBJECT
+
+	public:
+		SettingsDialog(QWidget* parent = nullptr);
+		QWidget* CreateServicesMainPage(QWidget* parent);
+		void OnOK();
+
+	private:
+		QHBoxLayout* layout;
+		SideBar* sidebar;
+};
+#endif // __gui__dialog__settings_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/pages/anime_list.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,92 @@
+#ifndef __gui__pages__anime_list_h
+#define __gui__pages__anime_list_h
+#include "core/anime.h"
+#include <QAbstractListModel>
+#include <QSortFilterProxyModel>
+#include <QStyledItemDelegate>
+#include <QTreeView>
+#include <QWidget>
+#include <vector>
+
+class AnimeListWidgetDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+	public:
+		explicit AnimeListWidgetDelegate(QObject* parent);
+
+		QWidget* createEditor(QWidget*, const QStyleOptionViewItem&, const QModelIndex&) const override;
+		void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override;
+};
+
+class AnimeListWidgetSortFilter : public QSortFilterProxyModel {
+	Q_OBJECT
+
+	public:
+		AnimeListWidgetSortFilter(QObject* parent = nullptr);
+
+	protected:
+		bool lessThan(const QModelIndex& l, const QModelIndex& r) const override;
+};
+
+class AnimeListWidgetModel : public QAbstractListModel {
+	Q_OBJECT
+
+	public:
+		enum columns {
+			AL_TITLE,
+			AL_PROGRESS,
+			AL_EPISODES,
+			AL_SCORE,
+			AL_AVG_SCORE,
+			AL_TYPE,
+			AL_SEASON,
+			AL_STARTED,
+			AL_COMPLETED,
+			AL_UPDATED,
+			AL_NOTES,
+			AL_ID, /* Note: This is only used in Qt::UserRole to make my life easier */
+
+			NB_COLUMNS
+		};
+
+		AnimeListWidgetModel(QWidget* parent);
+		~AnimeListWidgetModel() override = default;
+		int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+		int columnCount(const QModelIndex& parent = QModelIndex()) const override;
+		QVariant data(const QModelIndex& index, int role) const override;
+		QVariant headerData(const int section, const Qt::Orientation orientation, const int role) const override;
+		void UpdateAnime(int id);
+};
+
+/* todo: rename these to "page" or something more
+   sensible than "widget" */
+class AnimeListWidget : public QWidget {
+	Q_OBJECT
+
+	public:
+		AnimeListWidget(QWidget* parent);
+		void UpdateAnimeList();
+		void Reset();
+
+	protected:
+		void paintEvent(QPaintEvent*) override;
+		void InitStyle(QStyleOptionTabWidgetFrame* option) const;
+		void InitBasicStyle(QStyleOptionTabWidgetFrame* option) const;
+		void SetupLayout();
+		void showEvent(QShowEvent*) override;
+		void resizeEvent(QResizeEvent* e) override;
+
+	private slots:
+		void DisplayColumnHeaderMenu();
+		void DisplayListMenu();
+		void ItemDoubleClicked();
+		void SetColumnDefaults();
+		int VisibleColumnsCount() const;
+
+	private:
+		QTabBar* tab_bar;
+		QTreeView* tree_view;
+		QRect panelRect;
+		AnimeListWidgetSortFilter* sort_models[5];
+};
+#endif // __gui__pages__anime_list_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/pages/now_playing.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,12 @@
+#ifndef __gui__pages__now_playing_h
+#define __gui__pages__now_playing_h
+#include <QWidget>
+
+class NowPlayingWidget : public QWidget {
+		Q_OBJECT
+
+	public:
+		NowPlayingWidget(QWidget* parent = nullptr);
+};
+
+#endif // __gui__pages__now_playing_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/pages/statistics.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,33 @@
+#ifndef __gui__pages__statistics_h
+#define __gui__pages__statistics_h
+#include "gui/pages/anime_list.h"
+#include <QFrame>
+#include <QPlainTextEdit>
+#include <QWidget>
+
+class StatisticsWidget : public QFrame {
+		Q_OBJECT
+
+	public:
+		StatisticsWidget(QWidget* parent = nullptr);
+		void UpdateStatistics();
+
+	protected:
+		void showEvent(QShowEvent*) override;
+
+	private:
+		std::string MinutesToDateString(int minutes);
+		std::string SecondsToDateString(int seconds);
+
+		QPlainTextEdit* anime_list_data;
+
+		// QPlainTextEdit* score_distribution_title;
+		// QPlainTextEdit* score_distribution_labels;
+		// wxStaticText* score_distribution_graph; // how am I gonna do this
+
+		/* we don't HAVE a local database (yet ;)) */
+		// QPlainTextEdit* local_database_data;
+
+		QPlainTextEdit* application_data;
+};
+#endif // __gui__pages__statistics_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/sidebar.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,25 @@
+#ifndef __gui__sidebar_h
+#define __gui__sidebar_h
+#include <QItemSelectionModel>
+#include <QListWidget>
+#include <QListWidgetItem>
+class SideBar : public QListWidget {
+		Q_OBJECT
+
+	public:
+		SideBar(QWidget* parent = nullptr);
+		QListWidgetItem* AddItem(QString name, QIcon icon = QIcon());
+		QListWidgetItem* AddSeparator();
+		bool IndexIsSeparator(QModelIndex index) const;
+		static QIcon CreateIcon(const char* file);
+
+	signals:
+		void CurrentItemChanged(int index);
+
+	protected:
+		virtual void mouseMoveEvent(QMouseEvent* event) override;
+		QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex& index,
+															 const QEvent* event) const override;
+		int RemoveSeparatorsFromIndex(int index);
+};
+#endif // __gui__sidebar_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/translate/anime.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,10 @@
+#include "core/anime.h"
+
+namespace Translate {
+
+std::string TranslateListStatus(const Anime::ListStatus status);
+std::string TranslateSeriesFormat(const Anime::SeriesFormat format);
+std::string TranslateSeriesSeason(const Anime::SeriesSeason season);
+std::string TranslateSeriesStatus(const Anime::SeriesStatus status);
+
+} // namespace Translate
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/ui_utils.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,78 @@
+#ifndef __gui__ui_utils_h
+#define __gui__ui_utils_h
+#include <QFrame>
+#include <QLabel>
+#include <QPlainTextEdit>
+#include <QSize>
+#include <QString>
+#include <QWidget>
+namespace UiUtils {
+bool IsInDarkMode();
+void SetPlainTextEditData(QPlainTextEdit* text_edit, QString data);
+
+class Header : public QWidget {
+		Q_OBJECT
+
+	public:
+		Header(QString title, QWidget* parent = nullptr);
+		void SetTitle(QString title);
+
+	private:
+		QLabel* static_text_title;
+		QFrame* static_text_line;
+};
+
+class Paragraph : public QPlainTextEdit {
+		Q_OBJECT
+
+	public:
+		Paragraph(QString text, QWidget* parent = nullptr);
+		QSize minimumSizeHint() const override;
+		QSize sizeHint() const override;
+};
+
+/* technically a paragraph and a heading is actually a
+   "section", but that name is equally as confusing as
+   "text paragraph". */
+class TextParagraph : public QWidget {
+		Q_OBJECT
+
+	public:
+		TextParagraph(QString title, QString data, QWidget* parent = nullptr);
+		Header* GetHeader();
+		Paragraph* GetParagraph();
+
+	private:
+		Header* header;
+		Paragraph* paragraph;
+};
+
+class LabelledTextParagraph : public QWidget {
+		Q_OBJECT
+
+	public:
+		LabelledTextParagraph(QString title, QString label, QString data, QWidget* parent = nullptr);
+		Header* GetHeader();
+		Paragraph* GetLabels();
+		Paragraph* GetParagraph();
+
+	private:
+		Header* header;
+		Paragraph* labels;
+		Paragraph* paragraph;
+};
+
+class SelectableTextParagraph : public QWidget {
+		Q_OBJECT
+
+	public:
+		SelectableTextParagraph(QString title, QString data, QWidget* parent = nullptr);
+		Header* GetHeader();
+		Paragraph* GetParagraph();
+
+	private:
+		Header* header;
+		Paragraph* paragraph;
+};
+};	   // namespace UiUtils
+#endif // __gui__ui_utils_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/gui/window.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,22 @@
+#ifndef __window_h
+#define __window_h
+#include "core/config.h"
+#include <QCloseEvent>
+#include <QMainWindow>
+#include <QWidget>
+
+class MainWindow : public QMainWindow {
+		Q_OBJECT
+
+	public:
+		MainWindow(QWidget* parent = nullptr);
+		void SetActivePage(QWidget* page);
+		void SetStyleSheet(enum Themes theme);
+		void ThemeChanged();
+		void closeEvent(QCloseEvent* event) override;
+
+	private:
+		QWidget* main_widget;
+};
+
+#endif // __window_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/services/anilist.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,15 @@
+#ifndef __services__anilist_h
+#define __services__anilist_h
+#include "core/anime.h"
+#include "core/json.h"
+#include <curl/curl.h>
+namespace AniList {
+int Authorize();
+
+/* Read queries */
+int GetAnimeList();
+
+/* Write queries (mutations) */
+int UpdateAnimeEntry(int id);
+};	   // namespace AniList
+#endif // __services__anilist_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/sys/osx/dark_theme.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,10 @@
+#ifndef __sys__osx__dark_theme_h
+#define __sys__osx__dark_theme_h
+namespace osx {
+bool DarkThemeAvailable();
+bool IsInDarkTheme();
+void SetToDarkTheme();
+void SetToLightTheme();
+void SetToAutoTheme();
+} // namespace osx
+#endif // __sys__osx__dark_theme_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/sys/osx/filesystem.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,7 @@
+#ifndef __sys__osx__filesystem_h
+#define __sys__osx__filesystem_h
+#include <string>
+namespace osx {
+std::string GetApplicationSupportDirectory();
+}
+#endif // __sys__osx__filesystem_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/sys/win32/dark_theme.h	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,7 @@
+#ifndef __sys__win32__dark_theme_h
+#define __sys__win32__dark_theme_h
+namespace win32 {
+bool DarkThemeAvailable();
+bool IsInDarkTheme();
+} // namespace win32
+#endif // __sys__win32__dark_theme_h
\ No newline at end of file
--- a/src/anilist.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,239 +0,0 @@
-#include <QMessageBox>
-#include <QDesktopServices>
-#include <QInputDialog>
-#include <QLineEdit>
-#include <curl/curl.h>
-#include <chrono>
-#include <exception>
-#include <format>
-#include "json.h"
-#include "anilist.h"
-#include "anime.h"
-#include "config.h"
-#include "string_utils.h"
-#include "session.h"
-#define CLIENT_ID "13706"
-
-CURL* AniList::curl = NULL;
-CURLcode AniList::res = (CURLcode)0;
-
-size_t AniList::CurlWriteCallback(void *contents, size_t size, size_t nmemb, void *userdata) {
-    ((std::string*)userdata)->append((char*)contents, size * nmemb);
-    return size * nmemb;
-}
-
-std::string AniList::SendRequest(std::string data) {
-	struct curl_slist *list = NULL;
-	std::string userdata;
-	curl = curl_easy_init();
-	if (curl) {
-		list = curl_slist_append(list, "Accept: application/json");
-		list = curl_slist_append(list, "Content-Type: application/json");
-		std::string bearer = "Authorization: Bearer " + session.config.anilist.auth_token;
-		list = curl_slist_append(list, bearer.c_str());
-		curl_easy_setopt(curl, CURLOPT_URL, "https://graphql.anilist.co");
-		curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data.c_str());
-		curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
-		curl_easy_setopt(curl, CURLOPT_WRITEDATA, &userdata);
-		curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &CurlWriteCallback);
-		/* FIXME: This sucks. When using HTTPS, we should ALWAYS make sure that our peer
-		   is actually valid. I assume the best way to go about this would be to bundle a
-		   certificate file, and if it's not found we should *prompt the user* and ask them
-		   if it's okay to contact AniList WITHOUT verification. If so, we're golden, and this
-		   flag will be set. If not, we should abort mission.
-
-		   For this program, it's probably fine to just contact AniList without
-		   HTTPS verification. However it should still be in the list of things to do... */
-		curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, false);
-		res = curl_easy_perform(curl);
-		curl_slist_free_all(list);
-		if (res != CURLE_OK) {
-			QMessageBox box(QMessageBox::Icon::Critical, "", QString("curl_easy_perform(curl) failed!: ") + QString(curl_easy_strerror(res)));
-			box.exec();
-			curl_easy_cleanup(curl);
-			return "";
-		}
-		curl_easy_cleanup(curl);
-		return userdata;
-	}
-	return "";
-}
-
-int AniList::GetUserId(std::string name) {
-#define QUERY "query ($name: String) {\n" \
-			  "  User (name: $name) {\n" \
-			  "    id\n" \
-			  "  }\n" \
-			  "}\n"
-	nlohmann::json json = {
-		{"query", QUERY},
-		{"variables", {
-			{"name", name}
-		}}
-	};
-	auto ret = nlohmann::json::parse(SendRequest(json.dump()));
-	return JSON::GetInt(ret, "/data/User/id"_json_pointer);
-#undef QUERY
-}
-
-/* Maps to convert string forms to our internal enums */
-
-std::map<std::string, enum AnimeWatchingStatus> StringToAnimeWatchingMap = {
-	{"CURRENT",   CURRENT},
-	{"PLANNING",  PLANNING},
-	{"COMPLETED", COMPLETED},
-	{"DROPPED",   DROPPED},
-	{"PAUSED",    PAUSED},
-	{"REPEATING", REPEATING}
-};
-
-std::map<std::string, enum AnimeAiringStatus> StringToAnimeAiringMap = {
-	{"FINISHED",         FINISHED},
-	{"RELEASING",        RELEASING},
-	{"NOT_YET_RELEASED", NOT_YET_RELEASED},
-	{"CANCELLED",        CANCELLED},
-	{"HIATUS",           HIATUS}
-};
-
-std::map<std::string, enum AnimeSeason> StringToAnimeSeasonMap = {
-	{"WINTER", WINTER},
-	{"SPRING", SPRING},
-	{"SUMMER", SUMMER},
-	{"FALL",   FALL}
-};
-
-std::map<std::string, enum AnimeFormat> StringToAnimeFormatMap = {
-	{"TV",       TV},
-	{"TV_SHORT", TV_SHORT},
-	{"MOVIE",    MOVIE},
-	{"SPECIAL",  SPECIAL},
-	{"OVA",      OVA},
-	{"ONA",      ONA},
-	{"MUSIC",    MUSIC},
-	{"MANGA",    MANGA},
-	{"NOVEL",    NOVEL},
-	{"ONE_SHOT", ONE_SHOT}
-};
-
-int AniList::UpdateAnimeList(std::vector<AnimeList>* anime_lists, int id) {
-/* NOTE: these should be in the qrc file */
-#define QUERY "query ($id: Int) {\n" \
-"  MediaListCollection (userId: $id, type: ANIME) {\n" \
-"    lists {\n" \
-"      name\n" \
-"      entries {\n" \
-"        score\n" \
-"        notes\n" \
-"        progress\n" \
-"        startedAt {\n" \
-"          year\n" \
-"          month\n" \
-"          day\n" \
-"        }\n" \
-"        completedAt {\n" \
-"          year\n" \
-"          month\n" \
-"          day\n" \
-"        }\n" \
-"        updatedAt\n" \
-"        media {\n" \
-"          id\n" \
-"          title {\n" \
-"            romaji\n" \
-"            english\n" \
-"            native\n" \
-"          }\n" \
-"          format\n" \
-"          status\n" \
-"          averageScore\n" \
-"          season\n" \
-"          startDate {\n" \
-"            year\n" \
-"            month\n" \
-"            day\n" \
-"          }\n" \
-"          genres\n" \
-"          episodes\n" \
-"          duration\n" \
-"          synonyms\n" \
-"          description(asHtml: false)\n" \
-"        }\n" \
-"      }\n" \
-"    }\n" \
-"  }\n" \
-"}\n"
-	nlohmann::json json = {
-		{"query", QUERY},
-		{"variables", {
-			{"id", id}
-		}}
-	};
-	/* TODO: do a try catch here, catch any json errors and then call
-       Authorize() if needed */
-	auto res = nlohmann::json::parse(SendRequest(json.dump()));
-	/* TODO: make sure that we actually need the wstring converter and see
-	   if we can just get wide strings back from nlohmann::json */
-	for (const auto& list : res["data"]["MediaListCollection"]["lists"].items()) {
-		/* why are the .key() values strings?? */
-		AnimeList anime_list;
-		anime_list.name = JSON::GetString(list.value(), "/name"_json_pointer);
-		for (const auto& entry : list.value()["entries"].items()) {
-			Anime anime;
-			anime.score = JSON::GetInt(entry.value(), "/score"_json_pointer);
-			anime.progress = JSON::GetInt(entry.value(), "/progress"_json_pointer);
-			anime.status = StringToAnimeWatchingMap[JSON::GetString(entry.value(), "/status"_json_pointer)];
-			anime.notes = JSON::GetString(entry.value(), "/notes"_json_pointer);
-
-			anime.started.SetYear(JSON::GetInt(entry.value(), "/startedAt/year"_json_pointer));
-			anime.started.SetMonth(JSON::GetInt(entry.value(), "/startedAt/month"_json_pointer));
-			anime.started.SetDay(JSON::GetInt(entry.value(), "/startedAt/day"_json_pointer));
-
-			anime.completed.SetYear(JSON::GetInt(entry.value(), "/completedAt/year"_json_pointer));
-			anime.completed.SetMonth(JSON::GetInt(entry.value(), "/completedAt/month"_json_pointer));
-			anime.completed.SetDay(JSON::GetInt(entry.value(), "/completedAt/day"_json_pointer));
-
-			anime.updated = JSON::GetInt(entry.value(), "/updatedAt"_json_pointer);
-
-			anime.title.native  = JSON::GetString(entry.value(), "/media/title/native"_json_pointer);
-			anime.title.english = JSON::GetString(entry.value(), "/media/title/english"_json_pointer);
-			anime.title.romaji  = JSON::GetString(entry.value(), "/media/title/romaji"_json_pointer);
-
-			anime.id = JSON::GetInt(entry.value(), "/media/id"_json_pointer);
-			anime.episodes = JSON::GetInt(entry.value(), "/media/episodes"_json_pointer);
-			anime.type = StringToAnimeFormatMap[JSON::GetString(entry.value()["media"], "/media/format"_json_pointer)];
-
-			anime.airing = StringToAnimeAiringMap[JSON::GetString(entry.value()["media"], "/media/status"_json_pointer)];
-
-			anime.air_date.SetYear(JSON::GetInt(entry.value(), "/media/startDate/year"_json_pointer));
-			anime.air_date.SetMonth(JSON::GetInt(entry.value(), "/media/startDate/month"_json_pointer));
-			anime.air_date.SetDay(JSON::GetInt(entry.value(), "/media/startDate/day"_json_pointer));
-
-			anime.audience_score = JSON::GetInt(entry.value(), "/media/averageScore"_json_pointer);
-			anime.season = StringToAnimeSeasonMap[JSON::GetString(entry.value(), "/media/season"_json_pointer)];
-			anime.duration = JSON::GetInt(entry.value(), "/media/duration"_json_pointer);
-			anime.synopsis = StringUtils::TextifySynopsis(JSON::GetString(entry.value(), "/media/description"_json_pointer));
-
-			if (entry.value().contains("/media/genres"_json_pointer) && entry.value()["/media/genres"_json_pointer].is_array())
-				anime.genres = entry.value()["/media/genres"_json_pointer].get<std::vector<std::string>>();
-			if (entry.value().contains("/media/synonyms"_json_pointer) && entry.value()["/media/synonyms"_json_pointer].is_array())
-				anime.synonyms = entry.value()["/media/synonyms"_json_pointer].get<std::vector<std::string>>();
-			anime_list.Add(anime);
-		}
-		anime_lists->push_back(anime_list);
-	}
-	return 1;
-#undef QUERY
-}
-
-int AniList::Authorize() {
-	/* Prompt for PIN */
-	QDesktopServices::openUrl(QUrl("https://anilist.co/api/v2/oauth/authorize?client_id=" CLIENT_ID "&response_type=token"));
-	bool ok;
-	QString token = QInputDialog::getText(0, "Credentials needed!", "Please enter the code given to you after logging in to AniList:", QLineEdit::Normal, "", &ok);
-	if (ok && !token.isEmpty()) {
-		session.config.anilist.auth_token = token.toStdString();
-	} else { // fail
-		return 0;
-	}
-	return 1;
-}
--- a/src/anime.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,194 +0,0 @@
-/*
- * anime.cpp: defining of custom anime-related
- * datatypes & variables
-*/
-#include <chrono>
-#include <string>
-#include <vector>
-#include <cmath>
-#include <algorithm>
-#include "anilist.h"
-#include "anime.h"
-#include "date.h"
-#include "session.h"
-
-std::map<enum AnimeWatchingStatus, std::string> AnimeWatchingToStringMap = {
-	{CURRENT,   "Watching"},
-	{PLANNING,  "Planning"},
-	{COMPLETED, "Completed"},
-	{DROPPED,   "Dropped"},
-	{PAUSED,    "On hold"},
-	{REPEATING, "Rewatching"}
-};
-
-std::map<enum AnimeAiringStatus, std::string> AnimeAiringToStringMap = {
-	{FINISHED,         "Finished"},
-	{RELEASING,        "Airing"},
-	{NOT_YET_RELEASED, "Not aired yet"},
-	{CANCELLED,        "Cancelled"},
-	{HIATUS,           "On hiatus"}
-};
-
-std::map<enum AnimeSeason, std::string> AnimeSeasonToStringMap = {
-	{UNKNOWN, "Unknown"},
-	{WINTER,  "Winter"},
-	{SPRING,  "Spring"},
-	{SUMMER,  "Summer"},
-	{FALL,    "Fall"}
-};
-
-std::map<enum AnimeFormat, std::string> AnimeFormatToStringMap = {
-	{TV,       "TV"},
-	{TV_SHORT, "TV short"},
-	{MOVIE,    "Movie"},
-	{SPECIAL,  "Special"},
-	{OVA,      "OVA"},
-	{ONA,      "ONA"},
-	{MUSIC,    "Music video"},
-	/* these should NEVER be in the list. naybe we should
-	   remove them? */
-	{MANGA,    "Manga"},
-	{NOVEL,    "Novel"},
-	{ONE_SHOT, "One-shot"}
-};
-
-Anime::Anime() {}
-Anime::Anime(const Anime& a) {
-	status = a.status;
-	progress = a.progress;
-	score = a.score;
-	started = a.started;
-	completed = a.completed;
-	updated = a.updated;
-	notes = a.notes;
-	id = a.id;
-	title = a.title;
-	episodes = a.episodes;
-	airing = a.airing;
-	air_date = a.air_date;
-	genres = a.genres;
-	producers = a.producers;
-	type = a.type;
-	season = a.season;
-	audience_score = a.audience_score;
-	synopsis = a.synopsis;
-	duration = a.duration;
-}
-
-std::string Anime::GetUserPreferredTitle() {
-	switch (session.config.anime_list.language) {
-		case NATIVE:
-			return (title.native.empty()) ? title.romaji : title.native;
-		case ENGLISH:
-			return (title.english.empty()) ? title.romaji : title.english;
-		default:
-			return title.romaji;
-	}
-}
-
-std::vector<std::string> Anime::GetTitleSynonyms() {
-	std::vector<std::string> result;
-#define IN_VECTOR(v, k) \
-	(std::count(v.begin(), v.end(), k))
-#define ADD_TO_SYNONYMS(v, k) \
-	if (!k.empty() && !IN_VECTOR(v, k) && k != GetUserPreferredTitle()) v.push_back(k)
-	ADD_TO_SYNONYMS(result, title.english);
-	ADD_TO_SYNONYMS(result, title.romaji);
-	ADD_TO_SYNONYMS(result, title.native);
-	for (auto& synonym : synonyms) {
-		ADD_TO_SYNONYMS(result, synonym);
-	}
-#undef ADD_TO_SYNONYMS
-#undef IN_VECTOR
-	return result;
-}
-
-void AnimeList::Add(Anime& anime) {
-	if (anime_id_to_anime.contains(anime.id))
-		return;
-	anime_list.push_back(anime);
-	anime_id_to_anime.emplace(anime.id, &anime);
-}
-
-void AnimeList::Insert(size_t pos, Anime& anime) {
-	if (anime_id_to_anime.contains(anime.id))
-		return;
-	anime_list.insert(anime_list.begin()+pos, anime);
-	anime_id_to_anime.emplace(anime.id, &anime);
-}
-
-void AnimeList::Delete(size_t index) {
-	anime_list.erase(anime_list.begin()+index);
-}
-
-void AnimeList::Clear() {
-	anime_list.clear();
-}
-
-size_t AnimeList::Size() const {
-	return anime_list.size();
-}
-
-std::vector<Anime>::iterator AnimeList::begin() noexcept {
-	return anime_list.begin();
-}
-
-std::vector<Anime>::iterator AnimeList::end() noexcept {
-	return anime_list.end();
-}
-
-std::vector<Anime>::const_iterator AnimeList::cbegin() noexcept {
-	return anime_list.cbegin();
-}
-
-std::vector<Anime>::const_iterator AnimeList::cend() noexcept {
-	return anime_list.cend();
-}
-
-AnimeList::AnimeList() {}
-AnimeList::AnimeList(const AnimeList& l) {
-	for (unsigned long long i = 0; i < l.Size(); i++) {
-		anime_list.push_back(Anime(l[i]));
-	}
-	name = l.name;
-}
-
-AnimeList& AnimeList::operator=(const AnimeList& l) {
-	if (this != &l) {
-		for (unsigned long long i = 0; i < l.Size(); i++) {
-			this->anime_list.push_back(Anime(l[i]));
-		}
-		this->name = l.name;
-	}
-	return *this;
-}
-
-AnimeList::~AnimeList() {
-	anime_list.clear();
-	anime_list.shrink_to_fit();
-}
-
-Anime* AnimeList::AnimeById(int id) {
-	return anime_id_to_anime.contains(id) ? anime_id_to_anime[id] : nullptr;
-}
-
-bool AnimeList::AnimeInList(int id) {
-	return anime_id_to_anime.contains(id);
-}
-
-int AnimeList::GetAnimeIndex(Anime& anime) const {
-	for (unsigned long long i = 0; i < Size(); i++) {
-		if (&anime_list.at(i) == &anime) { // lazy
-			return i;
-		}
-	}
-	return -1;
-}
-
-Anime& AnimeList::operator[](std::size_t index) {
-	return anime_list.at(index);
-}
-
-const Anime& AnimeList::operator[](std::size_t index) const {
-	return anime_list.at(index);
-}
--- a/src/config.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,106 +0,0 @@
-/**
- * config.cpp:
- * parses the config
- *
- * much of this is similar to the code used in
- * wgsdk...
- * maybe some of this will be C++-ified someday ;)
-**/
-#include <filesystem> /* Sorry, C++17 is just sexy. if you have boost you can probably change this */
-#ifdef MACOSX
-#include <NSSystemDirectories.h>
-#endif
-#include <limits.h>
-#include <cstdlib>
-#include <cstring>
-#include <fstream>
-#include "json.h"
-#include "config.h"
-#include "window.h"
-#include "filesystem.h"
-
-std::map<std::string, enum Themes> StringToTheme = {
-	{"Default", OS},
-	{"Light", LIGHT},
-	{"Dark", DARK}
-};
-
-std::map<enum Themes, std::string> ThemeToString = {
-	{OS, "Default"},
-	{LIGHT, "Light"},
-	{DARK, "Dark"}
-};
-
-std::map<enum AnimeListServices, std::string> ServiceToString {
-	{NONE, "None"},
-	{ANILIST, "AniList"}
-};
-
-std::map<std::string, enum AnimeListServices> StringToService {
-	{"None", NONE},
-	{"AniList", ANILIST}
-};
-
-std::map<enum AnimeTitleLanguage, std::string> AnimeTitleToStringMap = {
-	{ROMAJI,  "Romaji"},
-	{NATIVE,  "Native"},
-	{ENGLISH, "English"}
-};
-
-std::map<std::string, enum AnimeTitleLanguage> StringToAnimeTitleMap = {
-	{"Romaji", ROMAJI},
-	{"Native", NATIVE},
-	{"English", ENGLISH}
-};
-
-int Config::Load() {
-	std::filesystem::path cfg_path = get_config_path();
-	if (!std::filesystem::exists(cfg_path))
-		return 0;
-	std::ifstream config_in(cfg_path.string().c_str(), std::ifstream::in);
-	auto config_js = nlohmann::json::parse(config_in);
-	service = StringToService[JSON::GetString(config_js, "/General/Service"_json_pointer)];
-	anime_list.language = StringToAnimeTitleMap[JSON::GetString(config_js, "/Anime List/Display only aired episodes"_json_pointer, "Romaji")];
-	anime_list.display_aired_episodes = JSON::GetBoolean(config_js, "/Anime List/Display only aired episodes"_json_pointer, true);
-	anime_list.display_available_episodes = JSON::GetBoolean(config_js, "/Anime List/Display only available episodes in library"_json_pointer, true);
-	anime_list.highlight_anime_if_available = JSON::GetBoolean(config_js, "/Anime List/Highlight anime if available"_json_pointer, true);
-	anime_list.highlighted_anime_above_others = JSON::GetBoolean(config_js, "/Anime List/Display highlighted anime above others"_json_pointer);
-	anilist.auth_token = JSON::GetString(config_js, "/Authorization/AniList/Auth Token"_json_pointer);
-	anilist.username = JSON::GetString(config_js, "/Authorization/AniList/Username"_json_pointer);
-	anilist.user_id = JSON::GetInt(config_js, "/Authorization/AniList/User ID"_json_pointer);
-	theme = StringToTheme[JSON::GetString(config_js, "/Appearance/Theme"_json_pointer)];
-	config_in.close();
-	return 0;
-}
-
-int Config::Save() {
-	std::filesystem::path cfg_path = get_config_path();
-	if (!std::filesystem::exists(cfg_path.parent_path()))
-		std::filesystem::create_directories(cfg_path.parent_path());
-	std::ofstream config_out(cfg_path.string().c_str(), std::ofstream::out | std::ofstream::trunc);
-	nlohmann::json config_js = {
-		{"General", {
-			{"Service", ServiceToString[service]}
-		}},
-		{"Anime List", {
-			{"Title language", AnimeTitleToStringMap[anime_list.language]},
-			{"Display only aired episodes", anime_list.display_aired_episodes},
-			{"Display only available episodes in library", anime_list.display_available_episodes},
-			{"Highlight anime if available", anime_list.highlight_anime_if_available},
-			{"Display highlighted anime above others", anime_list.highlighted_anime_above_others}
-		}},
-		{"Authorization", {
-			{"AniList", {
-				{"Auth Token", anilist.auth_token},
-				{"Username",   anilist.username},
-				{"User ID",    anilist.user_id}
-			}}
-		}},
-		{"Appearance", {
-			{"Theme", ThemeToString[theme]}
-		}}
-	};
-	config_out << std::setw(4) << config_js << std::endl;
-	config_out.close();
-	return 0;
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/core/anime.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,277 @@
+/*
+ * anime.cpp: defining of custom anime-related
+ * datatypes & variables
+ */
+#include "core/anime.h"
+#include "core/date.h"
+#include "core/session.h"
+#include <algorithm>
+#include <chrono>
+#include <cmath>
+#include <string>
+#include <vector>
+
+namespace Anime {
+
+/* User list data */
+bool Anime::IsInUserList() const {
+	if (list_info_.get())
+		return true;
+	return false;
+}
+
+void Anime::AddToUserList() {
+	if (!list_info_.get())
+		return;
+	list_info_.reset(new ListInformation);
+}
+
+void Anime::RemoveFromUserList() {
+	if (list_info_.get())
+		return;
+	list_info_.reset();
+}
+
+ListStatus Anime::GetUserStatus() const {
+	assert(list_info_.get());
+	return list_info_->status;
+}
+
+int Anime::GetUserProgress() const {
+	assert(list_info_.get());
+	return list_info_->progress;
+}
+
+int Anime::GetUserScore() const {
+	assert(list_info_.get());
+	return list_info_->score;
+}
+
+Date Anime::GetUserDateStarted() const {
+	assert(list_info_.get());
+	return list_info_->started;
+}
+
+Date Anime::GetUserDateCompleted() const {
+	assert(list_info_.get());
+	return list_info_->completed;
+}
+
+bool Anime::GetUserIsPrivate() const {
+	assert(list_info_.get());
+	return list_info_->is_private;
+}
+
+unsigned int Anime::GetUserRewatchedTimes() const {
+	assert(list_info_.get());
+	return list_info_->rewatched_times;
+}
+
+bool Anime::GetUserIsRewatching() const {
+	assert(list_info_.get());
+	return list_info_->rewatching;
+}
+
+uint64_t Anime::GetUserTimeUpdated() const {
+	assert(list_info_.get());
+	return list_info_->updated;
+}
+
+std::string Anime::GetUserNotes() const {
+	assert(list_info_.get());
+	return list_info_->notes;
+}
+
+void Anime::SetUserStatus(ListStatus status) {
+	assert(list_info_.get());
+	list_info_->status = status;
+}
+
+void Anime::SetUserProgress(int progress) {
+	assert(list_info_.get());
+	list_info_->progress = progress;
+}
+
+void Anime::SetUserDateStarted(Date const& started) {
+	assert(list_info_.get());
+	list_info_->started = started;
+}
+
+void Anime::SetUserDateCompleted(Date const& completed) {
+	assert(list_info_.get());
+	list_info_->completed = completed;
+}
+
+void Anime::SetUserIsPrivate(bool is_private) {
+	assert(list_info_.get());
+	list_info_->is_private = is_private;
+}
+
+void Anime::SetUserRewatchedTimes(int rewatched) {
+	assert(list_info_.get());
+	list_info_->rewatched_times = rewatched;
+}
+
+void Anime::SetUserIsRewatching(bool rewatching) {
+	assert(list_info_.get());
+	list_info_->rewatching = rewatching;
+}
+
+void Anime::SetUserTimeUpdated(uint64_t updated) {
+	assert(list_info_.get());
+	list_info_->updated = updated;
+}
+
+void Anime::SetUserNotes(std::string const& notes) {
+	assert(list_info_.get());
+	list_info_->notes = notes;
+}
+
+/* Series data */
+int Anime::GetId() const {
+	return info_.id;
+}
+
+std::string Anime::GetRomajiTitle() const {
+	return info_.title.romaji;
+}
+
+std::string Anime::GetEnglishTitle() const {
+	return info_.title.english;
+}
+
+std::string Anime::GetNativeTitle() const {
+	return info_.title.native;
+}
+
+std::vector<std::string> Anime::GetTitleSynonyms() const {
+	std::vector<std::string> result;
+#define IN_VECTOR(v, k) (std::count(v.begin(), v.end(), k))
+#define ADD_TO_SYNONYMS(v, k)                                                                                          \
+	if (!k.empty() && !IN_VECTOR(v, k) && k != GetUserPreferredTitle())                                                \
+	v.push_back(k)
+	ADD_TO_SYNONYMS(result, info_.title.english);
+	ADD_TO_SYNONYMS(result, info_.title.romaji);
+	ADD_TO_SYNONYMS(result, info_.title.native);
+	for (auto& synonym : info_.synonyms) {
+		ADD_TO_SYNONYMS(result, synonym);
+	}
+#undef ADD_TO_SYNONYMS
+#undef IN_VECTOR
+	return result;
+}
+
+int Anime::GetEpisodes() const {
+	return info_.episodes;
+}
+
+SeriesStatus Anime::GetAiringStatus() const {
+	return info_.status;
+}
+
+Date Anime::GetAirDate() const {
+	return info_.air_date;
+}
+
+std::vector<std::string> Anime::GetGenres() const {
+	return info_.genres;
+}
+
+std::vector<std::string> Anime::GetProducers() const {
+	return info_.producers;
+}
+
+SeriesFormat Anime::GetFormat() const {
+	return info_.format;
+}
+
+SeriesSeason Anime::GetSeason() const {
+	return info_.season;
+}
+
+int Anime::GetAudienceScore() const {
+	return info_.audience_score;
+}
+
+std::string Anime::GetSynopsis() const {
+	return info_.synopsis;
+}
+
+int Anime::GetDuration() const {
+	return info_.duration;
+}
+
+void Anime::SetId(int id) {
+	info_.id = id;
+}
+
+void Anime::SetRomajiTitle(std::string const& title) {
+	info_.title.romaji = title;
+}
+
+void Anime::SetEnglishTitle(std::string const& title) {
+	info_.title.english = title;
+}
+
+void Anime::SetNativeTitle(std::string const& title) {
+	info_.title.native = title;
+}
+
+void Anime::SetTitleSynonyms(std::vector<std::string> const& synonyms) {
+	info_.synonyms = synonyms;
+}
+
+void Anime::AddTitleSynonym(std::string const& synonym) {
+	info_.synonyms.push_back(synonym);
+}
+
+void Anime::SetEpisodes(int episodes) {
+	info_.episodes = episodes;
+}
+
+void Anime::SetAiringStatus(SeriesStatus status) {
+	info_.status = status;
+}
+
+void Anime::SetAirDate(Date const& date) {
+	info_.air_date = date;
+}
+
+void Anime::SetGenres(std::vector<std::string> const& genres) {
+	info_.genres = genres;
+}
+
+void Anime::SetProducers(std::vector<std::string> const& producers) {
+	info_.producers = producers;
+}
+
+void Anime::SetFormat(SeriesFormat format) {
+	info_.format = format;
+}
+
+void Anime::SetSeason(SeriesSeason season) {
+	info_.season = season;
+}
+
+void Anime::SetAudienceScore(int audience_score) {
+	info_.audience_score = audience_score;
+}
+
+void Anime::SetSynopsis(std::string synopsis) {
+	info_.synopsis = synopsis;
+}
+
+void Anime::SetDuration(int duration) {
+	info_.duration = duration;
+}
+
+std::string Anime::GetUserPreferredTitle() const {
+	switch (session.config.anime_list.language) {
+		case TitleLanguage::NATIVE: return (GetNativeTitle().empty()) ? GetRomajiTitle() : GetNativeTitle();
+		case TitleLanguage::ENGLISH: return (GetEnglishTitle().empty()) ? GetRomajiTitle() : GetEnglishTitle();
+		default: break;
+	}
+	return GetRomajiTitle();
+}
+
+} // namespace Anime
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/core/anime_db.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,78 @@
+#include "core/anime_db.h"
+#include "core/anime.h"
+
+namespace Anime {
+
+int Database::GetTotalAnimeAmount() {
+	int total = 0;
+	for (const auto& [id, anime] : items) {
+		if (anime.IsInUserList())
+			total++;
+	}
+	return total;
+}
+
+int Database::GetTotalEpisodeAmount() {
+	int total = 0;
+	for (const auto& [id, anime] : items) {
+		if (anime.IsInUserList()) {
+			total += anime.GetUserRewatchedTimes() * anime.GetEpisodes();
+			total += anime.GetUserProgress();
+		}
+	}
+	return total;
+}
+
+/* Returns the total watched amount in minutes. */
+int Database::GetTotalWatchedAmount() {
+	int total = 0;
+	for (const auto& [id, anime] : items) {
+		if (anime.IsInUserList()) {
+			total += anime.GetDuration() * anime.GetUserProgress();
+			total += anime.GetEpisodes() * anime.GetDuration() * anime.GetUserRewatchedTimes();
+		}
+	}
+	return total;
+}
+
+/* Returns the total planned amount in minutes.
+   Note that we should probably limit progress to the
+   amount of episodes, as AniList will let you
+   set episode counts up to 32768. But that should
+   rather be handled elsewhere. */
+int Database::GetTotalPlannedAmount() {
+	int total = 0;
+	for (const auto& [id, anime] : items) {
+		if (anime.IsInUserList())
+			total += anime.GetDuration() * (anime.GetEpisodes() - anime.GetUserProgress());
+	}
+	return total;
+}
+
+/* I'm sure many will appreciate this being called an
+   "average" instead of a "mean" */
+double Database::GetAverageScore() {
+	double avg = 0;
+	int amt = 0;
+	for (const auto& [id, anime] : items) {
+		if (anime.IsInUserList() && anime.GetUserScore()) {
+			avg += anime.GetUserScore();
+			amt++;
+		}
+	}
+	return avg / amt;
+}
+
+double Database::GetScoreDeviation() {
+	double squares_sum = 0, avg = GetAverageScore();
+	int amt = 0;
+	for (const auto& [id, anime] : items) {
+		if (anime.GetUserScore()) {
+			squares_sum += std::pow((double)anime.GetUserScore() - avg, 2);
+			amt++;
+		}
+	}
+	return (amt > 0) ? std::sqrt(squares_sum / amt) : 0;
+}
+
+} // namespace Anime
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/core/config.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,106 @@
+/**
+ * config.cpp:
+ * parses the config... lol
+ **/
+#include "core/config.h"
+#include "core/anime.h"
+#include "core/filesystem.h"
+#include "core/json.h"
+#include <cstdlib>
+#include <cstring>
+#include <filesystem>
+#include <fstream>
+#include <limits.h>
+
+std::map<std::string, Themes> StringToTheme = {
+	{"Default", Themes::OS	  },
+	 {"Light",   Themes::LIGHT},
+	   {"Dark",	Themes::DARK }
+};
+
+std::map<Themes, std::string> ThemeToString = {
+	{Themes::OS,	 "Default"},
+	 {Themes::LIGHT, "Light"	},
+	   {Themes::DARK,  "Dark"	}
+};
+
+std::map<Anime::Services, std::string> ServiceToString{
+	{Anime::Services::NONE,	"None"	  },
+	 {Anime::Services::ANILIST, "AniList"}
+};
+
+std::map<std::string, Anime::Services> StringToService{
+	{"None",	 Anime::Services::NONE	  },
+	 {"AniList", Anime::Services::ANILIST}
+};
+
+std::map<Anime::TitleLanguage, std::string> AnimeTitleToStringMap = {
+	{Anime::TitleLanguage::ROMAJI,  "Romaji" },
+	{Anime::TitleLanguage::NATIVE,  "Native" },
+	{Anime::TitleLanguage::ENGLISH, "English"}
+};
+
+std::map<std::string, Anime::TitleLanguage> StringToAnimeTitleMap = {
+	{"Romaji",  Anime::TitleLanguage::ROMAJI },
+	{"Native",  Anime::TitleLanguage::NATIVE },
+	{"English", Anime::TitleLanguage::ENGLISH}
+};
+
+int Config::Load() {
+	std::filesystem::path cfg_path = get_config_path();
+	if (!std::filesystem::exists(cfg_path))
+		return 0;
+	std::ifstream config_in(cfg_path.string().c_str(), std::ifstream::in);
+	auto config_js = nlohmann::json::parse(config_in);
+	service = StringToService[JSON::GetString(config_js, "/General/Service"_json_pointer)];
+	anime_list.language = StringToAnimeTitleMap[JSON::GetString(
+		config_js, "/Anime List/Display only aired episodes"_json_pointer, "Romaji")];
+	anime_list.display_aired_episodes =
+		JSON::GetBoolean(config_js, "/Anime List/Display only aired episodes"_json_pointer, true);
+	anime_list.display_available_episodes =
+		JSON::GetBoolean(config_js, "/Anime List/Display only available episodes in library"_json_pointer, true);
+	anime_list.highlight_anime_if_available =
+		JSON::GetBoolean(config_js, "/Anime List/Highlight anime if available"_json_pointer, true);
+	anime_list.highlighted_anime_above_others =
+		JSON::GetBoolean(config_js, "/Anime List/Display highlighted anime above others"_json_pointer);
+	anilist.auth_token = JSON::GetString(config_js, "/Authorization/AniList/Auth Token"_json_pointer);
+	anilist.username = JSON::GetString(config_js, "/Authorization/AniList/Username"_json_pointer);
+	anilist.user_id = JSON::GetInt(config_js, "/Authorization/AniList/User ID"_json_pointer);
+	theme = StringToTheme[JSON::GetString(config_js, "/Appearance/Theme"_json_pointer)];
+	config_in.close();
+	return 0;
+}
+
+int Config::Save() {
+	std::filesystem::path cfg_path = get_config_path();
+	if (!std::filesystem::exists(cfg_path.parent_path()))
+		std::filesystem::create_directories(cfg_path.parent_path());
+	std::ofstream config_out(cfg_path.string().c_str(), std::ofstream::out | std::ofstream::trunc);
+	// clang-format off
+	nlohmann::json config_js = {
+		{"General",	{
+			{"Service", ServiceToString[service]}
+		}},
+		{"Anime List", {
+			{"Title language", AnimeTitleToStringMap[anime_list.language]},
+			{"Display only aired episodes", anime_list.display_aired_episodes},
+			{"Display only available episodes in library", anime_list.display_available_episodes},
+			{"Highlight anime if available", anime_list.highlight_anime_if_available},
+			{"Display highlighted anime above others", anime_list.highlighted_anime_above_others}
+		}},
+		{"Authorization", {
+			{"AniList", {
+				{"Auth Token", anilist.auth_token},
+				{"Username", anilist.username},
+				{"User ID", anilist.user_id}
+			}}
+		}},
+		{"Appearance", {
+			{"Theme", ThemeToString[theme]}
+		}}
+	};
+	// clang-format on
+	config_out << std::setw(4) << config_js << std::endl;
+	config_out.close();
+	return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/core/date.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,124 @@
+#include "core/date.h"
+#include "core/json.h"
+#include <QDate>
+#include <cstdint>
+#include <tuple>
+
+/* An implementation of AniList's "fuzzy date" */
+
+#define MIN(A, B)                                                                                                      \
+	({                                                                                                                 \
+		__typeof__(A) __a = (A);                                                                                       \
+		__typeof__(B) __b = (B);                                                                                       \
+		__a < __b ? __a : __b;                                                                                         \
+	})
+#define MAX(A, B)                                                                                                      \
+	({                                                                                                                 \
+		__typeof__(A) __a = (A);                                                                                       \
+		__typeof__(B) __b = (B);                                                                                       \
+		__a < __b ? __b : __a;                                                                                         \
+	})
+
+#define CLAMP(x, low, high)                                                                                            \
+	({                                                                                                                 \
+		__typeof__(x) __x = (x);                                                                                       \
+		__typeof__(low) __low = (low);                                                                                 \
+		__typeof__(high) __high = (high);                                                                              \
+		__x > __high ? __high : (__x < __low ? __low : __x);                                                           \
+	})
+
+Date::Date() {
+}
+
+Date::Date(int32_t y) {
+	year = std::make_unique<int32_t>(MAX(0, y));
+}
+
+Date::Date(int32_t y, int8_t m, int8_t d) {
+	year = std::make_unique<int32_t>(MAX(0, y));
+	month = std::make_unique<int8_t>(CLAMP(m, 1, 12));
+	day = std::make_unique<int8_t>(CLAMP(d, 1, 31));
+}
+
+void Date::VoidYear() {
+	year.reset();
+}
+
+void Date::VoidMonth() {
+	month.reset();
+}
+
+void Date::VoidDay() {
+	day.reset();
+}
+
+void Date::SetYear(int32_t y) {
+	year = std::make_unique<int32_t>(MAX(0, y));
+}
+
+void Date::SetMonth(int8_t m) {
+	month = std::make_unique<int8_t>(CLAMP(m, 1, 12));
+}
+
+void Date::SetDay(int8_t d) {
+	day = std::make_unique<int8_t>(CLAMP(d, 1, 31));
+}
+
+int32_t Date::GetYear() const {
+	int32_t* ptr = year.get();
+	if (ptr != nullptr)
+		return *year;
+	return -1;
+}
+
+int8_t Date::GetMonth() const {
+	int8_t* ptr = month.get();
+	if (ptr != nullptr)
+		return *month;
+	return -1;
+}
+
+int8_t Date::GetDay() const {
+	int8_t* ptr = day.get();
+	if (ptr != nullptr)
+		return *day;
+	return -1;
+}
+
+bool Date::operator<(const Date& other) const {
+	int o_y = other.GetYear(), o_m = other.GetMonth(), o_d = other.GetDay();
+	return std::tie(*year, *month, *day) < std::tie(o_y, o_m, o_d);
+}
+
+bool Date::operator>(const Date& other) const {
+	return other < (*this);
+}
+
+bool Date::operator<=(const Date& other) const {
+	return !((*this) > other);
+}
+
+bool Date::operator>=(const Date& other) const {
+	return !((*this) < other);
+}
+
+QDate Date::GetAsQDate() const {
+	return QDate(*year, *month, *day);
+}
+
+nlohmann::json Date::GetAsAniListJson() const {
+	nlohmann::json result;
+	if (year.get())
+		result.insert(result.end(), {"year", *year});
+	else
+		result.insert(result.end(), {"year", nullptr});
+	if (month.get())
+		result.insert(result.end(), {"month", *month});
+	else
+		result.insert(result.end(), {"month", nullptr});
+	if (day.get())
+		result.insert(result.end(), {"day", *day});
+	else
+		result.insert(result.end(), {"day", nullptr});
+	return result;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/core/filesystem.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,35 @@
+#ifdef WIN32
+#include <shlobj.h>
+#elif defined(MACOSX)
+#include "sys/osx/filesystem.h"
+#elif defined(__linux__)
+#include <pwd.h>
+#include <sys/types.h>
+#include <unistd.h>
+#endif
+#include "core/config.h"
+#include "core/filesystem.h"
+#include "core/strings.h"
+#include <QMessageBox>
+#include <filesystem>
+#include <limits.h>
+
+std::filesystem::path get_config_path(void) {
+	std::filesystem::path cfg_path;
+#ifdef WIN32
+	char buf[PATH_MAX + 1];
+	if (SHGetFolderPathAndSubDir(NULL, CSIDL_APPDATA | CSIDL_FLAG_CREATE, NULL, 0, CONFIG_DIR, buf) == S_OK)
+		cfg_path = std::filesystem::path(buf) / CONFIG_NAME;
+#elif defined(MACOSX)
+	/* pass all of our problems to */
+	cfg_path = std::filesystem::path(osx::GetApplicationSupportDirectory()) / CONFIG_DIR / CONFIG_NAME;
+#else // just assume POSIX
+	if (getenv("HOME") != NULL)
+		cfg_path = std::filesystem::path(getenv("HOME")) / ".config" / CONFIG_DIR / CONFIG_NAME;
+#ifdef __linux__
+	else
+		cfg_path = std::filesystem::path(getpwuid(getuid())->pw_dir) / ".config" / CONFIG_DIR / CONFIG_NAME;
+#endif // __linux__
+#endif // !WIN32 && !MACOSX
+	return cfg_path;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/core/json.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,33 @@
+#include "core/json.h"
+
+namespace JSON {
+
+std::string GetString(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, std::string def) {
+	if (json.contains(ptr) && json[ptr].is_string())
+		return json[ptr].get<std::string>();
+	else
+		return def;
+}
+
+int GetInt(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, int def) {
+	if (json.contains(ptr) && json[ptr].is_number())
+		return json[ptr].get<int>();
+	else
+		return def;
+}
+
+bool GetBoolean(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, bool def) {
+	if (json.contains(ptr) && json[ptr].is_boolean())
+		return json[ptr].get<bool>();
+	else
+		return def;
+}
+
+double GetDouble(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, double def) {
+	if (json.contains(ptr) && json[ptr].is_number())
+		return json[ptr].get<double>();
+	else
+		return def;
+}
+
+} // namespace JSON
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/core/strings.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,62 @@
+/**
+ * strings.cpp: Useful functions for manipulating strings
+ **/
+#include "core/strings.h"
+#include <codecvt>
+#include <locale>
+#include <string>
+#include <vector>
+
+namespace Strings {
+
+std::string Implode(const std::vector<std::string>& vector, const std::string& delimiter) {
+	if (vector.size() < 1)
+		return "-";
+	std::string out = "";
+	for (unsigned long long i = 0; i < vector.size(); i++) {
+		out.append(vector.at(i));
+		if (i < vector.size() - 1)
+			out.append(delimiter);
+	}
+	return out;
+}
+
+std::string ReplaceAll(const std::string& string, const std::string& find, const std::string& replace) {
+	std::string result;
+	size_t pos, find_len = find.size(), from = 0;
+	while ((pos = string.find(find, from)) != std::string::npos) {
+		result.append(string, from, pos - from);
+		result.append(replace);
+		from = pos + find_len;
+	}
+	result.append(string, from, std::string::npos);
+	return result;
+}
+
+/* this function probably fucks your RAM but whatevs */
+std::string SanitizeLineEndings(const std::string& string) {
+	std::string result(string);
+	result = ReplaceAll(result, "\r\n", "\n");
+	result = ReplaceAll(result, "<br>", "\n");
+	result = ReplaceAll(result, "\n\n\n", "\n\n");
+	return result;
+}
+
+std::string RemoveHtmlTags(const std::string& string) {
+	std::string html(string);
+	while (html.find("<") != std::string::npos) {
+		auto startpos = html.find("<");
+		auto endpos = html.find(">") + 1;
+
+		if (endpos != std::string::npos) {
+			html.erase(startpos, endpos - startpos);
+		}
+	}
+	return html;
+}
+
+std::string TextifySynopsis(const std::string& string) {
+	return RemoveHtmlTags(SanitizeLineEndings(string));
+}
+
+} // namespace Strings
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/core/time.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,64 @@
+#include "core/time.h"
+#include <cassert>
+#include <cmath>
+#include <cstdint>
+#include <ctime>
+#include <string>
+
+namespace Time {
+
+Duration::Duration(int64_t l) {
+	length = l;
+}
+
+std::string Duration::AsRelativeString() {
+	std::string result;
+
+	auto get = [](int64_t val, const std::string& s, const std::string& p) {
+		return std::to_string(val) + " " + (val == 1 ? s : p);
+	};
+
+	if (InSeconds() < 60)
+		result = get(InSeconds(), "second", "seconds");
+	else if (InMinutes() < 60)
+		result = get(InMinutes(), "minute", "minutes");
+	else if (InHours() < 24)
+		result = get(InHours(), "hour", "hours");
+	else if (InDays() < 28)
+		result = get(InDays(), "day", "days");
+	else if (InDays() < 365)
+		result = get(InDays() / 30, "month", "months");
+	else
+		result = get(InDays() / 365, "year", "years");
+
+	if (length < 0)
+		result = "In " + result;
+	else
+		result += " ago";
+
+	return result;
+}
+
+int64_t Duration::InSeconds() {
+	return length;
+}
+
+int64_t Duration::InMinutes() {
+	return std::llround((double)length / 60.0);
+}
+
+int64_t Duration::InHours() {
+	return std::llround((double)length / 3600.0);
+}
+
+int64_t Duration::InDays() {
+	return std::llround((double)length / 86400.0);
+}
+
+int64_t GetSystemTime() {
+	assert(sizeof(int64_t) >= sizeof(time_t));
+	time_t t = std::time(nullptr);
+	return *reinterpret_cast<int64_t*>(&t);
+}
+
+} // namespace Time
\ No newline at end of file
--- a/src/date.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,73 +0,0 @@
-#include "date.h"
-#include <QDate>
-#include <cstdint>
-#include <tuple>
-
-#define MIN(A,B)    ({ __typeof__(A) __a = (A); __typeof__(B) __b = (B); __a < __b ? __a : __b; })
-#define MAX(A,B)    ({ __typeof__(A) __a = (A); __typeof__(B) __b = (B); __a < __b ? __b : __a; })
-
-#define CLAMP(x, low, high) ({\
-  __typeof__(x) __x = (x); \
-  __typeof__(low) __low = (low);\
-  __typeof__(high) __high = (high);\
-  __x > __high ? __high : (__x < __low ? __low : __x);\
-  })
-
-Date::Date() {
-}
-
-Date::Date(int32_t y) {
-	year = MAX(0, y);
-}
-
-Date::Date(int32_t y, int8_t m, int8_t d) {
-	year = MAX(0, y);
-	month = CLAMP(m, 1, 12);
-	day = CLAMP(d, 1, 31);
-}
-
-void Date::SetYear(int32_t y) {
-	year = MAX(0, y);
-}
-
-void Date::SetMonth(int8_t m) {
-	month = CLAMP(m, 1, 12);
-}
-
-void Date::SetDay(int8_t d) {
-	day = CLAMP(d, 1, 31);
-}
-
-int32_t Date::GetYear() const {
-	return year;
-}
-
-int8_t Date::GetMonth() const {
-	return month;
-}
-
-int8_t Date::GetDay() const {
-	return day;
-}
-
-bool Date::operator< (const Date& other) const {
-	int o_y = other.GetYear(), o_m = other.GetMonth(), o_d = other.GetDay();
-	return std::tie(year,       month,       day)
-		 < std::tie(o_y,        o_m,         o_d);
-}
-
-bool Date::operator> (const Date& other) const {
-	return other < (*this);
-}
-
-bool Date::operator<= (const Date& other) const {
-	return !((*this) > other);
-}
-
-bool Date::operator>= (const Date& other) const {
-	return !((*this) < other);
-}
-
-QDate Date::GetAsQDate() {
-	return QDate(year, month, day);
-}
--- a/src/dialog/information.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,95 +0,0 @@
-#include <QPlainTextEdit>
-#include <QVBoxLayout>
-#include <QTextStream>
-#include <QDebug>
-#include "window.h"
-#include "anime.h"
-#include "anime_list.h"
-#include "information.h"
-#include "ui_utils.h"
-#include "string_utils.h"
-
-#include <QDialogButtonBox>
-
-void InformationDialog::OnOK() {
-	model->UpdateAnime(*anime);
-	QDialog::accept();
-}
-
-InformationDialog::InformationDialog(Anime& a, AnimeListWidgetModel* model, QWidget* parent)
-                                   : QDialog(parent)
-                                     {
-	this->model = model;
-	this->anime = &a;
-	setFixedSize(842, 613);
-	setWindowTitle(tr("Anime Information"));
-	setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | Qt::WindowCloseButtonHint);
-	setObjectName("infodiag");
-
-	/* main widget */
-	QWidget* widget = new QWidget(this);
-	widget->resize(842-175, 530);
-	widget->move(175, 0);
-	widget->setStyleSheet(UiUtils::IsInDarkMode() ? "" : "background-color: white");
-	widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
-
-	/* anime title header text */
-	QPlainTextEdit* anime_title = new QPlainTextEdit(QString::fromUtf8(anime->GetUserPreferredTitle().c_str()), widget);
-	anime_title->setReadOnly(true);
-	anime_title->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-	anime_title->setWordWrapMode(QTextOption::NoWrap);
-	anime_title->setFrameShape(QFrame::NoFrame);
-	anime_title->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
-	anime_title->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-	anime_title->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-	anime_title->setStyleSheet("font-size: 16px; color: blue; background: transparent;");
-	anime_title->resize(636, 28);
-	anime_title->move(0, 12);
-
-	/* tabbed widget */
-	QTabWidget* tabbed_widget = new QTabWidget(widget);
-	tabbed_widget->resize(636, 485);
-	tabbed_widget->move(0, 45);
-	tabbed_widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
-
-	/* main info tab */
-	QWidget* main_information_widget = new QWidget(tabbed_widget);
-	main_information_widget->setLayout(new QVBoxLayout);
-
-	/* alt titles */
-	main_information_widget->layout()->addWidget(new UiUtils::SelectableTextParagraph("Alternative titles", QString::fromUtf8(StringUtils::Implode(anime->GetTitleSynonyms(), ", ").c_str()), main_information_widget));
-
-	/* details */
-	QString details_data;
-	QTextStream details_data_s(&details_data);
-	details_data_s << AnimeFormatToStringMap[anime->type].c_str() << "\n"
-	               << anime->episodes << "\n"
-	               << AnimeAiringToStringMap[anime->airing].c_str() << "\n"
-	               << AnimeSeasonToStringMap[anime->season].c_str() << " " << anime->air_date.GetYear() << "\n"
-	               << StringUtils::Implode(anime->genres, ", ").c_str() << "\n"
-	               << anime->audience_score << "%";
-	main_information_widget->layout()->addWidget(new UiUtils::LabelledTextParagraph("Details", "Type:\nEpisodes:\nStatus:\nSeason:\nGenres:\nScore:", details_data, main_information_widget));
-
-	/* synopsis */
-	UiUtils::SelectableTextParagraph* synopsis = new UiUtils::SelectableTextParagraph("Synopsis", QString::fromUtf8(anime->synopsis.c_str()), main_information_widget);
-	synopsis->GetParagraph()->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
-	((QVBoxLayout*)main_information_widget->layout())->addWidget(synopsis);
-
-	//((QVBoxLayout*)main_information_widget->layout())->addStretch();
-
-	QWidget* settings_widget = new QWidget(tabbed_widget);
-
-	tabbed_widget->addTab(main_information_widget, "Main information");
-	tabbed_widget->addTab(settings_widget, "My list and settings");
-	QDialogButtonBox* button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
-	connect(button_box, &QDialogButtonBox::accepted, this, &InformationDialog::OnOK);
-	connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
-	QVBoxLayout* buttons_layout = new QVBoxLayout(this);
-	//buttons_layout->addWidget(widget, 0, Qt::AlignTop);
-	buttons_layout->addWidget(button_box, 0, Qt::AlignBottom);
-	// this should probably be win32-only
-	setStyleSheet(UiUtils::IsInDarkMode() ? "" : "QDialog#infodiag{background-color: white;}");
-	setLayout(buttons_layout);
-}
-
-#include "moc_information.cpp"
--- a/src/dialog/settings.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,99 +0,0 @@
-#include <QHBoxLayout>
-#include <QVBoxLayout>
-#include <QDialogButtonBox>
-#include <QPlainTextEdit>
-#include <QPlainTextDocumentLayout>
-#include <QComboBox>
-#include <QGroupBox>
-#include <QWidget>
-#include <QStackedWidget>
-#include "settings.h"
-#include "sidebar.h"
-#include "ui_utils.h"
-
-SettingsPage::SettingsPage(QWidget* parent, QString title)
-	: QWidget(parent) {
-	setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
-	page_title = new QLabel(title, this);
-	page_title->setWordWrap(false);
-	page_title->setFrameShape(QFrame::Panel);
-	page_title->setFrameShadow(QFrame::Sunken);
-	page_title->setStyleSheet("QLabel { font-size: 10pt; font-weight: bold; background-color: #ABABAB; color: white; }");
-	page_title->setFixedHeight(23);
-	page_title->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
-	page_title->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
-	tab_widget = new QTabWidget(this);
-	tab_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
-
-	QVBoxLayout* layout = new QVBoxLayout;
-	layout->setMargin(0);
-	layout->addWidget(page_title);
-	layout->addWidget(tab_widget);
-	setLayout(layout);
-}
-
-void SettingsPage::SetTitle(QString title) {
-	page_title->setText(title);
-}
-
-void SettingsPage::AddTab(QWidget* tab, QString title) {
-	tab_widget->addTab(tab, title);
-}
-
-void SettingsPage::SaveInfo() {
-	// no-op... child classes will implement this
-}
-
-void SettingsDialog::OnOK() {
-	QStackedWidget* stacked = (QStackedWidget*)layout->itemAt(1)->widget();
-	for (int i = 0; i < stacked->count(); i++) {
-		((SettingsPage*)stacked->widget(i))->SaveInfo();
-	}
-	QDialog::accept();
-}
-
-SettingsDialog::SettingsDialog(QWidget* parent)
-	: QDialog(parent) {
-	setFixedSize(755, 566);
-	setWindowTitle(tr("Settings"));
-	setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | Qt::WindowCloseButtonHint);
-	QWidget* widget = new QWidget(this);
-	widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
-	sidebar = new SideBar(widget);
-	sidebar->setCurrentItem(sidebar->AddItem(tr("Services"), UiUtils::CreateSideBarIcon(":/icons/24x24/globe.png")));
-	//sidebar->AddItem(tr("Library"), UiUtils::CreateSideBarIcon(":/icons/24x24/inbox-film.png"));
-	sidebar->AddItem(tr("Application"), UiUtils::CreateSideBarIcon(":/icons/24x24/application-sidebar-list.png"));
-	//sidebar->AddItem(tr("Recognition"), UiUtils::CreateSideBarIcon(":/icons/24x24/question.png"));
-	//sidebar->AddItem(tr("Sharing"), UiUtils::CreateSideBarIcon(":/icons/24x24/megaphone.png"));
-	//sidebar->AddItem(tr("Torrents"), UiUtils::CreateSideBarIcon(":/icons/24x24/feed.png"));
-	//sidebar->AddItem(tr("Advanced"), UiUtils::CreateSideBarIcon(":/icons/24x24/gear.png"));
-	sidebar->setIconSize(QSize(24, 24));
-	sidebar->setFrameShape(QFrame::Box);
-	sidebar->setStyleSheet("QListWidget { background-color: white; font-size: 12px; }");
-	sidebar->setFixedWidth(158);
-	sidebar->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
-	
-	QStackedWidget* stacked = new QStackedWidget;
-	stacked->addWidget(new SettingsPageServices(stacked));
-	stacked->addWidget(new SettingsPageApplication(stacked));
-	stacked->setCurrentIndex(0);
-
-	connect(sidebar, &QListWidget::currentRowChanged, stacked, &QStackedWidget::setCurrentIndex);
-
-	layout = new QHBoxLayout;
-	layout->addWidget(sidebar);
-	layout->addWidget(stacked);
-	layout->setMargin(0);
-	widget->setLayout(layout);
-	
-	QDialogButtonBox* button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
-	connect(button_box, &QDialogButtonBox::accepted, this, &SettingsDialog::OnOK);
-	connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
-
-	QVBoxLayout* buttons_layout = new QVBoxLayout(this);
-	buttons_layout->addWidget(widget);
-	buttons_layout->addWidget(button_box);
-	setLayout(buttons_layout);
-}
-
-#include "moc_settings.cpp"
--- a/src/dialog/settings/application.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,127 +0,0 @@
-#include "settings.h"
-#include "anilist.h"
-#include "session.h"
-#include <QGroupBox>
-#include <QComboBox>
-#include <QCheckBox>
-#include <QPushButton>
-#include <QSizePolicy>
-
-QWidget* SettingsPageApplication::CreateAnimeListWidget() {
-	QWidget* result = new QWidget(this);
-	result->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
-
-	QGroupBox* actions_group_box = new QGroupBox(tr("Actions"), result);
-	actions_group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
-
-	/* Actions/Double click */
-	QWidget* double_click_widget = new QWidget(actions_group_box);
-	QLabel* dc_combo_box_label = new QLabel(tr("Double click:"), double_click_widget);
-	QComboBox* dc_combo_box = new QComboBox(double_click_widget);
-	dc_combo_box->addItem(tr("View anime info"));
-
-	QVBoxLayout* double_click_layout = new QVBoxLayout;
-	double_click_layout->addWidget(dc_combo_box_label);
-	double_click_layout->addWidget(dc_combo_box);
-	double_click_layout->setMargin(0);
-	double_click_widget->setLayout(double_click_layout);
-
-	/* Actions/Middle click */
-	QWidget* middle_click_widget = new QWidget(actions_group_box);
-	QLabel* mc_combo_box_label = new QLabel(tr("Middle click:"), middle_click_widget);
-	QComboBox* mc_combo_box = new QComboBox(middle_click_widget);
-	mc_combo_box->addItem(tr("Play next episode"));
-
-	QVBoxLayout* middle_click_layout = new QVBoxLayout;
-	middle_click_layout->addWidget(mc_combo_box_label);
-	middle_click_layout->addWidget(mc_combo_box);
-	middle_click_layout->setMargin(0);
-	middle_click_widget->setLayout(middle_click_layout);
-
-	/* Actions */
-	QHBoxLayout* actions_layout = new QHBoxLayout;
-	actions_layout->addWidget(double_click_widget);
-	actions_layout->addWidget(middle_click_widget);
-	actions_group_box->setLayout(actions_layout);
-
-	QGroupBox* appearance_group_box = new QGroupBox(tr("Appearance"), result);
-	appearance_group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
-
-	QLabel* lang_combo_box_label = new QLabel(tr("Title language preference:"), appearance_group_box);
-	QComboBox* lang_combo_box = new QComboBox(appearance_group_box);
-	lang_combo_box->addItem(tr("Romaji"));
-	lang_combo_box->addItem(tr("Native"));
-	lang_combo_box->addItem(tr("English"));
-	connect(lang_combo_box, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index){
-		language = static_cast<enum AnimeTitleLanguage>(index);
-	});
-	lang_combo_box->setCurrentIndex(language);
-
-	QCheckBox* hl_anime_box = new QCheckBox(tr("Highlight anime if next episode is available in library folders"), appearance_group_box);
-	QCheckBox* hl_above_anime_box = new QCheckBox(tr("Display highlighted anime above others"), appearance_group_box);
-	connect(hl_anime_box, &QCheckBox::stateChanged, this, [this, hl_above_anime_box](int state){
-		highlight_anime_if_available = (state == Qt::Unchecked) ? false : true;
-		hl_above_anime_box->setEnabled(state);
-	});
-	connect(hl_above_anime_box, &QCheckBox::stateChanged, this, [this](int state){
-		highlight_anime_if_available = (state == Qt::Unchecked) ? false : true;
-	});
-	hl_anime_box->setCheckState(highlight_anime_if_available ? Qt::Checked : Qt::Unchecked);
-	hl_above_anime_box->setCheckState(highlighted_anime_above_others ? Qt::Checked : Qt::Unchecked);
-	hl_above_anime_box->setEnabled(hl_anime_box->checkState() != Qt::Unchecked);
-	hl_above_anime_box->setStyleSheet("margin-left: 10px;");
-
-	/* Appearance */
-	QVBoxLayout* appearance_layout = new QVBoxLayout;
-	appearance_layout->addWidget(lang_combo_box_label);
-	appearance_layout->addWidget(lang_combo_box);
-	appearance_layout->addWidget(hl_anime_box);
-	appearance_layout->addWidget(hl_above_anime_box);
-	appearance_group_box->setLayout(appearance_layout);
-
-	QGroupBox* progress_group_box = new QGroupBox(tr("Progress"), result);
-	progress_group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
-
-	QCheckBox* progress_display_aired_episodes = new QCheckBox(tr("Display aired episodes (estimated)"), progress_group_box);
-	connect(progress_display_aired_episodes, &QCheckBox::stateChanged, this, [this](int state){
-		display_aired_episodes = (state == Qt::Unchecked) ? false : true;
-	});
-	progress_display_aired_episodes->setCheckState(display_aired_episodes ? Qt::Checked : Qt::Unchecked);
-
-	QCheckBox* progress_display_available_episodes = new QCheckBox(tr("Display available episodes in library folders"), progress_group_box);
-	connect(progress_display_available_episodes, &QCheckBox::stateChanged, this, [this](int state){
-		display_available_episodes = (state == Qt::Unchecked) ? false : true;
-	});
-	progress_display_available_episodes->setCheckState(display_available_episodes ? Qt::Checked : Qt::Unchecked);
-
-	QVBoxLayout* progress_layout = new QVBoxLayout;
-	progress_layout->addWidget(progress_display_aired_episodes);
-	progress_layout->addWidget(progress_display_available_episodes);
-	progress_group_box->setLayout(progress_layout);
-
-	QVBoxLayout* full_layout = new QVBoxLayout;
-	full_layout->addWidget(actions_group_box);
-	full_layout->addWidget(appearance_group_box);
-	full_layout->addWidget(progress_group_box);
-	full_layout->addStretch();
-	result->setLayout(full_layout);
-	return result;
-}
-
-void SettingsPageApplication::SaveInfo() {
-	session.config.anime_list.language = language;
-	session.config.anime_list.highlighted_anime_above_others = highlighted_anime_above_others;
-	session.config.anime_list.highlight_anime_if_available = highlight_anime_if_available;
-	session.config.anime_list.display_aired_episodes = display_aired_episodes;
-	session.config.anime_list.display_available_episodes = display_available_episodes;
-}
-
-SettingsPageApplication::SettingsPageApplication(QWidget* parent)
-	: SettingsPage(parent, tr("Application")) {
-	language = session.config.anime_list.language;
-	highlighted_anime_above_others = session.config.anime_list.highlighted_anime_above_others;
-	highlight_anime_if_available = session.config.anime_list.highlight_anime_if_available;
-	display_aired_episodes = session.config.anime_list.display_aired_episodes;
-	display_available_episodes = session.config.anime_list.display_available_episodes;
-	AddTab(CreateAnimeListWidget(), tr("Anime list"));
-}
--- a/src/dialog/settings/services.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,96 +0,0 @@
-#include "settings.h"
-#include "anilist.h"
-#include "session.h"
-#include <QGroupBox>
-#include <QComboBox>
-#include <QPushButton>
-#include <QSizePolicy>
-
-QWidget* SettingsPageServices::CreateMainPage() {
-	QWidget* result = new QWidget(this);
-	result->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
-
-	QGroupBox* sync_group_box = new QGroupBox(tr("Synchronization"), result);
-	sync_group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
-
-	QLabel* sync_combo_box_label = new QLabel(tr("Active service and metadata provider:"), sync_group_box);
-
-	QComboBox* sync_combo_box = new QComboBox(sync_group_box);
-	sync_combo_box->addItem(tr("AniList"));
-	connect(sync_combo_box, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index){
-		service = static_cast<enum AnimeListServices>(index + 1);
-	});
-	sync_combo_box->setCurrentIndex(service - 1);
-
-	QLabel* sync_note_label = new QLabel(tr("Note: Weeaboo is unable to synchronize multiple services at the same time."), sync_group_box);
-
-	QVBoxLayout* sync_layout = new QVBoxLayout;
-	sync_layout->addWidget(sync_combo_box_label);
-	sync_layout->addWidget(sync_combo_box);
-	sync_layout->addWidget(sync_note_label);
-	sync_group_box->setLayout(sync_layout);
-
-	QVBoxLayout* full_layout = new QVBoxLayout;
-	full_layout->addWidget(sync_group_box);
-	full_layout->addStretch();
-	result->setLayout(full_layout);
-	return result;
-}
-
-QWidget* SettingsPageServices::CreateAniListPage() {
-	QWidget* result = new QWidget(this);
-	result->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
-
-	QGroupBox* group_box = new QGroupBox(tr("Account"), result);
-	group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
-
-	QLabel* username_entry_label = new QLabel(tr("Username: (not your email address)"), group_box);
-
-	QWidget* auth_widget = new QWidget(group_box);
-	QLineEdit* username_entry = new QLineEdit(username, auth_widget);
-	connect(username_entry, &QLineEdit::editingFinished, this, [this, username_entry]{
-		username = username_entry->text();
-	});
-
-	QPushButton* auth_button = new QPushButton(auth_widget);
-	connect(auth_button, &QPushButton::clicked, this, []{
-		AniList a;
-		a.Authorize();
-	});
-	auth_button->setText(session.config.anilist.auth_token.empty() ? tr("Authorize...") : tr("Re-authorize..."));
-
-	QHBoxLayout* auth_layout = new QHBoxLayout;
-	auth_layout->addWidget(username_entry);
-	auth_layout->addWidget(auth_button);
-	auth_widget->setLayout(auth_layout);
-
-	QLabel* note_label = new QLabel(tr("<a href=\"http://anilist.co/\">Create a new AniList account</a>"), group_box);
-	note_label->setTextFormat(Qt::RichText);
-	note_label->setTextInteractionFlags(Qt::TextBrowserInteraction);
-	note_label->setOpenExternalLinks(true);
-
-	QVBoxLayout* layout = new QVBoxLayout;
-	layout->addWidget(username_entry_label);
-	layout->addWidget(auth_widget);
-	layout->addWidget(note_label);
-	group_box->setLayout(layout);
-
-	QVBoxLayout* full_layout = new QVBoxLayout;
-	full_layout->addWidget(group_box);
-	full_layout->addStretch();
-	result->setLayout(full_layout);
-	return result;
-}
-
-void SettingsPageServices::SaveInfo() {
-	session.config.anilist.username = username.toStdString();
-	session.config.service = service;
-}
-
-SettingsPageServices::SettingsPageServices(QWidget* parent)
-	: SettingsPage(parent, tr("Services")) {
-	username = QString::fromUtf8(session.config.anilist.username.c_str());
-	service = session.config.service;
-	AddTab(CreateMainPage(), tr("Main"));
-	AddTab(CreateAniListPage(), tr("AniList"));
-}
--- a/src/filesystem.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,35 +0,0 @@
-#ifdef _WIN32
-#include <shlobj.h>
-#elif defined(MACOSX)
-#include "sys/osx/filesystem.h"
-#elif defined(__linux__)
-#include <unistd.h>
-#include <sys/types.h>
-#include <pwd.h>
-#endif
-#include <filesystem>
-#include <limits.h>
-#include <QMessageBox>
-#include "config.h"
-#include "filesystem.h"
-#include "string_utils.h"
-
-std::filesystem::path get_config_path(void) {
-	std::filesystem::path cfg_path;
-#ifdef _WIN32
-	char buf[PATH_MAX+1];
-	if (SHGetFolderPathAndSubDir(NULL, CSIDL_APPDATA | CSIDL_FLAG_CREATE, NULL, 0, CONFIG_DIR, buf) == S_OK)
-		cfg_path = std::filesystem::path(buf) / CONFIG_NAME;
-#elif defined(MACOSX)
-	/* pass all of our problems to */
- 	cfg_path = std::filesystem::path(StringUtils::Utf8ToWstr(osx::GetApplicationSupportDirectory())) / CONFIG_DIR / CONFIG_NAME;
-#else // just assume POSIX
-	if (getenv("HOME") != NULL)
-		cfg_path = std::filesystem::path(getenv("HOME")) / ".config" / CONFIG_DIR / CONFIG_NAME;
-#ifdef __linux__
-	else
-		cfg_path = std::filesystem::path(getpwuid(getuid())->pw_dir) / ".config" / CONFIG_DIR / CONFIG_NAME;
-#endif // __linux__
-#endif
-	return cfg_path;
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/dialog/information.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,108 @@
+#include "gui/dialog/information.h"
+#include "core/anime.h"
+#include "core/strings.h"
+#include "gui/pages/anime_list.h"
+#include "gui/translate/anime.h"
+#include "gui/ui_utils.h"
+#include "gui/window.h"
+#include <QDebug>
+#include <QDialogButtonBox>
+#include <QPlainTextEdit>
+#include <QTextStream>
+#include <QVBoxLayout>
+#include <functional>
+
+InformationDialog::InformationDialog(Anime::Anime& anime, std::function<void()> accept, QWidget* parent)
+	: QDialog(parent) {
+	setFixedSize(842, 613);
+	setWindowTitle(tr("Anime Information"));
+	setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | Qt::WindowCloseButtonHint);
+	setObjectName("infodiag");
+	setStyleSheet(UiUtils::IsInDarkMode() ? "" : "QDialog#infodiag{background-color: white;}");
+
+	QWidget* widget = new QWidget(this);
+
+	/* "sidebar", includes... just the anime image :) */
+	QWidget* sidebar = new QWidget(widget);
+	sidebar->setFixedWidth(175);
+
+	/* main widget */
+	QWidget* main_widget = new QWidget(widget);
+	main_widget->setStyleSheet(UiUtils::IsInDarkMode() ? "" : "background-color: white");
+	main_widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+
+	/* anime title header text */
+	UiUtils::Paragraph* anime_title =
+		new UiUtils::Paragraph(QString::fromUtf8(anime.GetUserPreferredTitle().c_str()), main_widget);
+	anime_title->setReadOnly(true);
+	anime_title->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+	anime_title->setWordWrapMode(QTextOption::NoWrap);
+	anime_title->setFrameShape(QFrame::NoFrame);
+	anime_title->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
+	anime_title->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+	anime_title->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+	anime_title->setStyleSheet("font-size: 16px; color: blue; background: transparent;");
+
+	/* tabbed widget */
+	QTabWidget* tabbed_widget = new QTabWidget(main_widget);
+	tabbed_widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+
+	/* main info tab */
+	QWidget* main_information_widget = new QWidget(tabbed_widget);
+	main_information_widget->setLayout(new QVBoxLayout);
+
+	/* alt titles */
+	main_information_widget->layout()->addWidget(new UiUtils::SelectableTextParagraph(
+		"Alternative titles", QString::fromUtf8(StringUtils::Implode(anime.GetTitleSynonyms(), ", ").c_str()),
+		main_information_widget));
+
+	/* details */
+	QString details_data;
+	QTextStream details_data_s(&details_data);
+	details_data_s << Translate::TranslateSeriesFormat(anime.GetFormat()).c_str() << "\n"
+				   << anime.GetEpisodes() << "\n"
+				   << Translate::TranslateListStatus(anime.GetUserStatus()).c_str() << "\n"
+				   << Translate::TranslateSeriesSeason(anime.GetSeason()).c_str() << " " << anime.GetAirDate().GetYear()
+				   << "\n"
+				   << StringUtils::Implode(anime.GetGenres(), ", ").c_str() << "\n"
+				   << anime.GetAudienceScore() << "%";
+	main_information_widget->layout()->addWidget(new UiUtils::LabelledTextParagraph(
+		"Details", "Type:\nEpisodes:\nStatus:\nSeason:\nGenres:\nScore:", details_data, main_information_widget));
+
+	/* synopsis */
+	UiUtils::SelectableTextParagraph* synopsis = new UiUtils::SelectableTextParagraph(
+		"Synopsis", QString::fromUtf8(anime.GetSynopsis().c_str()), main_information_widget);
+
+	synopsis->GetParagraph()->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
+	((QVBoxLayout*)main_information_widget->layout())->addWidget(synopsis);
+
+	QWidget* settings_widget = new QWidget(tabbed_widget);
+
+	tabbed_widget->addTab(main_information_widget, "Main information");
+	tabbed_widget->addTab(settings_widget, "My list and settings");
+
+	QVBoxLayout* main_layout = new QVBoxLayout;
+	main_layout->addWidget(anime_title);
+	main_layout->addWidget(tabbed_widget);
+	main_layout->setMargin(0);
+	main_widget->setLayout(main_layout);
+
+	QHBoxLayout* layout = new QHBoxLayout;
+	layout->addWidget(sidebar);
+	layout->addWidget(main_widget);
+	widget->setLayout(layout);
+
+	QDialogButtonBox* button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+	connect(button_box, &QDialogButtonBox::accepted, this, [this, accept] {
+		accept();
+		QDialog::accept();
+	});
+	connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
+
+	QVBoxLayout* buttons_layout = new QVBoxLayout;
+	buttons_layout->addWidget(widget);
+	buttons_layout->addWidget(button_box, 0, Qt::AlignBottom);
+	setLayout(buttons_layout);
+}
+
+#include "gui/dialog/moc_information.cpp"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/dialog/settings.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,98 @@
+#include "gui/dialog/settings.h"
+#include "gui/sidebar.h"
+#include "gui/ui_utils.h"
+#include <QComboBox>
+#include <QDialogButtonBox>
+#include <QGroupBox>
+#include <QHBoxLayout>
+#include <QPlainTextDocumentLayout>
+#include <QPlainTextEdit>
+#include <QStackedWidget>
+#include <QVBoxLayout>
+#include <QWidget>
+
+SettingsPage::SettingsPage(QWidget* parent, QString title) : QWidget(parent) {
+	setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
+	page_title = new QLabel(title, this);
+	page_title->setWordWrap(false);
+	page_title->setFrameShape(QFrame::Panel);
+	page_title->setFrameShadow(QFrame::Sunken);
+	page_title->setStyleSheet(
+		"QLabel { font-size: 10pt; font-weight: bold; background-color: #ABABAB; color: white; }");
+	page_title->setFixedHeight(23);
+	page_title->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
+	page_title->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
+	tab_widget = new QTabWidget(this);
+	tab_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
+
+	QVBoxLayout* layout = new QVBoxLayout;
+	layout->setMargin(0);
+	layout->addWidget(page_title);
+	layout->addWidget(tab_widget);
+	setLayout(layout);
+}
+
+void SettingsPage::SetTitle(QString title) {
+	page_title->setText(title);
+}
+
+void SettingsPage::AddTab(QWidget* tab, QString title) {
+	tab_widget->addTab(tab, title);
+}
+
+void SettingsPage::SaveInfo() {
+	// no-op... child classes will implement this
+}
+
+void SettingsDialog::OnOK() {
+	QStackedWidget* stacked = (QStackedWidget*)layout->itemAt(1)->widget();
+	for (int i = 0; i < stacked->count(); i++) {
+		((SettingsPage*)stacked->widget(i))->SaveInfo();
+	}
+	QDialog::accept();
+}
+
+SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) {
+	setFixedSize(755, 566);
+	setWindowTitle(tr("Settings"));
+	setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | Qt::WindowCloseButtonHint);
+	QWidget* widget = new QWidget(this);
+	widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
+	sidebar = new SideBar(widget);
+	sidebar->setCurrentItem(sidebar->AddItem(tr("Services"), SideBar::CreateIcon(":/icons/24x24/globe.png")));
+	// sidebar->AddItem(tr("Library"), SideBar::CreateIcon(":/icons/24x24/inbox-film.png"));
+	sidebar->AddItem(tr("Application"), SideBar::CreateIcon(":/icons/24x24/application-sidebar-list.png"));
+	// sidebar->AddItem(tr("Recognition"), SideBar::CreateIcon(":/icons/24x24/question.png"));
+	// sidebar->AddItem(tr("Sharing"), SideBar::CreateIcon(":/icons/24x24/megaphone.png"));
+	// sidebar->AddItem(tr("Torrents"), SideBar::CreateIcon(":/icons/24x24/feed.png"));
+	// sidebar->AddItem(tr("Advanced"), SideBar::CreateIcon(":/icons/24x24/gear.png"));
+	sidebar->setIconSize(QSize(24, 24));
+	sidebar->setFrameShape(QFrame::Box);
+	sidebar->setStyleSheet("QListWidget { background-color: white; font-size: 12px; }");
+	sidebar->setFixedWidth(158);
+	sidebar->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
+
+	QStackedWidget* stacked = new QStackedWidget;
+	stacked->addWidget(new SettingsPageServices(stacked));
+	stacked->addWidget(new SettingsPageApplication(stacked));
+	stacked->setCurrentIndex(0);
+
+	connect(sidebar, &QListWidget::currentRowChanged, stacked, &QStackedWidget::setCurrentIndex);
+
+	layout = new QHBoxLayout;
+	layout->addWidget(sidebar);
+	layout->addWidget(stacked);
+	layout->setMargin(0);
+	widget->setLayout(layout);
+
+	QDialogButtonBox* button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+	connect(button_box, &QDialogButtonBox::accepted, this, &SettingsDialog::OnOK);
+	connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
+
+	QVBoxLayout* buttons_layout = new QVBoxLayout(this);
+	buttons_layout->addWidget(widget);
+	buttons_layout->addWidget(button_box);
+	setLayout(buttons_layout);
+}
+
+#include "gui/dialog/moc_settings.cpp"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/dialog/settings/application.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,125 @@
+#include "core/session.h"
+#include "gui/dialog/settings.h"
+#include <QCheckBox>
+#include <QComboBox>
+#include <QGroupBox>
+#include <QPushButton>
+#include <QSizePolicy>
+
+QWidget* SettingsPageApplication::CreateAnimeListWidget() {
+	QWidget* result = new QWidget(this);
+	result->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
+
+	QGroupBox* actions_group_box = new QGroupBox(tr("Actions"), result);
+	actions_group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
+
+	/* Actions/Double click */
+	QWidget* double_click_widget = new QWidget(actions_group_box);
+	QLabel* dc_combo_box_label = new QLabel(tr("Double click:"), double_click_widget);
+	QComboBox* dc_combo_box = new QComboBox(double_click_widget);
+	dc_combo_box->addItem(tr("View anime info"));
+
+	QVBoxLayout* double_click_layout = new QVBoxLayout;
+	double_click_layout->addWidget(dc_combo_box_label);
+	double_click_layout->addWidget(dc_combo_box);
+	double_click_layout->setMargin(0);
+	double_click_widget->setLayout(double_click_layout);
+
+	/* Actions/Middle click */
+	QWidget* middle_click_widget = new QWidget(actions_group_box);
+	QLabel* mc_combo_box_label = new QLabel(tr("Middle click:"), middle_click_widget);
+	QComboBox* mc_combo_box = new QComboBox(middle_click_widget);
+	mc_combo_box->addItem(tr("Play next episode"));
+
+	QVBoxLayout* middle_click_layout = new QVBoxLayout;
+	middle_click_layout->addWidget(mc_combo_box_label);
+	middle_click_layout->addWidget(mc_combo_box);
+	middle_click_layout->setMargin(0);
+	middle_click_widget->setLayout(middle_click_layout);
+
+	/* Actions */
+	QHBoxLayout* actions_layout = new QHBoxLayout;
+	actions_layout->addWidget(double_click_widget);
+	actions_layout->addWidget(middle_click_widget);
+	actions_group_box->setLayout(actions_layout);
+
+	QGroupBox* appearance_group_box = new QGroupBox(tr("Appearance"), result);
+	appearance_group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
+
+	QLabel* lang_combo_box_label = new QLabel(tr("Title language preference:"), appearance_group_box);
+	QComboBox* lang_combo_box = new QComboBox(appearance_group_box);
+	lang_combo_box->addItem(tr("Romaji"));
+	lang_combo_box->addItem(tr("Native"));
+	lang_combo_box->addItem(tr("English"));
+	connect(lang_combo_box, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
+			[this](int index) { language = static_cast<Anime::TitleLanguage>(index); });
+	lang_combo_box->setCurrentIndex(static_cast<int>(language));
+
+	QCheckBox* hl_anime_box =
+		new QCheckBox(tr("Highlight anime if next episode is available in library folders"), appearance_group_box);
+	QCheckBox* hl_above_anime_box = new QCheckBox(tr("Display highlighted anime above others"), appearance_group_box);
+	connect(hl_anime_box, &QCheckBox::stateChanged, this, [this, hl_above_anime_box](int state) {
+		highlight_anime_if_available = (state == Qt::Unchecked) ? false : true;
+		hl_above_anime_box->setEnabled(state);
+	});
+	connect(hl_above_anime_box, &QCheckBox::stateChanged, this,
+			[this](int state) { highlight_anime_if_available = (state == Qt::Unchecked) ? false : true; });
+	hl_anime_box->setCheckState(highlight_anime_if_available ? Qt::Checked : Qt::Unchecked);
+	hl_above_anime_box->setCheckState(highlighted_anime_above_others ? Qt::Checked : Qt::Unchecked);
+	hl_above_anime_box->setEnabled(hl_anime_box->checkState() != Qt::Unchecked);
+	hl_above_anime_box->setStyleSheet("margin-left: 10px;");
+
+	/* Appearance */
+	QVBoxLayout* appearance_layout = new QVBoxLayout;
+	appearance_layout->addWidget(lang_combo_box_label);
+	appearance_layout->addWidget(lang_combo_box);
+	appearance_layout->addWidget(hl_anime_box);
+	appearance_layout->addWidget(hl_above_anime_box);
+	appearance_group_box->setLayout(appearance_layout);
+
+	QGroupBox* progress_group_box = new QGroupBox(tr("Progress"), result);
+	progress_group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
+
+	QCheckBox* progress_display_aired_episodes =
+		new QCheckBox(tr("Display aired episodes (estimated)"), progress_group_box);
+	connect(progress_display_aired_episodes, &QCheckBox::stateChanged, this,
+			[this](int state) { display_aired_episodes = (state == Qt::Unchecked) ? false : true; });
+	progress_display_aired_episodes->setCheckState(display_aired_episodes ? Qt::Checked : Qt::Unchecked);
+
+	QCheckBox* progress_display_available_episodes =
+		new QCheckBox(tr("Display available episodes in library folders"), progress_group_box);
+	connect(progress_display_available_episodes, &QCheckBox::stateChanged, this,
+			[this](int state) { display_available_episodes = (state == Qt::Unchecked) ? false : true; });
+	progress_display_available_episodes->setCheckState(display_available_episodes ? Qt::Checked : Qt::Unchecked);
+
+	QVBoxLayout* progress_layout = new QVBoxLayout;
+	progress_layout->addWidget(progress_display_aired_episodes);
+	progress_layout->addWidget(progress_display_available_episodes);
+	progress_group_box->setLayout(progress_layout);
+
+	QVBoxLayout* full_layout = new QVBoxLayout;
+	full_layout->addWidget(actions_group_box);
+	full_layout->addWidget(appearance_group_box);
+	full_layout->addWidget(progress_group_box);
+	full_layout->setSpacing(10);
+	full_layout->addStretch();
+	result->setLayout(full_layout);
+	return result;
+}
+
+void SettingsPageApplication::SaveInfo() {
+	session.config.anime_list.language = language;
+	session.config.anime_list.highlighted_anime_above_others = highlighted_anime_above_others;
+	session.config.anime_list.highlight_anime_if_available = highlight_anime_if_available;
+	session.config.anime_list.display_aired_episodes = display_aired_episodes;
+	session.config.anime_list.display_available_episodes = display_available_episodes;
+}
+
+SettingsPageApplication::SettingsPageApplication(QWidget* parent) : SettingsPage(parent, tr("Application")) {
+	language = session.config.anime_list.language;
+	highlighted_anime_above_others = session.config.anime_list.highlighted_anime_above_others;
+	highlight_anime_if_available = session.config.anime_list.highlight_anime_if_available;
+	display_aired_episodes = session.config.anime_list.display_aired_episodes;
+	display_available_episodes = session.config.anime_list.display_available_episodes;
+	AddTab(CreateAnimeListWidget(), tr("Anime list"));
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/dialog/settings/services.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,94 @@
+#include "core/anime.h"
+#include "core/session.h"
+#include "gui/dialog/settings.h"
+#include "services/anilist.h"
+#include <QComboBox>
+#include <QGroupBox>
+#include <QPushButton>
+#include <QSizePolicy>
+
+QWidget* SettingsPageServices::CreateMainPage() {
+	QWidget* result = new QWidget(this);
+	result->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
+
+	QGroupBox* sync_group_box = new QGroupBox(tr("Synchronization"), result);
+	sync_group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
+
+	QLabel* sync_combo_box_label = new QLabel(tr("Active service and metadata provider:"), sync_group_box);
+
+	QComboBox* sync_combo_box = new QComboBox(sync_group_box);
+	sync_combo_box->addItem(tr("AniList"));
+	connect(sync_combo_box, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
+			[this](int index) { service = static_cast<Anime::Services>(index + 1); });
+	sync_combo_box->setCurrentIndex(static_cast<int>(service) - 1);
+
+	QLabel* sync_note_label =
+		new QLabel(tr("Note: Weeaboo is unable to synchronize multiple services at the same time."), sync_group_box);
+
+	QVBoxLayout* sync_layout = new QVBoxLayout;
+	sync_layout->addWidget(sync_combo_box_label);
+	sync_layout->addWidget(sync_combo_box);
+	sync_layout->addWidget(sync_note_label);
+	sync_group_box->setLayout(sync_layout);
+
+	QVBoxLayout* full_layout = new QVBoxLayout;
+	full_layout->addWidget(sync_group_box);
+	full_layout->setSpacing(10);
+	full_layout->addStretch();
+	result->setLayout(full_layout);
+	return result;
+}
+
+QWidget* SettingsPageServices::CreateAniListPage() {
+	QWidget* result = new QWidget(this);
+	result->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
+
+	QGroupBox* group_box = new QGroupBox(tr("Account"), result);
+	group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
+
+	QLabel* username_entry_label = new QLabel(tr("Username: (not your email address)"), group_box);
+
+	QWidget* auth_widget = new QWidget(group_box);
+	QLineEdit* username_entry = new QLineEdit(username, auth_widget);
+	connect(username_entry, &QLineEdit::editingFinished, this,
+			[this, username_entry] { username = username_entry->text(); });
+
+	QPushButton* auth_button = new QPushButton(auth_widget);
+	connect(auth_button, &QPushButton::clicked, this, [] { AniList::Authorize(); });
+	auth_button->setText(session.config.anilist.auth_token.empty() ? tr("Authorize...") : tr("Re-authorize..."));
+
+	QHBoxLayout* auth_layout = new QHBoxLayout;
+	auth_layout->addWidget(username_entry);
+	auth_layout->addWidget(auth_button);
+	auth_widget->setLayout(auth_layout);
+
+	QLabel* note_label = new QLabel(tr("<a href=\"http://anilist.co/\">Create a new AniList account</a>"), group_box);
+	note_label->setTextFormat(Qt::RichText);
+	note_label->setTextInteractionFlags(Qt::TextBrowserInteraction);
+	note_label->setOpenExternalLinks(true);
+
+	QVBoxLayout* layout = new QVBoxLayout;
+	layout->addWidget(username_entry_label);
+	layout->addWidget(auth_widget);
+	layout->addWidget(note_label);
+	group_box->setLayout(layout);
+
+	QVBoxLayout* full_layout = new QVBoxLayout;
+	full_layout->addWidget(group_box);
+	full_layout->setSpacing(10);
+	full_layout->addStretch();
+	result->setLayout(full_layout);
+	return result;
+}
+
+void SettingsPageServices::SaveInfo() {
+	session.config.anilist.username = username.toStdString();
+	session.config.service = service;
+}
+
+SettingsPageServices::SettingsPageServices(QWidget* parent) : SettingsPage(parent, tr("Services")) {
+	username = QString::fromUtf8(session.config.anilist.username.c_str());
+	service = session.config.service;
+	AddTab(CreateMainPage(), tr("Main"));
+	AddTab(CreateAniListPage(), tr("AniList"));
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/pages/anime_list.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,468 @@
+/**
+ * anime_list.cpp: defines the anime list page
+ * and widgets.
+ *
+ * much of this file is based around
+ * Qt's original QTabWidget implementation, because
+ * I needed a somewhat native way to create a tabbed
+ * widget with only one subwidget that worked exactly
+ * like a native tabbed widget.
+ **/
+#include "gui/pages/anime_list.h"
+#include "core/anime.h"
+#include "core/anime_db.h"
+#include "core/session.h"
+#include "core/time.h"
+#include "gui/dialog/information.h"
+#include "gui/translate/anime.h"
+#include "services/anilist.h"
+#include <QHBoxLayout>
+#include <QHeaderView>
+#include <QMenu>
+#include <QProgressBar>
+#include <QShortcut>
+#include <QStylePainter>
+#include <QStyledItemDelegate>
+#include <cmath>
+
+#if 0
+AnimeListWidgetDelegate::AnimeListWidgetDelegate(QObject* parent) : QStyledItemDelegate(parent) {
+}
+
+QWidget* AnimeListWidgetDelegate::createEditor(QWidget*, const QStyleOptionViewItem&, const QModelIndex&) const {
+	// no edit 4 u
+	return nullptr;
+}
+
+void AnimeListWidgetDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option,
+									const QModelIndex& index) const {
+	switch (index.column()) {
+/*
+		case AnimeListWidgetModel::AL_PROGRESS: {
+			const int progress = static_cast<int>(index.data(Qt::UserRole).toReal());
+			const int episodes =
+				static_cast<int>(index.siblingAtColumn(AnimeListWidgetModel::AL_EPISODES).data(Qt::UserRole).toReal());
+
+			int text_width = 59;
+			QRectF text_rect(option.rect.x() + text_width, option.rect.y(), text_width, option.decorationSize.height());
+			painter->save();
+			painter->drawText(text_rect, "/", QTextOption(Qt::AlignCenter | Qt::AlignVCenter));
+			// drawText(const QRectF &rectangle, const QString &text, const QTextOption &option = QTextOption())
+			painter->drawText(QRectF(text_rect.x(), text_rect.y(), text_width / 2 - 2, text_rect.height()),
+							  QString::number(progress), QTextOption(Qt::AlignRight | Qt::AlignVCenter));
+			painter->drawText(
+				QRectF(text_rect.x() + text_width / 2 + 2, text_rect.y(), text_width / 2 - 2, text_rect.height()),
+				QString::number(episodes), QTextOption(Qt::AlignLeft | Qt::AlignVCenter));
+			painter->restore();
+			QStyledItemDelegate::paint(painter, option, index);
+			break;
+		}
+*/
+		default: QStyledItemDelegate::paint(painter, option, index); break;
+	}
+}
+
+AnimeListWidgetSortFilter::AnimeListWidgetSortFilter(QObject* parent) : QSortFilterProxyModel(parent) {
+}
+
+bool AnimeListWidgetSortFilter::lessThan(const QModelIndex& l, const QModelIndex& r) const {
+	QVariant left = sourceModel()->data(l, sortRole());
+	QVariant right = sourceModel()->data(r, sortRole());
+
+	switch (left.userType()) {
+		case QMetaType::Int:
+		case QMetaType::UInt:
+		case QMetaType::LongLong:
+		case QMetaType::ULongLong: return left.toInt() < right.toInt();
+		case QMetaType::QDate: return left.toDate() < right.toDate();
+		case QMetaType::QString:
+		default: return QString::compare(left.toString(), right.toString(), Qt::CaseInsensitive) < 0;
+	}
+}
+
+AnimeListWidgetModel::AnimeListWidgetModel(QWidget* parent) : QAbstractListModel(parent) {
+	return;
+}
+
+int AnimeListWidgetModel::rowCount(const QModelIndex& parent) const {
+	int count = 0;
+	for (const auto& [id, anime] : Anime::db.items) {
+		if (anime.IsInUserList())
+			count++;
+	}
+	return count;
+	(void)(parent);
+}
+
+int AnimeListWidgetModel::columnCount(const QModelIndex& parent) const {
+	return NB_COLUMNS;
+	(void)(parent);
+}
+
+QVariant AnimeListWidgetModel::headerData(const int section, const Qt::Orientation orientation, const int role) const {
+	if (role == Qt::DisplayRole) {
+		switch (section) {
+			case AL_TITLE: return tr("Anime title");
+			case AL_PROGRESS: return tr("Progress");
+			case AL_EPISODES: return tr("Episodes");
+			case AL_TYPE: return tr("Type");
+			case AL_SCORE: return tr("Score");
+			case AL_SEASON: return tr("Season");
+			case AL_STARTED: return tr("Date started");
+			case AL_COMPLETED: return tr("Date completed");
+			case AL_NOTES: return tr("Notes");
+			case AL_AVG_SCORE: return tr("Average score");
+			case AL_UPDATED: return tr("Last updated");
+			default: return {};
+		}
+	} else if (role == Qt::TextAlignmentRole) {
+		switch (section) {
+			case AL_TITLE:
+			case AL_NOTES: return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
+			case AL_PROGRESS:
+			case AL_EPISODES:
+			case AL_TYPE:
+			case AL_SCORE:
+			case AL_AVG_SCORE: return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
+			case AL_SEASON:
+			case AL_STARTED:
+			case AL_COMPLETED:
+			case AL_UPDATED: return QVariant(Qt::AlignRight | Qt::AlignVCenter);
+			default: return QAbstractListModel::headerData(section, orientation, role);
+		}
+	}
+	return QAbstractListModel::headerData(section, orientation, role);
+}
+
+QVariant AnimeListWidgetModel::data(const QModelIndex& index, int role) const {
+	if (!index.isValid())
+		return QVariant();
+	switch (role) {
+		case Qt::DisplayRole:
+			switch (index.column()) {
+				case AL_TITLE: return QString::fromUtf8(list[index.row()].GetUserPreferredTitle().c_str());
+				case AL_PROGRESS:
+					return QString::number(list[index.row()].progress) + "/" + QString::number(list[index.row()].episodes);
+				case AL_EPISODES: return list[index.row()].episodes;
+				case AL_SCORE: return list[index.row()].score;
+				case AL_TYPE: return QString::fromStdString(Translate::TranslateSeriesFormat(list[index.row()].type));
+				case AL_SEASON:
+					return QString::fromStdString(Translate::TranslateSeriesSeason(list[index.row()].season)) + " " +
+						   QString::number(list[index.row()].air_date.GetYear());
+				case AL_AVG_SCORE: return QString::number(list[index.row()].audience_score) + "%";
+				case AL_STARTED: return list[index.row()].started.GetAsQDate();
+				case AL_COMPLETED: return list[index.row()].completed.GetAsQDate();
+				case AL_UPDATED: {
+					if (list[index.row()].updated == 0)
+						return QString("-");
+					Time::Duration duration(Time::GetSystemTime() - list[index.row()].updated);
+					return QString::fromUtf8(duration.AsRelativeString().c_str());
+				}
+				case AL_NOTES: return QString::fromUtf8(list[index.row()].notes.c_str());
+				default: return "";
+			}
+			break;
+		case Qt::UserRole:
+			switch (index.column()) {
+				case AL_ID: return 
+				case AL_PROGRESS: return list[index.row()].progress;
+				case AL_TYPE: return list[index.row()].type;
+				case AL_SEASON: return list[index.row()].air_date.GetAsQDate();
+				case AL_AVG_SCORE: return list[index.row()].audience_score;
+				case AL_UPDATED: return list[index.row()].updated;
+				default: return data(index, Qt::DisplayRole);
+			}
+			break;
+		case Qt::TextAlignmentRole:
+			switch (index.column()) {
+				case AL_TITLE:
+				case AL_NOTES: return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
+				case AL_PROGRESS:
+				case AL_EPISODES:
+				case AL_TYPE:
+				case AL_SCORE:
+				case AL_AVG_SCORE: return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
+				case AL_SEASON:
+				case AL_STARTED:
+				case AL_COMPLETED:
+				case AL_UPDATED: return QVariant(Qt::AlignRight | Qt::AlignVCenter);
+				default: break;
+			}
+			break;
+	}
+	return QVariant();
+}
+
+void AnimeListWidgetModel::UpdateAnime(int id) {
+	/* meh... it might be better to just redraw the entire list */
+	int i = 0;
+	for (const auto& [a_id, anime] : Anime:db.items) {
+		if (anime.IsInUserList() && a_id == id && anime.GetUserStatus() == Anime::ListStatus::WATCHING) {
+			emit dataChanged(index(i), index(i));
+		}
+		i++;
+	}
+}
+#endif
+
+int AnimeListWidget::VisibleColumnsCount() const {
+	int count = 0;
+
+	for (int i = 0, end = tree_view->header()->count(); i < end; i++) {
+		if (!tree_view->isColumnHidden(i))
+			count++;
+	}
+
+	return count;
+}
+
+void AnimeListWidget::SetColumnDefaults() {
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_SEASON, false);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_TYPE, false);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_UPDATED, false);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_PROGRESS, false);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_SCORE, false);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_TITLE, false);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_EPISODES, true);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_AVG_SCORE, true);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_STARTED, true);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_COMPLETED, true);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_UPDATED, true);
+	tree_view->setColumnHidden(AnimeListWidgetModel::AL_NOTES, true);
+}
+
+void AnimeListWidget::DisplayColumnHeaderMenu() {
+	QMenu* menu = new QMenu(this);
+	menu->setAttribute(Qt::WA_DeleteOnClose);
+	menu->setTitle(tr("Column visibility"));
+	menu->setToolTipsVisible(true);
+
+	for (int i = 0; i < AnimeListWidgetModel::NB_COLUMNS; i++) {
+		if (i == AnimeListWidgetModel::AL_TITLE)
+			continue;
+		const auto column_name =
+			sort_models[tab_bar->currentIndex()]->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();
+		QAction* action = menu->addAction(column_name, this, [this, i](const bool checked) {
+			if (!checked && (VisibleColumnsCount() <= 1))
+				return;
+
+			tree_view->setColumnHidden(i, !checked);
+
+			if (checked && (tree_view->columnWidth(i) <= 5))
+				tree_view->resizeColumnToContents(i);
+
+			// SaveSettings();
+		});
+		action->setCheckable(true);
+		action->setChecked(!tree_view->isColumnHidden(i));
+	}
+
+	menu->addSeparator();
+	QAction* resetAction = menu->addAction(tr("Reset to defaults"), this, [this]() {
+		for (int i = 0, count = tree_view->header()->count(); i < count; ++i) {
+			SetColumnDefaults();
+		}
+		// SaveSettings();
+	});
+	menu->popup(QCursor::pos());
+	(void)(resetAction);
+}
+
+void AnimeListWidget::DisplayListMenu() {
+	QMenu* menu = new QMenu(this);
+	menu->setAttribute(Qt::WA_DeleteOnClose);
+	menu->setTitle(tr("Column visibility"));
+	menu->setToolTipsVisible(true);
+
+	const QItemSelection selection = sort_models[tab_bar->currentIndex()]->mapSelectionToSource(tree_view->selectionModel()->selection());
+	if (!selection.indexes().first().isValid()) {
+		return;
+	}
+
+/*
+	QAction* action = menu->addAction("Information", [this, selection] {
+		const QModelIndex index = ((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())
+									  ->index(selection.indexes().first().row());
+		Anime::Anime* anime =
+			((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())->GetAnimeFromIndex(index);
+		if (!anime) {
+			return;
+		}
+
+		InformationDialog* dialog = new InformationDialog(
+			*anime,
+			[this, anime] {
+				((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())->UpdateAnime(*anime);
+			},
+			this);
+
+		dialog->show();
+		dialog->raise();
+		dialog->activateWindow();
+	});
+*/
+	menu->popup(QCursor::pos());
+}
+
+void AnimeListWidget::ItemDoubleClicked() {
+	/* throw out any other garbage */
+	const QItemSelection selection =
+		sort_models[tab_bar->currentIndex()]->mapSelectionToSource(tree_view->selectionModel()->selection());
+	if (!selection.indexes().first().isValid()) {
+		return;
+	}
+
+/*
+	const QModelIndex index = ((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())
+								  ->index(selection.indexes().first().row());
+	Anime::Anime* anime =
+		((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())->GetAnimeFromIndex(index);
+	if (!anime) {
+		return;
+	}
+
+	InformationDialog* dialog = new InformationDialog(*anime, [this, anime] {
+		((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())->UpdateAnime(*anime);
+	}, this);
+
+	dialog->show();
+	dialog->raise();
+	dialog->activateWindow();
+*/
+}
+
+void AnimeListWidget::paintEvent(QPaintEvent*) {
+	QStylePainter p(this);
+
+	QStyleOptionTabWidgetFrame opt;
+	InitStyle(&opt);
+	opt.rect = panelRect;
+	p.drawPrimitive(QStyle::PE_FrameTabWidget, opt);
+}
+
+void AnimeListWidget::resizeEvent(QResizeEvent* e) {
+	QWidget::resizeEvent(e);
+	SetupLayout();
+}
+
+void AnimeListWidget::showEvent(QShowEvent*) {
+	SetupLayout();
+}
+
+void AnimeListWidget::InitBasicStyle(QStyleOptionTabWidgetFrame* option) const {
+	if (!option)
+		return;
+
+	option->initFrom(this);
+	option->lineWidth = style()->pixelMetric(QStyle::PM_DefaultFrameWidth, nullptr, this);
+	option->shape = QTabBar::RoundedNorth;
+	option->tabBarRect = tab_bar->geometry();
+}
+
+void AnimeListWidget::InitStyle(QStyleOptionTabWidgetFrame* option) const {
+	if (!option)
+		return;
+
+	InitBasicStyle(option);
+
+	// int exth = style()->pixelMetric(QStyle::PM_TabBarBaseHeight, nullptr, this);
+	QSize t(0, tree_view->frameWidth());
+	if (tab_bar->isVisibleTo(this)) {
+		t = tab_bar->sizeHint();
+		t.setWidth(width());
+	}
+	option->tabBarSize = t;
+
+	QRect selected_tab_rect = tab_bar->tabRect(tab_bar->currentIndex());
+	selected_tab_rect.moveTopLeft(selected_tab_rect.topLeft() + option->tabBarRect.topLeft());
+	option->selectedTabRect = selected_tab_rect;
+
+	option->lineWidth = style()->pixelMetric(QStyle::PM_DefaultFrameWidth, nullptr, this);
+}
+
+void AnimeListWidget::SetupLayout() {
+	QStyleOptionTabWidgetFrame option;
+	InitStyle(&option);
+
+	QRect tabRect = style()->subElementRect(QStyle::SE_TabWidgetTabBar, &option, this);
+	tabRect.setLeft(tabRect.left() + 1);
+	panelRect = style()->subElementRect(QStyle::SE_TabWidgetTabPane, &option, this);
+	QRect contentsRect = style()->subElementRect(QStyle::SE_TabWidgetTabContents, &option, this);
+
+	tab_bar->setGeometry(tabRect);
+	tree_view->parentWidget()->setGeometry(contentsRect);
+}
+
+AnimeListWidget::AnimeListWidget(QWidget* parent) : QWidget(parent) {
+	/* Tab bar */
+	tab_bar = new QTabBar(this);
+	tab_bar->setExpanding(false);
+	tab_bar->setDrawBase(false);
+	for (int i = 0; i < ARRAYSIZE(sort_models); i++) {
+		tab_bar->addTab(QString::fromStdString(Translate::TranslateListStatus(Anime::ListStatuses[i])));
+
+		/* Tree view... */
+		QWidget* tree_widget = new QWidget(this);
+		tree_view = new QTreeView(tree_widget);
+		tree_view->setItemDelegate(new AnimeListWidgetDelegate(tree_view));
+		tree_view->setUniformRowHeights(true);
+		tree_view->setAllColumnsShowFocus(false);
+		tree_view->setAlternatingRowColors(true);
+		tree_view->setSortingEnabled(true);
+		tree_view->setSelectionMode(QAbstractItemView::ExtendedSelection);
+		tree_view->setItemsExpandable(false);
+		tree_view->setRootIsDecorated(false);
+		tree_view->setContextMenuPolicy(Qt::CustomContextMenu);
+		tree_view->setFrameShape(QFrame::NoFrame);
+
+		QHBoxLayout* layout = new QHBoxLayout;
+		layout->addWidget(tree_view);
+		layout->setMargin(0);
+		tree_widget->setLayout(layout);
+
+		/* Double click stuff */
+		connect(tree_view, &QAbstractItemView::doubleClicked, this, &AnimeListWidget::ItemDoubleClicked);
+		connect(tree_view, &QWidget::customContextMenuRequested, this, &AnimeListWidget::DisplayListMenu);
+
+		/* Enter & return keys */
+		connect(new QShortcut(Qt::Key_Return, tree_view, nullptr, nullptr, Qt::WidgetShortcut), &QShortcut::activated,
+				this, &AnimeListWidget::ItemDoubleClicked);
+
+		connect(new QShortcut(Qt::Key_Enter, tree_view, nullptr, nullptr, Qt::WidgetShortcut), &QShortcut::activated,
+				this, &AnimeListWidget::ItemDoubleClicked);
+
+		tree_view->header()->setStretchLastSection(false);
+		tree_view->header()->setContextMenuPolicy(Qt::CustomContextMenu);
+		connect(tree_view->header(), &QWidget::customContextMenuRequested, this,
+				&AnimeListWidget::DisplayColumnHeaderMenu);
+
+		connect(tab_bar, &QTabBar::currentChanged, this, [this](int index) {
+			if (sort_models[index])
+				tree_view->setModel(sort_models[index]);
+		});
+
+		setFocusPolicy(Qt::TabFocus);
+		setFocusProxy(tab_bar);
+	}
+
+	void AnimeListWidget::UpdateAnimeList() {
+		for (unsigned int i = 0; i < ARRAYSIZE(sort_models); i++) {
+			sort_models[i] = new AnimeListWidgetSortFilter(tree_view);
+			sort_models[i]->setSourceModel(new AnimeListWidgetModel(this, &anime_lists[i]));
+			sort_models[i]->setSortRole(Qt::UserRole);
+			sort_models[i]->setSortCaseSensitivity(Qt::CaseInsensitive);
+		}
+		if (ARRAYSIZE(sort_models) > 0)
+			tree_view->setModel(sort_models[0]);
+		SetColumnDefaults();
+		SetupLayout();
+	}
+
+	void AnimeListWidget::Reset() {
+		while (tab_bar->count())
+			tab_bar->removeTab(0);
+		for (int i = 0; i < ARRAYSIZE(sort_models); i++)
+			delete sort_models[i];
+	}
+
+#include "gui/pages/moc_anime_list.cpp"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/pages/now_playing.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,6 @@
+#include "gui/pages/now_playing.h"
+
+NowPlayingWidget::NowPlayingWidget(QWidget* parent) : QWidget(parent) {
+}
+
+#include "gui/pages/moc_now_playing.cpp"
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/pages/statistics.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,111 @@
+#include "gui/pages/statistics.h"
+#include "gui/pages/anime_list.h"
+#include "gui/ui_utils.h"
+#include "session.h"
+#include <QString>
+#include <QTextDocument>
+#include <QTextStream>
+#include <QTimer>
+#include <QVBoxLayout>
+#include <QWidget>
+#include <sstream>
+
+StatisticsWidget::StatisticsWidget(QWidget* parent) : QFrame(parent) {
+	setLayout(new QVBoxLayout);
+
+	setFrameShape(QFrame::Panel);
+	setFrameShadow(QFrame::Sunken);
+
+	UiUtils::LabelledTextParagraph* anime_list_paragraph = new UiUtils::LabelledTextParagraph(
+		"Anime list",
+		"Anime count:\nEpisode count:\nTime spent watching:\nTime to complete:\nAverage score:\nScore deviation:", "",
+		this);
+	anime_list_data = anime_list_paragraph->GetParagraph();
+
+	UiUtils::LabelledTextParagraph* application_paragraph =
+		new UiUtils::LabelledTextParagraph("Weeaboo", "Uptime:", "", this);
+	application_data = application_paragraph->GetParagraph();
+
+	layout()->addWidget(anime_list_paragraph);
+	layout()->addWidget(application_paragraph);
+	((QBoxLayout*)layout())->addStretch();
+
+	QPalette pal = QPalette();
+	pal.setColor(QPalette::Window, Qt::white);
+	setAutoFillBackground(true);
+	setPalette(pal);
+
+	QTimer* timer = new QTimer(this);
+	connect(timer, &QTimer::timeout, this, [this] {
+		if (isVisible())
+			UpdateStatistics();
+	});
+	timer->start(1000); // update statistics every second
+}
+
+void StatisticsWidget::showEvent(QShowEvent*) {
+	UpdateStatistics();
+}
+
+/* me abusing macros :) */
+#define ADD_TIME_SEGMENT(r, x, s, p)                                                                                   \
+	if (x > 0)                                                                                                         \
+	r << x << ((x == 1) ? s : p)
+std::string StatisticsWidget::MinutesToDateString(int minutes) {
+	/* ew */
+	int years = (minutes * (1 / 525949.2F));
+	int months = (minutes * (1 / 43829.1F)) - (years * 12);
+	int days = (minutes * (1 / 1440.0F)) - (years * 365.2425F) - (months * 30.436875F);
+	int hours = (minutes * (1 / 60.0F)) - (years * 8765.82F) - (months * 730.485F) - (days * 24);
+	int rest_minutes = (minutes) - (years * 525949.2F) - (months * 43829.1F) - (days * 1440) - (hours * 60);
+	std::ostringstream return_stream;
+	ADD_TIME_SEGMENT(return_stream, years, " year ", " years ");
+	ADD_TIME_SEGMENT(return_stream, months, " month ", " months ");
+	ADD_TIME_SEGMENT(return_stream, days, " day ", " days ");
+	ADD_TIME_SEGMENT(return_stream, hours, " hour ", " hours ");
+	if (rest_minutes > 0 || return_stream.str().size() == 0)
+		return_stream << rest_minutes << ((rest_minutes == 1) ? " minute" : " minutes");
+	return return_stream.str();
+}
+
+std::string StatisticsWidget::SecondsToDateString(int seconds) {
+	/* this is all fairly unnecessary, but works:tm: */
+	std::chrono::duration<int, std::ratio<1>> int_total_mins(seconds);
+	auto int_years = std::chrono::duration_cast<std::chrono::years>(int_total_mins);
+	auto int_months = std::chrono::duration_cast<std::chrono::months>(int_total_mins - int_years);
+	auto int_days = std::chrono::duration_cast<std::chrono::days>(int_total_mins - int_years - int_months);
+	auto int_hours = std::chrono::duration_cast<std::chrono::hours>(int_total_mins - int_years - int_months - int_days);
+	auto int_minutes = std::chrono::duration_cast<std::chrono::minutes>(int_total_mins - int_years - int_months -
+																		int_days - int_hours);
+	auto int_seconds = std::chrono::duration_cast<std::chrono::seconds>(int_total_mins - int_years - int_months -
+																		int_days - int_hours - int_minutes);
+	std::ostringstream return_stream;
+	ADD_TIME_SEGMENT(return_stream, int_years, " year ", " years ");
+	ADD_TIME_SEGMENT(return_stream, int_months, " month ", " months ");
+	ADD_TIME_SEGMENT(return_stream, int_days, " day ", " days ");
+	ADD_TIME_SEGMENT(return_stream, int_hours, " hour ", " hours ");
+	ADD_TIME_SEGMENT(return_stream, int_minutes, " minute ", " minutes ");
+	if (int_seconds.count() > 0 || return_stream.str().size() == 0)
+		return_stream << int_seconds.count() << ((int_seconds.count() == 1) ? " second" : " seconds");
+	return return_stream.str();
+}
+#undef ADD_TIME_SEGMENT
+
+void StatisticsWidget::UpdateStatistics() {
+	/* Anime list */
+	QString string = "";
+	QTextStream ts(&string);
+	ts << Anime::db->GetTotalAnimeAmount() << '\n';
+	ts << Anime::db->GetTotalEpisodeAmount() << '\n';
+	ts << MinutesToDateString(Anime::db->GetTotalWatchedAmount()).c_str() << '\n';
+	ts << MinutesToDateString(Anime::db->GetTotalPlannedAmount()).c_str() << '\n';
+	ts << Anime::db->GetAverageScore() << '\n';
+	ts << Anime::db->GetScoreDeviation();
+	UiUtils::SetPlainTextEditData(anime_list_data, string);
+
+	/* Application */
+	// UiUtils::SetPlainTextEditData(application_data, QString::number(session.uptime() / 1000));
+	UiUtils::SetPlainTextEditData(application_data, QString(SecondsToDateString(session.uptime() / 1000).c_str()));
+}
+
+#include "gui/pages/moc_statistics.h"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/sidebar.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,80 @@
+#include "gui/sidebar.h"
+#include <QFrame>
+#include <QListWidget>
+#include <QListWidgetItem>
+#include <QMessageBox>
+#include <QMouseEvent>
+
+SideBar::SideBar(QWidget* parent) : QListWidget(parent) {
+	setObjectName("sidebar");
+	setFrameShape(QFrame::NoFrame);
+	setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+	setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+	setSelectionMode(QAbstractItemView::SingleSelection);
+	setSelectionBehavior(QAbstractItemView::SelectItems);
+	setMouseTracking(true);
+	viewport()->setAutoFillBackground(false);
+	setStyleSheet("font-size: 12px");
+	connect(this, &QListWidget::currentRowChanged, this,
+			[this](int index) { emit CurrentItemChanged(RemoveSeparatorsFromIndex(index)); });
+}
+
+QListWidgetItem* SideBar::AddItem(QString name, QIcon icon) {
+	QListWidgetItem* item = new QListWidgetItem(this);
+	item->setText(name);
+	if (!icon.isNull())
+		item->setIcon(icon);
+	return item;
+}
+
+QIcon SideBar::CreateIcon(const char* file) {
+	QPixmap pixmap(file, "PNG");
+	QIcon result;
+	result.addPixmap(pixmap, QIcon::Normal);
+	result.addPixmap(pixmap, QIcon::Selected);
+	return result;
+}
+
+QListWidgetItem* SideBar::AddSeparator() {
+	QListWidgetItem* item = new QListWidgetItem(this);
+	setStyleSheet("QListWidget::item:disabled {background: transparent;}");
+	QFrame* line = new QFrame(this);
+	line->setFrameShape(QFrame::HLine);
+	line->setFrameShadow(QFrame::Sunken);
+	line->setMouseTracking(true);
+	line->setEnabled(false);
+	setItemWidget(item, line);
+	item->setFlags(Qt::NoItemFlags);
+	return item;
+}
+
+int SideBar::RemoveSeparatorsFromIndex(int index) {
+	int i, j;
+	for (i = 0, j = 0; i < index; i++) {
+		if (!IndexIsSeparator(indexFromItem(item(i))))
+			j++;
+	}
+	return j;
+}
+
+bool SideBar::IndexIsSeparator(QModelIndex index) const {
+	return !(index.isValid() && index.flags() & Qt::ItemIsEnabled);
+}
+
+QItemSelectionModel::SelectionFlags SideBar::selectionCommand(const QModelIndex& index, const QEvent* event) const {
+	if (IndexIsSeparator(index))
+		return QItemSelectionModel::NoUpdate;
+	return QItemSelectionModel::ClearAndSelect;
+	/* silence unused parameter warnings */
+	(void)event;
+}
+
+void SideBar::mouseMoveEvent(QMouseEvent* event) {
+	if (!IndexIsSeparator(indexAt(event->pos())))
+		setCursor(Qt::PointingHandCursor);
+	else
+		unsetCursor();
+	QListView::mouseMoveEvent(event);
+}
+
+#include "gui/moc_sidebar.cpp"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/translate/anime.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,54 @@
+#include "core/anime.h"
+
+namespace Translate {
+
+std::string TranslateListStatus(const Anime::ListStatus status) {
+	switch (status) {
+		case Anime::ListStatus::NOT_IN_LIST: return "Not in list";
+		case Anime::ListStatus::CURRENT: return "Currently watching";
+		case Anime::ListStatus::PLANNING: return "Plan to watch";
+		case Anime::ListStatus::COMPLETED: return "Completed";
+		case Anime::ListStatus::DROPPED: return "Dropped";
+		case Anime::ListStatus::PAUSED: return "On hold";
+		default: return "";
+	}
+}
+
+std::string TranslateSeriesFormat(const Anime::SeriesFormat format) {
+	switch (format) {
+		case Anime::SeriesFormat::UNKNOWN: return "Unknown";
+		case Anime::SeriesFormat::TV: return "TV";
+		case Anime::SeriesFormat::TV_SHORT: return "TV short";
+		case Anime::SeriesFormat::OVA: return "OVA";
+		case Anime::SeriesFormat::MOVIE: return "Movie";
+		case Anime::SeriesFormat::SPECIAL: return "Special";
+		case Anime::SeriesFormat::ONA: return "ONA";
+		case Anime::SeriesFormat::MUSIC: return "Music";
+		default: return "";
+	}
+}
+
+std::string TranslateSeriesSeason(const Anime::SeriesSeason season) {
+	switch (season) {
+		case Anime::SeriesSeason::UNKNOWN: return "Unknown";
+		case Anime::SeriesSeason::WINTER: return "Winter";
+		case Anime::SeriesSeason::SUMMER: return "Summer";
+		case Anime::SeriesSeason::FALL: return "Fall";
+		case Anime::SeriesSeason::SPRING: return "Spring";
+		default: return "";
+	}
+}
+
+std::string TranslateSeriesStatus(const Anime::SeriesStatus status) {
+	switch (status) {
+		case Anime::SeriesStatus::UNKNOWN: return "Unknown";
+		case Anime::SeriesStatus::RELEASING: return "Currently airing";
+		case Anime::SeriesStatus::FINISHED: return "Finished airing";
+		case Anime::SeriesStatus::NOT_YET_RELEASED: return "Not yet aired";
+		case Anime::SeriesStatus::CANCELLED: return "Cancelled";
+		case Anime::SeriesStatus::HIATUS: return "On hiatus";
+		default: return "";
+	}
+}
+
+} // namespace Translate
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/ui_utils.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,212 @@
+/**
+ * FIXME: most of these can actually be rerouted to *separate* files.
+ * Please do this! It makes everything cleaner :)
+ **/
+#include "gui/ui_utils.h"
+#include "core/session.h"
+#include <QFrame>
+#include <QLabel>
+#include <QPixmap>
+#include <QTextBlock>
+#include <QVBoxLayout>
+#ifdef MACOSX
+#include "sys/osx/dark_theme.h"
+#else
+#include "sys/win32/dark_theme.h"
+#endif
+
+namespace UiUtils {
+
+bool IsInDarkMode() {
+	if (session.config.theme != Themes::OS)
+		return (session.config.theme == Themes::DARK);
+#ifdef MACOSX
+	if (osx::DarkThemeAvailable()) {
+		if (osx::IsInDarkTheme()) {
+			return true;
+		} else {
+			return false;
+		}
+	}
+#elif defined(WIN32)
+	if (win32::DarkThemeAvailable()) {
+		if (win32::IsInDarkTheme()) {
+			return true;
+		} else {
+			return false;
+		}
+	}
+#endif
+	return (session.config.theme == Themes::DARK);
+}
+
+Header::Header(QString title, QWidget* parent) : QWidget(parent) {
+	setLayout(new QVBoxLayout);
+	setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
+
+	static_text_title = new QLabel(title, this);
+	static_text_title->setTextFormat(Qt::PlainText);
+	QFont font = static_text_title->font();
+	font.setWeight(QFont::Bold);
+	static_text_title->setFont(font);
+	static_text_title->setFixedHeight(16);
+
+	static_text_line = new QFrame(this);
+	static_text_line->setFrameShape(QFrame::HLine);
+	static_text_line->setFrameShadow(QFrame::Sunken);
+	static_text_line->setFixedHeight(2);
+
+	layout()->addWidget(static_text_title);
+	layout()->addWidget(static_text_line);
+	layout()->setSpacing(0);
+	layout()->setMargin(0);
+}
+
+void Header::SetTitle(QString title) {
+	static_text_title->setText(title);
+}
+
+TextParagraph::TextParagraph(QString title, QString data, QWidget* parent) : QWidget(parent) {
+	setLayout(new QVBoxLayout);
+
+	header = new Header(title, this);
+
+	QWidget* content = new QWidget(this);
+	content->setLayout(new QHBoxLayout);
+
+	paragraph = new Paragraph(data, this);
+	paragraph->setTextInteractionFlags(Qt::NoTextInteraction);
+	paragraph->setAttribute(Qt::WidgetAttribute::WA_TransparentForMouseEvents);
+	paragraph->setWordWrapMode(QTextOption::NoWrap);
+
+	content->layout()->addWidget(paragraph);
+	content->layout()->setSpacing(0);
+	content->layout()->setMargin(0);
+	content->setContentsMargins(12, 0, 0, 0);
+
+	layout()->addWidget(header);
+	layout()->addWidget(paragraph);
+	layout()->setSpacing(0);
+	layout()->setMargin(0);
+}
+
+Header* TextParagraph::GetHeader() {
+	return header;
+}
+
+Paragraph* TextParagraph::GetParagraph() {
+	return paragraph;
+}
+
+LabelledTextParagraph::LabelledTextParagraph(QString title, QString label, QString data, QWidget* parent)
+	: QWidget(parent) {
+	setLayout(new QVBoxLayout);
+
+	header = new Header(title, this);
+
+	// this is not accessible from the object because there's really
+	// no reason to make it accessible...
+	QWidget* content = new QWidget(this);
+	content->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
+
+	labels = new Paragraph(label, this);
+	labels->setTextInteractionFlags(Qt::NoTextInteraction);
+	labels->setAttribute(Qt::WidgetAttribute::WA_TransparentForMouseEvents);
+	labels->setWordWrapMode(QTextOption::NoWrap);
+	labels->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
+	labels->setFixedWidth(123);
+
+	paragraph = new Paragraph(data, this);
+	paragraph->setTextInteractionFlags(Qt::NoTextInteraction);
+	paragraph->setAttribute(Qt::WidgetAttribute::WA_TransparentForMouseEvents);
+	paragraph->setWordWrapMode(QTextOption::NoWrap);
+
+	QHBoxLayout* content_layout = new QHBoxLayout;
+	content_layout->addWidget(labels, 0, Qt::AlignTop);
+	content_layout->addWidget(paragraph, 0, Qt::AlignTop);
+	content_layout->setSpacing(0);
+	content_layout->setMargin(0);
+	content->setLayout(content_layout);
+
+	content->setContentsMargins(12, 0, 0, 0);
+
+	layout()->addWidget(header);
+	layout()->addWidget(content);
+	layout()->setSpacing(0);
+	layout()->setMargin(0);
+}
+
+Header* LabelledTextParagraph::GetHeader() {
+	return header;
+}
+
+Paragraph* LabelledTextParagraph::GetLabels() {
+	return labels;
+}
+
+Paragraph* LabelledTextParagraph::GetParagraph() {
+	return paragraph;
+}
+
+SelectableTextParagraph::SelectableTextParagraph(QString title, QString data, QWidget* parent) : QWidget(parent) {
+	setLayout(new QVBoxLayout);
+
+	header = new Header(title, this);
+
+	QWidget* content = new QWidget(this);
+	content->setLayout(new QHBoxLayout);
+
+	paragraph = new Paragraph(data, content);
+
+	content->layout()->addWidget(paragraph);
+	content->layout()->setSpacing(0);
+	content->layout()->setMargin(0);
+	content->setContentsMargins(12, 0, 0, 0);
+
+	layout()->addWidget(header);
+	layout()->addWidget(content);
+	layout()->setSpacing(0);
+	layout()->setMargin(0);
+}
+
+Header* SelectableTextParagraph::GetHeader() {
+	return header;
+}
+
+Paragraph* SelectableTextParagraph::GetParagraph() {
+	return paragraph;
+}
+
+void SetPlainTextEditData(QPlainTextEdit* text_edit, QString data) {
+	QTextDocument* document = new QTextDocument(text_edit);
+	document->setDocumentLayout(new QPlainTextDocumentLayout(document));
+	document->setPlainText(data);
+	text_edit->setDocument(document);
+}
+
+/* inherits QPlainTextEdit and gives a much more reasonable minimum size */
+Paragraph::Paragraph(QString text, QWidget* parent) : QPlainTextEdit(text, parent) {
+	setReadOnly(true);
+	setTextInteractionFlags(Qt::TextBrowserInteraction);
+	setFrameShape(QFrame::NoFrame);
+	setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+	setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+	setStyleSheet("background: transparent;");
+	setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+}
+
+/* highly based upon... some stackoverflow answer for PyQt */
+QSize Paragraph::minimumSizeHint() const {
+	QTextDocument* doc = document();
+	long h = (long)(blockBoundingGeometry(doc->findBlockByNumber(doc->blockCount() - 1)).bottom() +
+					(2 * doc->documentMargin()));
+	return QSize(QPlainTextEdit::sizeHint().width(), (long)h);
+}
+
+QSize Paragraph::sizeHint() const {
+	return minimumSizeHint();
+}
+
+} // namespace UiUtils
+
+#include "gui/moc_ui_utils.cpp"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/gui/window.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,193 @@
+#include "gui/window.h"
+#include "core/config.h"
+#include "core/session.h"
+#include "gui/dialog/settings.h"
+#include "gui/pages/anime_list.h"
+#include "gui/pages/now_playing.h"
+#include "gui/pages/statistics.h"
+#include "gui/sidebar.h"
+#include "gui/ui_utils.h"
+#include <QApplication>
+#include <QFile>
+#include <QMainWindow>
+#include <QMenuBar>
+#include <QPlainTextEdit>
+#include <QStackedWidget>
+#include <QTextStream>
+#if MACOSX
+#include "sys/osx/dark_theme.h"
+#elif WIN32
+#include "sys/win32/dark_theme.h"
+#endif
+
+/* note that this code was originally created for use in
+   wxWidgets, but I thought the API was a little meh, so
+   I switched to Qt. */
+
+MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
+	main_widget = new QWidget(parent);
+	/* Menu Bar */
+	QAction* action;
+	QMenuBar* menubar = new QMenuBar(parent);
+	QMenu* menu = menubar->addMenu("&File");
+	QMenu* submenu = menu->addMenu("&Library folders");
+	action = new QAction("&Add new folder...");
+	submenu->addAction(action);
+	action = new QAction("&Scan available episodes");
+	menu->addAction(action);
+
+	menu->addSeparator();
+
+	action = menu->addAction("Play &next episode");
+	action = menu->addAction("Play &random episode");
+	menu->addSeparator();
+	action = menu->addAction("E&xit", qApp, &QApplication::quit);
+
+	menu = menubar->addMenu("&Services");
+	action = new QAction("Synchronize &list");
+
+	menu->addSeparator();
+
+	submenu = menu->addMenu("&AniList");
+	action = submenu->addAction("Go to my &profile");
+	action = submenu->addAction("Go to my &stats");
+
+	submenu = menu->addMenu("&Kitsu");
+	action = submenu->addAction("Go to my &feed");
+	action = submenu->addAction("Go to my &library");
+	action = submenu->addAction("Go to my &profile");
+
+	submenu = menu->addMenu("&MyAnimeList");
+	action = submenu->addAction("Go to my p&anel");
+	action = submenu->addAction("Go to my &profile");
+	action = submenu->addAction("Go to my &history");
+
+	menu = menubar->addMenu("&Tools");
+	submenu = menu->addMenu("&Export anime list");
+	action = submenu->addAction("Export as &Markdown...");
+	action = submenu->addAction("Export as MyAnimeList &XML...");
+
+	menu->addSeparator();
+
+	action = menu->addAction("Enable anime &recognition");
+	action->setCheckable(true);
+	action = menu->addAction("Enable auto &sharing");
+	action->setCheckable(true);
+	action = menu->addAction("Enable &auto synchronization");
+	action->setCheckable(true);
+
+	menu->addSeparator();
+
+	action = menu->addAction("&Settings", [this] {
+		SettingsDialog dialog(this);
+		dialog.exec();
+	});
+
+	setMenuBar(menubar);
+
+	SideBar* sidebar = new SideBar(main_widget);
+	sidebar->AddItem("Now Playing", SideBar::CreateIcon(":/icons/16x16/film.png"));
+	sidebar->AddSeparator();
+	sidebar->AddItem("Anime List", SideBar::CreateIcon(":/icons/16x16/document-list.png"));
+	sidebar->AddItem("History", SideBar::CreateIcon(":/icons/16x16/clock-history-frame.png"));
+	sidebar->AddItem("Statistics", SideBar::CreateIcon(":/icons/16x16/chart.png"));
+	sidebar->AddSeparator();
+	sidebar->AddItem("Search", SideBar::CreateIcon(":/icons/16x16/magnifier.png"));
+	sidebar->AddItem("Seasons", SideBar::CreateIcon(":/icons/16x16/calendar.png"));
+	sidebar->AddItem("Torrents", SideBar::CreateIcon(":/icons/16x16/feed.png"));
+	sidebar->setFixedWidth(128);
+	sidebar->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
+
+	QStackedWidget* stack = new QStackedWidget(main_widget);
+	stack->addWidget(new NowPlayingWidget(parent));
+	stack->addWidget(new AnimeListWidget(parent));
+	stack->addWidget(new StatisticsWidget(parent));
+
+	connect(sidebar, &SideBar::CurrentItemChanged, stack, [stack](int index) {
+		switch (index) {
+			case 0:
+			case 1: stack->setCurrentIndex(index); break;
+			case 3: stack->setCurrentIndex(2); break;
+			default: break;
+		}
+	});
+	sidebar->setCurrentRow(2);
+
+	QHBoxLayout* layout = new QHBoxLayout(main_widget);
+	layout->addWidget(sidebar, 0, Qt::AlignLeft | Qt::AlignTop);
+	layout->addWidget(stack);
+	setCentralWidget(main_widget);
+
+	ThemeChanged();
+}
+
+void MainWindow::SetStyleSheet(enum Themes theme) {
+	switch (theme) {
+		case Themes::DARK: {
+			QFile f(":qdarkstyle/dark/darkstyle.qss");
+			if (!f.exists())
+				return; // fail
+			f.open(QFile::ReadOnly | QFile::Text);
+			QTextStream ts(&f);
+			setStyleSheet(ts.readAll());
+			break;
+		}
+		default: setStyleSheet(""); break;
+	}
+}
+
+void MainWindow::ThemeChanged() {
+	switch (session.config.theme) {
+		case Themes::LIGHT: {
+#if MACOSX
+			if (osx::DarkThemeAvailable())
+				osx::SetToLightTheme();
+			else
+#else
+			SetStyleSheet(Themes::LIGHT);
+#endif
+				break;
+		}
+		case Themes::DARK: {
+#if MACOSX
+			if (osx::DarkThemeAvailable())
+				osx::SetToDarkTheme();
+			else
+#else
+			SetStyleSheet(Themes::DARK);
+#endif
+				break;
+		}
+		case Themes::OS: {
+#if MACOSX
+			if (osx::DarkThemeAvailable())
+				osx::SetToAutoTheme();
+			else
+#elif defined(WIN32)
+			if (win32::DarkThemeAvailable()) {
+				if (win32::IsInDarkTheme()) {
+					SetStyleSheet(Themes::DARK);
+				} else {
+					SetStyleSheet(Themes::LIGHT);
+				}
+			} else
+#endif
+				/* Currently OS detection only supports Windows and macOS.
+				   Please don't be shy if you're willing to port it to other OSes
+				   (or desktop environments, or window managers) */
+				SetStyleSheet(Themes::LIGHT);
+			break;
+		}
+	}
+}
+
+void MainWindow::SetActivePage(QWidget* page) {
+	this->setCentralWidget(page);
+}
+
+void MainWindow::closeEvent(QCloseEvent* event) {
+	session.config.Save();
+	event->accept();
+}
+
+#include "gui/moc_window.cpp"
--- a/src/include/anilist.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-#ifndef __anilist_h
-#define __anilist_h
-#include <curl/curl.h>
-#include "anime.h"
-#include "json.h"
-class AniList {
-	public:
-		static int Authorize();
-		static int GetUserId(std::string name);
-		static int UpdateAnimeList(std::vector<AnimeList>* anime_lists, int id);
-
-	private:
-		static size_t CurlWriteCallback(void *contents, size_t size, size_t nmemb, void *userdata);
-		static std::string SendRequest(std::string data);
-		static enum AnimeWatchingStatus ConvertWatchingStatusToEnum(std::string status);
-		static enum AnimeAiringStatus ConvertAiringStatusToEnum(std::string status);
-		static enum AnimeFormat ConvertFormatToEnum(std::string format);
-		static enum AnimeSeason ConvertSeasonToEnum(std::string season);
-		static CURL* curl;
-		static CURLcode res;
-};
-#endif // __anilist_h
--- a/src/include/anime.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,116 +0,0 @@
-#ifndef __anime_h
-#define __anime_h
-#include <vector>
-#include <map>
-#include "date.h"
-#include "window.h"
-#include "progress.h"
-
-enum AnimeWatchingStatus {
-	CURRENT,
-	PLANNING,
-	COMPLETED,
-	DROPPED,
-	PAUSED,
-	REPEATING
-};
-
-enum AnimeAiringStatus {
-	FINISHED,
-	RELEASING,
-	NOT_YET_RELEASED,
-	CANCELLED,
-	HIATUS
-};
-
-enum AnimeFormat {
-	TV,
-	TV_SHORT,
-	MOVIE,
-	SPECIAL,
-	OVA,
-	ONA,
-	MUSIC,
-	MANGA,
-	NOVEL,
-	ONE_SHOT
-};
-
-enum AnimeSeason {
-	UNKNOWN,
-	WINTER,
-	SPRING,
-	SUMMER,
-	FALL
-};
-
-class Anime {
-	public:
-		Anime();
-		Anime(const Anime& a);
-		/* List-specific data */
-		enum AnimeWatchingStatus status;
-		int progress;
-		int score;
-		Date started;
-		Date completed;
-		int updated; /* this should be 64-bit */
-		std::string notes;
-
-		/* Useful information */
-		int id;
-		struct {
-			std::string romaji;
-			std::string english;
-			std::string native;
-		} title;
-		std::vector<std::string> synonyms;
-		int episodes;
-		enum AnimeAiringStatus airing;
-		Date air_date;
-		std::vector<std::string> genres;
-		std::vector<std::string> producers;
-		enum AnimeFormat type;
-		enum AnimeSeason season;
-		int audience_score;
-		std::string synopsis;
-		int duration;
-		
-		std::string GetUserPreferredTitle();
-		std::vector<std::string> GetTitleSynonyms();
-};
-
-/* This is a simple wrapper on a vector that provides 
-   methods to make it easier to search the list. */
-class AnimeList {
-	public:
-		AnimeList();
-		AnimeList(const AnimeList& l);
-		AnimeList& operator=(const AnimeList& l);
-		~AnimeList();
-		void Add(Anime& anime);
-		void Insert(size_t pos, Anime& anime);
-		void Delete(size_t index);
-		void Clear();
-		std::vector<Anime>::iterator begin() noexcept;
-		std::vector<Anime>::iterator end() noexcept;
-		std::vector<Anime>::const_iterator cbegin() noexcept;
-		std::vector<Anime>::const_iterator cend() noexcept;
-		size_t Size() const;
-		Anime* AnimeById(int id);
-		int GetAnimeIndex(Anime& anime) const;
-		bool AnimeInList(int id);
-		Anime& operator[](size_t index);
-		const Anime& operator[](size_t index) const;
-		std::string name;
-
-	protected:
-		std::vector<Anime> anime_list;
-		std::map<int, Anime*> anime_id_to_anime;
-};
-
-extern std::map<enum AnimeSeason, std::string> AnimeSeasonToStringMap;
-extern std::map<enum AnimeFormat, std::string> AnimeFormatToStringMap;
-extern std::map<enum AnimeWatchingStatus, std::string> AnimeWatchingToStringMap;
-extern std::map<enum AnimeAiringStatus, std::string> AnimeAiringToStringMap;
-#endif // __anime_h
\ No newline at end of file
--- a/src/include/anime_list.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,109 +0,0 @@
-#ifndef __anime_list_h
-#define __anime_list_h
-#include <vector>
-#include <QStyledItemDelegate>
-#include <QSortFilterProxyModel>
-#include <QAbstractListModel>
-#include <QTreeView>
-#include <QWidget>
-#include "anime.h"
-#include "progress.h"
-
-class AnimeListWidgetDelegate : public QStyledItemDelegate {
-    Q_OBJECT
-
-	public:
-		explicit AnimeListWidgetDelegate(QObject *parent);
-
-		QWidget *createEditor(QWidget *, const QStyleOptionViewItem &, const QModelIndex &) const override;
-		void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
-
-	protected:
-		AnimeProgressBar progress_bar;
-};
-
-class AnimeListWidgetSortFilter : public QSortFilterProxyModel
-{
-    Q_OBJECT
-
-	public:
-		AnimeListWidgetSortFilter(QObject *parent = nullptr);
-
-	protected:
-		bool lessThan(const QModelIndex &l, const QModelIndex &r) const override;
-};
-
-class AnimeListWidgetModel : public QAbstractListModel {
-	Q_OBJECT
-
-	public:
-		enum columns {
-			AL_TITLE,
-			AL_PROGRESS,
-			AL_EPISODES,
-			AL_SCORE,
-			AL_AVG_SCORE,
-			AL_TYPE,
-			AL_SEASON,
-			AL_STARTED,
-			AL_COMPLETED,
-			AL_UPDATED,
-			AL_NOTES,
-			
-			NB_COLUMNS
-		};
-
-		AnimeListWidgetModel(QWidget* parent, AnimeList* alist);
-		~AnimeListWidgetModel() override = default;
-		int rowCount(const QModelIndex& parent = QModelIndex()) const override;
-		int columnCount(const QModelIndex& parent = QModelIndex()) const override;
-		QVariant data(const QModelIndex& index, int role) const override;
-		QVariant headerData(const int section, const Qt::Orientation orientation, const int role) const override;
-		Anime* GetAnimeFromIndex(const QModelIndex& index);
-		void UpdateAnime(Anime& anime);
-		void Update(const AnimeList& new_list);
-
-	private:
-		//void AddAnime(AnimeList& list);
-		AnimeList& list;
-};
-
-/* todo: rename these to "page" or something more
-   sensible than "widget" */
-class AnimeListWidget : public QWidget {
-	Q_OBJECT
-
-	public:
-		AnimeListWidget(QWidget* parent);
-		void SyncAnimeList();
-		void FreeAnimeList();
-		int GetTotalAnimeAmount();
-		int GetTotalEpisodeAmount();
-		int GetTotalWatchedAmount();
-		int GetTotalPlannedAmount();
-		double GetAverageScore();
-		double GetScoreDeviation();
-
-	protected:
-		void paintEvent(QPaintEvent*) override;
-		void InitStyle(QStyleOptionTabWidgetFrame *option) const;
-		void InitBasicStyle(QStyleOptionTabWidgetFrame *option) const;
-		void SetupLayout();
-		void showEvent(QShowEvent*) override;
-		void resizeEvent(QResizeEvent* e) override;
-
-	private slots:
-		void DisplayColumnHeaderMenu();
-		void DisplayListMenu();
-		void ItemDoubleClicked();
-		void SetColumnDefaults();
-		int VisibleColumnsCount() const;
-
-	private:
-		QTabBar* tab_bar;
-		QTreeView* tree_view;
-		QRect panelRect;
-		std::vector<AnimeListWidgetSortFilter*> sort_models;
-		std::vector<AnimeList> anime_lists;
-};
-#endif // __anime_list_h
\ No newline at end of file
--- a/src/include/config.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,46 +0,0 @@
-#ifndef __config_h
-#define __config_h
-enum AnimeTitleLanguage {
-	ROMAJI,
-	NATIVE,
-	ENGLISH
-};
-
-enum AnimeListServices {
-	NONE,
-	ANILIST,
-	NB_SERVICES
-};
-
-enum Themes {
-	LIGHT,
-	DARK,
-	OS
-};
-
-class Config {
-	public:
-		int Load();
-		int Save();
-
-		enum AnimeListServices service;
-		enum Themes theme;
-
-		struct {
-			enum AnimeTitleLanguage language;
-			bool display_aired_episodes;
-			bool display_available_episodes;
-			bool highlight_anime_if_available;
-			bool highlighted_anime_above_others;
-		} anime_list;
-
-		struct {
-			std::string auth_token;
-			std::string username;
-			int user_id;
-		} anilist;
-};
-#define CONFIG_DIR  "weeaboo"
-#define CONFIG_NAME "config.json"
-#define MAX_LINE_LENGTH 256
-#endif // __config_h
--- a/src/include/date.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-#ifndef __date_h
-#define __date_h
-#include <cstdint>
-#include <QDate>
-class Date {
-	public:
-		Date();
-		Date(int32_t y);
-		Date(int32_t y, int8_t m, int8_t d);
-		void SetYear(int32_t y);
-		void SetMonth(int8_t m);
-		void SetDay(int8_t d);
-		int32_t GetYear() const;
-		int8_t GetMonth() const;
-		int8_t GetDay() const;
-		QDate GetAsQDate();
-		bool operator< (const Date& other) const;
-		bool operator> (const Date& other) const;
-		bool operator<= (const Date& other) const;
-		bool operator>= (const Date& other) const;
-
-	private:
-		int32_t year = -1;
-		int8_t month = -1;
-		int8_t day = -1;
-};
-#endif // __date_h
--- a/src/include/filesystem.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-#ifndef __filesystem_h
-#define __filesystem_h
-#include <filesystem>
-std::filesystem::path get_config_path(void);
-#endif // __filesystem_h
\ No newline at end of file
--- a/src/include/information.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,15 +0,0 @@
-#ifndef __information_h
-#define __information_h
-#include <QDialog>
-#include "anime.h"
-class InformationDialog: public QDialog {
-	Q_OBJECT
-	public:
-		InformationDialog(Anime& a, AnimeListWidgetModel* model, QWidget* parent = nullptr);
-
-	private:
-		void OnOK();
-		Anime* anime;
-		AnimeListWidgetModel* model;
-};
-#endif // __information_h
\ No newline at end of file
--- a/src/include/json.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
-#include "../../dep/json/json.h"
-#ifndef __json_h
-#define __json_h
-namespace JSON {
-	std::string GetString(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, std::string def = "");
-	int GetInt(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, int def = 0);
-	bool GetBoolean(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, bool def = false);
-	double GetDouble(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, double def = 0);
-}
-#endif // __json_h
\ No newline at end of file
--- a/src/include/now_playing.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,12 +0,0 @@
-#ifndef __now_playing_h
-#define __now_playing_h
-#include <QWidget>
-
-class NowPlayingWidget : public QWidget {
-	Q_OBJECT
-
-	public:
-		NowPlayingWidget(QWidget* parent = nullptr);
-};
-
-#endif // __now_playing_h
--- a/src/include/progress.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,15 +0,0 @@
-#ifndef __progress_h
-#define __progress_h
-#include <QPainter>
-#include <QStyleOptionViewItem>
-#include <QString>
-#include <QProgressBar>
-class AnimeProgressBar {
-	public:
-		AnimeProgressBar();
-		void paint(QPainter *painter, const QStyleOptionViewItem &option, const QString &text, const int progress, const int episodes) const;
-
-	private:
-		QProgressBar dummy_progress;
-};
-#endif // __progress_h
--- a/src/include/session.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-#ifndef __session_h
-#define __session_h
-#include <QElapsedTimer>
-#include "config.h"
-
-struct Session {
-	Config config;
-	Session() { timer.start(); }
-	int uptime() { return timer.elapsed(); }
-
-	private: QElapsedTimer timer;
-};
-
-extern Session session;
-
-#endif // __session_h
\ No newline at end of file
--- a/src/include/settings.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,64 +0,0 @@
-#ifndef __settings_h
-#define __settings_h
-#include <QWidget>
-#include <QDialog>
-#include <QTabWidget>
-#include <QLabel>
-#include <QLineEdit>
-#include <QComboBox>
-#include <QHBoxLayout>
-#include "sidebar.h"
-#include "anime.h"
-class SettingsPage : public QWidget {
-	Q_OBJECT
-
-	public:
-		SettingsPage(QWidget* parent = nullptr, QString title = "");
-		void SetTitle(QString title);
-		virtual void SaveInfo();
-		void AddTab(QWidget* tab, QString title = "");
-
-	private:
-		QLabel* page_title;
-		QTabWidget* tab_widget;
-};
-
-class SettingsPageServices : public SettingsPage {
-	public:
-		SettingsPageServices(QWidget* parent = nullptr);
-		void SaveInfo() override;
-
-	private:
-		QWidget* CreateMainPage();
-		QWidget* CreateAniListPage();
-		QString username;
-		enum AnimeListServices service;
-};
-
-class SettingsPageApplication : public SettingsPage {
-	public:
-		SettingsPageApplication(QWidget* parent = nullptr);
-		void SaveInfo() override;
-
-	private:
-		QWidget* CreateAnimeListWidget();
-		enum AnimeTitleLanguage language;
-		bool display_aired_episodes;
-		bool display_available_episodes;
-		bool highlight_anime_if_available;
-		bool highlighted_anime_above_others;
-};
-
-class SettingsDialog : public QDialog {
-	Q_OBJECT
-
-	public:
-		SettingsDialog(QWidget* parent = nullptr);
-		QWidget* CreateServicesMainPage(QWidget* parent);
-		void OnOK();
-
-	private:
-		QHBoxLayout* layout;
-		SideBar* sidebar;
-};
-#endif // __settings_h
\ No newline at end of file
--- a/src/include/sidebar.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,23 +0,0 @@
-#ifndef __sidebar_h
-#define __sidebar_h
-#include <QListWidget>
-#include <QListWidgetItem>
-#include <QItemSelectionModel>
-class SideBar : public QListWidget {
-	Q_OBJECT
-
-	public:
-		SideBar(QWidget *parent = nullptr);
-		QListWidgetItem* AddItem(QString name, QIcon icon = QIcon());
-		QListWidgetItem* AddSeparator();
-		bool IndexIsSeparator(QModelIndex index) const;
-
-	signals:
-		void CurrentItemChanged(int index);
-
-	protected:
-		virtual void mouseMoveEvent(QMouseEvent* event) override;
-		QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex & index, const QEvent * event) const override;
-		int RemoveSeparatorsFromIndex(int index);
-};
-#endif // __sidebar_h
--- a/src/include/statistics.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,31 +0,0 @@
-#ifndef __statistics_h
-#define __statistics_h
-#include <QWidget>
-#include <QFrame>
-#include <QPlainTextEdit>
-#include "anime_list.h"
-
-class StatisticsWidget : public QFrame {
-	public:
-		StatisticsWidget(AnimeListWidget* listwidget, QWidget* parent = nullptr);
-		void UpdateStatistics();
-
-	private:
-		std::string MinutesToDateString(int minutes);
-		std::string SecondsToDateString(int seconds);
-
-		AnimeListWidget* anime_list;
-		QPlainTextEdit* anime_list_data;
-
-		//QPlainTextEdit* score_distribution_title;
-		//QPlainTextEdit* score_distribution_labels;
-		//wxStaticText* score_distribution_graph; // how am I gonna do this
-
-		/* we don't HAVE a local database (yet ;)) */
-		//wxStaticText* local_database_title;
-		//wxStaticText* local_database_labels;
-		//wxStaticText* local_database_data;
-
-		QPlainTextEdit* application_data;
-};
-#endif // __statistics_h
\ No newline at end of file
--- a/src/include/string_utils.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-#ifndef __string_utils_h
-#define __string_utils_h
-#include <string>
-#include <vector>
-namespace StringUtils {
-	/* Implode function: takes a vector of strings and turns it
-	   into a string, separated by delimiters. */
-	std::string  Implode(const std::vector<std::string>& vector,
-	                     const std::string& delimiter);
-	std::wstring Implode(const std::vector<std::wstring>& vector,
-                         const std::wstring& delimiter);
-
-	/* Conversion from UTF-8 to std::wstring and vice versa */
-	std::string  WstrToUtf8(const std::wstring& string);
-	std::wstring Utf8ToWstr(const std::string& string);
-
-	/* Substring removal functions */
-	std::string  ReplaceAll(const std::string& string,
-						    const std::string& find,
-						    const std::string& replace);
-	std::wstring ReplaceAll(const std::wstring& string,
-                            const std::wstring& find,
-							const std::wstring& replace);
-	std::string  SanitizeLineEndings(const std::string& string);
-	std::wstring SanitizeLineEndings(const std::wstring& string);
-	std::wstring RemoveHtmlTags(const std::wstring& string);
-	std::string  RemoveHtmlTags(const std::string& string);
-
-	/* stupid HTML bullshit */
-	std::string  TextifySynopsis(const std::string& string);
-	std::wstring TextifySynopsis(const std::wstring& string);
-};
-#endif // __string_utils_h
\ No newline at end of file
--- a/src/include/sys/osx/dark_theme.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
-#ifndef __sys__osx__dark_theme_h
-#define __sys__osx__dark_theme_h
-namespace osx {
-	bool DarkThemeAvailable();
-	bool IsInDarkTheme();
-	void SetToDarkTheme();
-	void SetToLightTheme();
-	void SetToAutoTheme();
-}
-#endif // __sys__osx__dark_theme_h
\ No newline at end of file
--- a/src/include/sys/osx/filesystem.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,7 +0,0 @@
-#ifndef __sys__osx__filesystem_h
-#define __sys__osx__filesystem_h
-#include <string>
-namespace osx {
-	std::string GetApplicationSupportDirectory();
-}
-#endif // __sys__osx__filesystem_h
\ No newline at end of file
--- a/src/include/sys/win32/dark_theme.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,7 +0,0 @@
-#ifndef __sys__win32__dark_theme_h
-#define __sys__win32__dark_theme_h
-namespace win32 {
-	bool DarkThemeAvailable();
-	bool IsInDarkTheme();
-}
-#endif // __sys__win32__dark_theme_h
\ No newline at end of file
--- a/src/include/time_utils.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-#ifndef __duration_h
-#define __duration_h
-#include <string>
-#include <cstdint>
-namespace Time {
-	class Duration {
-		public:
-			Duration(int64_t l);
-			int64_t InSeconds();
-			int64_t InMinutes();
-			int64_t InHours();
-			int64_t InDays();
-			std::string AsRelativeString();
-
-		private:
-			int64_t length;
-	};
-	int64_t GetSystemTime();
-};
-#endif // __duration_h
\ No newline at end of file
--- a/src/include/ui_utils.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,72 +0,0 @@
-#ifndef __ui_utils_h
-#define __ui_utils_h
-#include <QLabel>
-#include <QFrame>
-#include <QWidget>
-#include <QString>
-#include <QPoint>
-#include <QSize>
-#include <QDateTime>
-#include <QIcon>
-#include <QPlainTextEdit>
-namespace UiUtils {
-	QIcon CreateSideBarIcon(const char* file);
-	bool IsInDarkMode();
-	std::string GetLengthFromQDateTime(QDateTime stamp);
-	void SetPlainTextEditData(QPlainTextEdit* text_edit, QString data);
-
-	class Header : public QWidget {
-		public:
-			Header(QString title, QWidget* parent = nullptr);
-			void SetTitle(QString title);
-
-		private:
-			QLabel* static_text_title;
-			QFrame* static_text_line;
-	};
-
-	class Paragraph : public QPlainTextEdit {
-		public:
-			Paragraph(QString text, QWidget* parent = nullptr);
-			QSize minimumSizeHint() const override;
-			QSize sizeHint() const override;
-	};
-
-	/* Convenience class that combines Paragraph and Header.
-	   Fairly awful naming, but meh :') */
-	class TextParagraph : public QWidget {
-		public:
-			TextParagraph(QString title, QString data, QWidget* parent = nullptr);
-			Header* GetHeader();
-			Paragraph* GetParagraph();
-
-		private:
-			Header* header;
-			Paragraph* paragraph;
-	};
-
-	class LabelledTextParagraph : public QWidget {
-		public:
-			LabelledTextParagraph(QString title, QString label, QString data, QWidget* parent = nullptr);
-			Header* GetHeader();
-			Paragraph* GetLabels();
-			Paragraph* GetParagraph();
-
-		private:
-			Header* header;
-			Paragraph* labels;
-			Paragraph* paragraph;
-	};
-
-	class SelectableTextParagraph : public QWidget {
-		public:
-			SelectableTextParagraph(QString title, QString data, QWidget* parent = nullptr);
-			Header* GetHeader();
-			Paragraph* GetParagraph();
-
-		private:
-			Header* header;
-			Paragraph* paragraph;
-	};
-};
-#endif // __ui_utils_h
\ No newline at end of file
--- a/src/include/window.h	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-#ifndef __window_h
-#define __window_h
-#include <QMainWindow>
-#include <QWidget>
-#include <QCloseEvent>
-#include "config.h"
-
-class MainWindow : public QMainWindow {
-	public:
-		MainWindow(QWidget* parent = nullptr);
-		void SetActivePage(QWidget* page);
-		void SetStyleSheet(enum Themes theme);
-		void ThemeChanged();
-		void closeEvent(QCloseEvent* event) override;
-
-	private:
-		QWidget* main_widget;
-};
-
-#endif // __window_h
--- a/src/json.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,30 +0,0 @@
-#include "json.h"
-
-namespace JSON {
-
-std::string GetString(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, std::string def) {
-	if (json.contains(ptr) && json[ptr].is_string())
-		return json[ptr].get<std::string>();
-	else return def;
-}
-
-int GetInt(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, int def) {
-	if (json.contains(ptr) && json[ptr].is_number())
-		return json[ptr].get<int>();
-	else return def;
-}
-
-bool GetBoolean(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, bool def) {
-	if (json.contains(ptr) && json[ptr].is_boolean())
-		return json[ptr].get<bool>();
-	else return def;
-}
-
-double GetDouble(nlohmann::json const& json, nlohmann::json::json_pointer const& ptr, double def) {
-	if (json.contains(ptr) && json[ptr].is_number())
-		return json[ptr].get<double>();
-	else return def;
-}
-
-}
-
--- a/src/main.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ b/src/main.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -1,222 +1,19 @@
-#include <QApplication>
-#include <QMainWindow>
-#include <QMenuBar>
-#include <QPlainTextEdit>
-#include <QStackedWidget>
-#include <QFile>
-#include <QTextStream>
-#include <QMessageBox>
-#include "window.h"
-#include "config.h"
-#include "anime_list.h"
-#include "now_playing.h"
-#include "statistics.h"
-#include "sidebar.h"
-#include "ui_utils.h"
-#include "settings.h"
-#include "session.h"
-#if MACOSX
-#include "sys/osx/dark_theme.h"
-#elif WIN32
-#include "sys/win32/dark_theme.h"
-#endif
-
-Session session;
-
-/* note that this code was originally created for use in 
-   wxWidgets, but I thought the API was a little meh, so
-   I switched to Qt. */
-
-MainWindow::MainWindow(QWidget* parent) :
-           QMainWindow(parent) {
-	main_widget = new QWidget(parent);
-	/* Menu Bar */
-	QAction* action;
-	QMenuBar* menubar = new QMenuBar(parent);
-	QMenu* menu = menubar->addMenu("&File");
-	QMenu* submenu = menu->addMenu("&Library folders");
-	action = new QAction("&Add new folder...");
-	submenu->addAction(action);
-	action = new QAction("&Scan available episodes");
-	menu->addAction(action);
-
-	menu->addSeparator();
-
-	action = menu->addAction("Play &next episode");
-	action = menu->addAction("Play &random episode");
-	menu->addSeparator();
-	action = menu->addAction("E&xit", qApp, &QApplication::quit);
-
-	menu = menubar->addMenu("&Services");
-	action = new QAction("Synchronize &list");
-
-	menu->addSeparator();
-
-	submenu = menu->addMenu("&AniList");
-	action = submenu->addAction("Go to my &profile");
-	action = submenu->addAction("Go to my &stats");
-
-	submenu = menu->addMenu("&Kitsu");
-	action = submenu->addAction("Go to my &feed");
-	action = submenu->addAction("Go to my &library");
-	action = submenu->addAction("Go to my &profile");
-
-	submenu = menu->addMenu("&MyAnimeList");
-	action = submenu->addAction("Go to my p&anel");
-	action = submenu->addAction("Go to my &profile");
-	action = submenu->addAction("Go to my &history");
-
-	menu = menubar->addMenu("&Tools");
-	submenu = menu->addMenu("&Export anime list");
-	action = submenu->addAction("Export as &Markdown...");
-	action = submenu->addAction("Export as MyAnimeList &XML...");
-
-	menu->addSeparator();
-
-	action = menu->addAction("Enable anime &recognition");
-	action->setCheckable(true);
-	action = menu->addAction("Enable auto &sharing");
-	action->setCheckable(true);
-	action = menu->addAction("Enable &auto synchronization");
-	action->setCheckable(true);
-
-	menu->addSeparator();
-
-	action = menu->addAction("&Settings", [this]{
-		SettingsDialog dialog(this);
-		dialog.exec();
-	});
-
-	setMenuBar(menubar);
-
-	SideBar* sidebar = new SideBar(main_widget);
-	sidebar->AddItem("Now Playing", UiUtils::CreateSideBarIcon(":/icons/16x16/film.png"));
-	sidebar->AddSeparator();
-	sidebar->AddItem("Anime List", UiUtils::CreateSideBarIcon(":/icons/16x16/document-list.png"));
-	sidebar->AddItem("History", UiUtils::CreateSideBarIcon(":/icons/16x16/clock-history-frame.png"));
-	sidebar->AddItem("Statistics", UiUtils::CreateSideBarIcon(":/icons/16x16/chart.png"));
-	sidebar->AddSeparator();
-	sidebar->AddItem("Search", UiUtils::CreateSideBarIcon(":/icons/16x16/magnifier.png"));
-	sidebar->AddItem("Seasons", UiUtils::CreateSideBarIcon(":/icons/16x16/calendar.png"));
-	sidebar->AddItem("Torrents", UiUtils::CreateSideBarIcon(":/icons/16x16/feed.png"));
-	sidebar->setFixedWidth(128);
-	sidebar->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
-
-	QStackedWidget* stack = new QStackedWidget(main_widget);
-	stack->addWidget(new NowPlayingWidget(parent));
-	AnimeListWidget* list_widget = new AnimeListWidget(parent);
-	list_widget->SyncAnimeList();
-	stack->addWidget(list_widget);
-	stack->addWidget(new StatisticsWidget(list_widget, parent));
-
-	connect(sidebar, &SideBar::CurrentItemChanged, stack, [stack](int index){
-		switch (index) {
-			case 0:
-			case 1:
-				stack->setCurrentIndex(index);
-				break;
-			case 3:
-				stack->setCurrentIndex(2);
-				break;
-			default:
-				break;
-		}
-	});
-	sidebar->setCurrentRow(2);
-
-	QHBoxLayout* layout = new QHBoxLayout(main_widget);
-	layout->addWidget(sidebar, 0, Qt::AlignLeft | Qt::AlignTop);
-	layout->addWidget(stack);
-	setCentralWidget(main_widget);
-
-	ThemeChanged();
-}
-
-void MainWindow::SetStyleSheet(enum Themes theme) {
-	switch (theme) {
-		case DARK: {
-			QFile f(":qdarkstyle/dark/darkstyle.qss");
-			if (!f.exists())
-				return; // fail
-			f.open(QFile::ReadOnly | QFile::Text);
-			QTextStream ts(&f);
-			setStyleSheet(ts.readAll());
-			break;
-		}
-		default:
-			setStyleSheet("");
-			break;
-	}
-}
-
-void MainWindow::ThemeChanged() {
-	switch (session.config.theme) {
-		case LIGHT: {
-#if MACOSX
-			if (osx::DarkThemeAvailable())
-				osx::SetToLightTheme();
-			else
-				SetStyleSheet(LIGHT);
-#else
-			SetStyleSheet(LIGHT);
-#endif
-			break;
-		}
-		case DARK: {
-#if MACOSX
-			if (osx::DarkThemeAvailable())
-				osx::SetToDarkTheme();
-			else
-				SetStyleSheet(DARK);
-#else
-			SetStyleSheet(DARK);
-#endif
-			break;
-		}
-		case OS: {
-#if MACOSX
-			if (osx::DarkThemeAvailable())
-				osx::SetToAutoTheme();
-			else
-				SetStyleSheet(LIGHT);
-#elif defined(WIN32)
-			if (win32::DarkThemeAvailable()) {
-				if (win32::IsInDarkTheme()) {
-					SetStyleSheet(DARK);
-				} else {
-					SetStyleSheet(LIGHT);
-				}
-			}
-#else
-			/* Currently OS detection only supports Windows and macOS.
-			   Please don't be shy if you're willing to port it to other OSes
-			   (or desktop environments, or window managers) */
-			SetStyleSheet(LIGHT);
-#endif
-			break;
-		}
-	}
-}
-
-void MainWindow::SetActivePage(QWidget* page) {
-	this->setCentralWidget(page);
-}
-
-void MainWindow::closeEvent(QCloseEvent* event) {
-	session.config.Save();
-	event->accept();
-}
-
-int main(int argc, char** argv) {
-	QApplication app(argc, argv);
-
-	session.config.Load();
-
-	MainWindow window;
-
-	window.resize(941, 750);
-	window.setWindowTitle("Weeaboo");
-	window.show();
-
-	return app.exec();
-}
+#include "core/session.h"
+#include "gui/window.h"
+#include <QApplication>
+
+Session session;
+
+int main(int argc, char** argv) {
+	QApplication app(argc, argv);
+
+	session.config.Load();
+
+	MainWindow window;
+
+	window.resize(941, 750);
+	window.setWindowTitle("Weeaboo");
+	window.show();
+
+	return app.exec();
+}
\ No newline at end of file
--- a/src/pages/anime_list.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,565 +0,0 @@
-/**
- * anime_list.cpp: defines the anime list page
- * and widgets.
- *
- * much of this file is based around
- * Qt's original QTabWidget implementation, because
- * I needed a somewhat native way to create a tabbed
- * widget with only one subwidget that worked exactly
- * like a native tabbed widget.
-**/
-#include <cmath>
-#include <QStyledItemDelegate>
-#include <QProgressBar>
-#include <QShortcut>
-#include <QHBoxLayout>
-#include <QStylePainter>
-#include <QMenu>
-#include <QHeaderView>
-#include "anilist.h"
-#include "anime.h"
-#include "anime_list.h"
-#include "information.h"
-#include "session.h"
-#include "time_utils.h"
-
-AnimeListWidgetDelegate::AnimeListWidgetDelegate(QObject* parent)
-	: QStyledItemDelegate (parent) {
-}
-
-QWidget* AnimeListWidgetDelegate::createEditor(QWidget *, const QStyleOptionViewItem &, const QModelIndex &) const {
-    // no edit 4 u
-    return nullptr;
-}
-
-void AnimeListWidgetDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
-{
-    switch (index.column()) {
-		case AnimeListWidgetModel::AL_PROGRESS: {
-			const int progress = static_cast<int>(index.data(Qt::UserRole).toReal());
-			const int episodes = static_cast<int>(index.siblingAtColumn(AnimeListWidgetModel::AL_EPISODES).data(Qt::UserRole).toReal());
-
-			QStyleOptionViewItem customOption (option);
-			customOption.state.setFlag(QStyle::State_Enabled, true);
-
-			progress_bar.paint(painter, customOption, index.data().toString(), progress, episodes);
-			break;
-		}
-		default:
-			QStyledItemDelegate::paint(painter, option, index);
-			break;
-    }
-}
-
-AnimeListWidgetSortFilter::AnimeListWidgetSortFilter(QObject *parent)
-    : QSortFilterProxyModel(parent) {
-}
-
-bool AnimeListWidgetSortFilter::lessThan(const QModelIndex &l,
-                                         const QModelIndex &r) const {
-    QVariant left  = sourceModel()->data(l, sortRole());
-    QVariant right = sourceModel()->data(r, sortRole());
-
-	switch (left.userType()) {
-		case QMetaType::Int:
-		case QMetaType::UInt:
-		case QMetaType::LongLong:
-		case QMetaType::ULongLong:
-			return left.toInt() < right.toInt();
-		case QMetaType::QDate:
-			return left.toDate() < right.toDate();
-		case QMetaType::QString:
-		default:
-			return QString::compare(left.toString(), right.toString(), Qt::CaseInsensitive) < 0;
-	}
-}
-
-AnimeListWidgetModel::AnimeListWidgetModel (QWidget* parent, AnimeList* alist)
-                                          : QAbstractListModel(parent)
-										  , list(*alist) {
-	return;
-}
-
-int AnimeListWidgetModel::rowCount(const QModelIndex& parent) const {
-	return list.Size();
-	(void)(parent);
-}
-
-int AnimeListWidgetModel::columnCount(const QModelIndex& parent) const {
-	return NB_COLUMNS;
-	(void)(parent);
-}
-
-QVariant AnimeListWidgetModel::headerData(const int section, const Qt::Orientation orientation, const int role) const {
-	if (role == Qt::DisplayRole) {
-		switch (section) {
-			case AL_TITLE:
-				return tr("Anime title");
-			case AL_PROGRESS:
-				return tr("Progress");
-			case AL_EPISODES:
-				return tr("Episodes");
-			case AL_TYPE:
-				return tr("Type");
-			case AL_SCORE:
-				return tr("Score");
-			case AL_SEASON:
-				return tr("Season");
-			case AL_STARTED:
-				return tr("Date started");
-			case AL_COMPLETED:
-				return tr("Date completed");
-			case AL_NOTES:
-				return tr("Notes");
-			case AL_AVG_SCORE:
-				return tr("Average score");
-			case AL_UPDATED:
-				return tr("Last updated");
-			default:
-				return {};
-		}
-	} else if (role == Qt::TextAlignmentRole) {
-		switch (section) {
-			case AL_TITLE:
-			case AL_NOTES:
-				return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
-			case AL_PROGRESS:
-			case AL_EPISODES:
-			case AL_TYPE:
-			case AL_SCORE:
-			case AL_AVG_SCORE:
-				return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
-			case AL_SEASON:
-			case AL_STARTED:
-			case AL_COMPLETED:
-			case AL_UPDATED:
-				return QVariant(Qt::AlignRight | Qt::AlignVCenter);
-			default:
-				return QAbstractListModel::headerData(section, orientation, role);
-		}
-	}
-	return QAbstractListModel::headerData(section, orientation, role);
-}
-
-Anime* AnimeListWidgetModel::GetAnimeFromIndex(const QModelIndex& index) {
-	return (index.isValid()) ? &(list[index.row()]) : nullptr;
-}
-
-QVariant AnimeListWidgetModel::data(const QModelIndex& index, int role) const {
-	if (!index.isValid())
-		return QVariant();
-	switch (role) {
-		case Qt::DisplayRole:
-			switch (index.column()) {
-				case AL_TITLE:
-					return QString::fromUtf8(list[index.row()].GetUserPreferredTitle().c_str());
-				case AL_PROGRESS:
-					return QString::number(list[index.row()].progress) + "/" + QString::number(list[index.row()].episodes);
-				case AL_EPISODES:
-					return list[index.row()].episodes;
-				case AL_SCORE:
-					return list[index.row()].score;
-				case AL_TYPE:
-					return QString::fromStdString(AnimeFormatToStringMap[list[index.row()].type]);
-				case AL_SEASON:
-					return QString::fromStdString(AnimeSeasonToStringMap[list[index.row()].season]) + " " + QString::number(list[index.row()].air_date.GetYear());
-				case AL_AVG_SCORE:
-					return QString::number(list[index.row()].audience_score) + "%";
-				case AL_STARTED:
-					return list[index.row()].started.GetAsQDate();
-				case AL_COMPLETED:
-					return list[index.row()].completed.GetAsQDate();
-				case AL_UPDATED: {
-					if (list[index.row()].updated == 0)
-						return QString("-");
-					Time::Duration duration(Time::GetSystemTime() - list[index.row()].updated);
-					return QString::fromUtf8(duration.AsRelativeString().c_str());
-				}
-				case AL_NOTES:
-					return QString::fromUtf8(list[index.row()].notes.c_str());
-				default:
-					return "";
-			}
-			break;
-		case Qt::UserRole:
-			switch (index.column()) {
-				case AL_PROGRESS:
-					return list[index.row()].progress;
-				case AL_TYPE:
-					return list[index.row()].type;
-				case AL_SEASON:
-					return list[index.row()].air_date.GetAsQDate();
-				case AL_AVG_SCORE:
-					return list[index.row()].audience_score;
-				case AL_UPDATED:
-					return list[index.row()].updated;
-				default:
-					return data(index, Qt::DisplayRole);
-			}
-			break;
-		case Qt::TextAlignmentRole:
-			switch (index.column()) {
-				case AL_TITLE:
-				case AL_NOTES:
-					return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
-				case AL_PROGRESS:
-				case AL_EPISODES:
-				case AL_TYPE:
-				case AL_SCORE:
-				case AL_AVG_SCORE:
-					return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
-				case AL_SEASON:
-				case AL_STARTED:
-				case AL_COMPLETED:
-				case AL_UPDATED:
-					return QVariant(Qt::AlignRight | Qt::AlignVCenter);
-				default:
-					break;
-			}
-			break;
-	}
-	return QVariant();
-}
-
-void AnimeListWidgetModel::UpdateAnime(Anime& anime) {
-	int i = list.GetAnimeIndex(anime);
-	emit dataChanged(index(i), index(i));
-}
-
-void AnimeListWidgetModel::Update(AnimeList const& new_list) {
-	list = AnimeList(new_list);
-	emit dataChanged(index(0), index(rowCount()));
-}
-
-int AnimeListWidget::VisibleColumnsCount() const {
-    int count = 0;
-
-    for (int i = 0, end = tree_view->header()->count(); i < end; i++)
-    {
-        if (!tree_view->isColumnHidden(i))
-            count++;
-    }
-
-    return count;
-}
-
-void AnimeListWidget::SetColumnDefaults() {
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_SEASON, false);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_TYPE, false);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_UPDATED, false);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_PROGRESS, false);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_SCORE, false);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_TITLE, false);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_EPISODES, true);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_AVG_SCORE, true);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_STARTED, true);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_COMPLETED, true);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_UPDATED, true);
-	tree_view->setColumnHidden(AnimeListWidgetModel::AL_NOTES, true);
-}
-
-void AnimeListWidget::DisplayColumnHeaderMenu() {
-    QMenu *menu = new QMenu(this);
-    menu->setAttribute(Qt::WA_DeleteOnClose);
-    menu->setTitle(tr("Column visibility"));
-    menu->setToolTipsVisible(true);
-
-    for (int i = 0; i < AnimeListWidgetModel::NB_COLUMNS; i++) {
-		if (i == AnimeListWidgetModel::AL_TITLE)
-			continue;
-        const auto column_name = sort_models[tab_bar->currentIndex()]->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();
-        QAction *action = menu->addAction(column_name, this, [this, i](const bool checked) {
-            if (!checked && (VisibleColumnsCount() <= 1))
-                return;
-
-            tree_view->setColumnHidden(i, !checked);
-
-            if (checked && (tree_view->columnWidth(i) <= 5))
-                tree_view->resizeColumnToContents(i);
-
-            // SaveSettings();
-        });
-        action->setCheckable(true);
-        action->setChecked(!tree_view->isColumnHidden(i));
-    }
-
-    menu->addSeparator();
-    QAction *resetAction = menu->addAction(tr("Reset to defaults"), this, [this]() {
-        for (int i = 0, count = tree_view->header()->count(); i < count; ++i)
-        {
-            SetColumnDefaults();
-        }
-		// SaveSettings();
-    });
-    menu->popup(QCursor::pos());
-	(void)(resetAction);
-}
-
-void AnimeListWidget::DisplayListMenu() {
-    QMenu *menu = new QMenu(this);
-    menu->setAttribute(Qt::WA_DeleteOnClose);
-    menu->setTitle(tr("Column visibility"));
-    menu->setToolTipsVisible(true);
-
-    const QItemSelection selection = sort_models[tab_bar->currentIndex()]->mapSelectionToSource(tree_view->selectionModel()->selection());
-    if (!selection.indexes().first().isValid()) {
-        return;
-	}
-
-	QAction* action = menu->addAction("Information", [this, selection]{
-		const QModelIndex index = ((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())->index(selection.indexes().first().row());
-		Anime* anime = ((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())->GetAnimeFromIndex(index);
-		if (!anime) {
-			return;
-		}
-
-		InformationDialog* dialog = new InformationDialog(*anime, ((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel()), this);
-
-		dialog->show();
-		dialog->raise();
-		dialog->activateWindow();
-	});
-	menu->popup(QCursor::pos());
-}
-
-void AnimeListWidget::ItemDoubleClicked() {
-	/* throw out any other garbage */
-    const QItemSelection selection = sort_models[tab_bar->currentIndex()]->mapSelectionToSource(tree_view->selectionModel()->selection());
-    if (!selection.indexes().first().isValid()) {
-        return;
-	}
-
-	const QModelIndex index = ((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())->index(selection.indexes().first().row());
-	Anime* anime = ((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel())->GetAnimeFromIndex(index);
-	if (!anime) {
-		return;
-	}
-
-	InformationDialog* dialog = new InformationDialog(*anime, ((AnimeListWidgetModel*)sort_models[tab_bar->currentIndex()]->sourceModel()), this);
-
-    dialog->show();
-    dialog->raise();
-    dialog->activateWindow();
-}
-
-void AnimeListWidget::paintEvent(QPaintEvent*) {
-    QStylePainter p(this);
-
-    QStyleOptionTabWidgetFrame opt;
-	InitStyle(&opt);
-	opt.rect = panelRect;
-    p.drawPrimitive(QStyle::PE_FrameTabWidget, opt);
-}
-
-void AnimeListWidget::resizeEvent(QResizeEvent* e) {
-	QWidget::resizeEvent(e);
-	SetupLayout();
-}
-
-void AnimeListWidget::showEvent(QShowEvent*) {
-	SetupLayout();
-}
-
-void AnimeListWidget::InitBasicStyle(QStyleOptionTabWidgetFrame *option) const
-{
-	if (!option)
-		return;
-
-    option->initFrom(this);
-    option->lineWidth = style()->pixelMetric(QStyle::PM_DefaultFrameWidth, nullptr, this);
-	option->shape = QTabBar::RoundedNorth;
-    option->tabBarRect = tab_bar->geometry();
-}
-
-void AnimeListWidget::InitStyle(QStyleOptionTabWidgetFrame *option) const
-{
-	if (!option)
-		return;
-
-	InitBasicStyle(option);
-
-    //int exth = style()->pixelMetric(QStyle::PM_TabBarBaseHeight, nullptr, this);
-    QSize t(0, tree_view->frameWidth());
-    if (tab_bar->isVisibleTo(this)) {
-        t = tab_bar->sizeHint();
-		t.setWidth(width());
-    }
-	option->tabBarSize = t;
-
-	QRect selected_tab_rect = tab_bar->tabRect(tab_bar->currentIndex());
-	selected_tab_rect.moveTopLeft(selected_tab_rect.topLeft() + option->tabBarRect.topLeft());
-	option->selectedTabRect = selected_tab_rect;
-
-	option->lineWidth = style()->pixelMetric(QStyle::PM_DefaultFrameWidth, nullptr, this);
-}
-
-void AnimeListWidget::SetupLayout() {
-	QStyleOptionTabWidgetFrame option;
-	InitStyle(&option);
-
-	QRect tabRect = style()->subElementRect(QStyle::SE_TabWidgetTabBar, &option, this);
-	tabRect.setLeft(tabRect.left()+1);
-	panelRect = style()->subElementRect(QStyle::SE_TabWidgetTabPane, &option, this);
-	QRect contentsRect = style()->subElementRect(QStyle::SE_TabWidgetTabContents, &option, this);
-
-	tab_bar->setGeometry(tabRect);
-	tree_view->parentWidget()->setGeometry(contentsRect);
-}
-
-AnimeListWidget::AnimeListWidget(QWidget* parent)
-                               : QWidget(parent) {
-	/* Tab bar */
-	tab_bar = new QTabBar(this);
-	tab_bar->setExpanding(false);
-	tab_bar->setDrawBase(false);
-
-	/* Tree view... */
-	QWidget* tree_widget = new QWidget(this);
-	tree_view = new QTreeView(tree_widget);
-	tree_view->setItemDelegate(new AnimeListWidgetDelegate(tree_view));
-	tree_view->setUniformRowHeights(true);
-	tree_view->setAllColumnsShowFocus(false);
-	tree_view->setSortingEnabled(true);
-	tree_view->setSelectionMode(QAbstractItemView::ExtendedSelection);
-	tree_view->setItemsExpandable(false);
-	tree_view->setRootIsDecorated(false);
-	tree_view->setContextMenuPolicy(Qt::CustomContextMenu);
-	tree_view->setFrameShape(QFrame::NoFrame);
-	QHBoxLayout* layout = new QHBoxLayout;
-	layout->addWidget(tree_view);
-	layout->setMargin(0);
-	tree_widget->setLayout(layout);
-	connect(tree_view, &QAbstractItemView::doubleClicked, this, &AnimeListWidget::ItemDoubleClicked);
-	connect(tree_view, &QWidget::customContextMenuRequested, this, &AnimeListWidget::DisplayListMenu);
-
-	/* Enter & return keys */
-    connect(new QShortcut(Qt::Key_Return, tree_view, nullptr, nullptr, Qt::WidgetShortcut),
-	        &QShortcut::activated, this, &AnimeListWidget::ItemDoubleClicked);
-
-    connect(new QShortcut(Qt::Key_Enter, tree_view, nullptr, nullptr, Qt::WidgetShortcut),
-	        &QShortcut::activated, this, &AnimeListWidget::ItemDoubleClicked);
-
-	tree_view->header()->setStretchLastSection(false);
-	tree_view->header()->setContextMenuPolicy(Qt::CustomContextMenu);
-	connect(tree_view->header(), &QWidget::customContextMenuRequested, this, &AnimeListWidget::DisplayColumnHeaderMenu);
-
-	connect(tab_bar, &QTabBar::currentChanged, this, [this](int index){
-		if (index < sort_models.size())
-			tree_view->setModel(sort_models[index]);
-	});
-
-	setFocusPolicy(Qt::TabFocus);
-	setFocusProxy(tab_bar);
-}
-
-void AnimeListWidget::SyncAnimeList() {
-	switch (session.config.service) {
-		case ANILIST: {
-			session.config.anilist.user_id = AniList::GetUserId(session.config.anilist.username);
-			FreeAnimeList();
-			AniList::UpdateAnimeList(&anime_lists, session.config.anilist.user_id);
-			break;
-		}
-		default:
-			break;
-	}
-	for (unsigned int i = 0; i < anime_lists.size(); i++) {
-		tab_bar->addTab(QString::fromStdString(anime_lists[i].name));
-		AnimeListWidgetSortFilter* sort_model = new AnimeListWidgetSortFilter(tree_view);
-		sort_model->setSourceModel(new AnimeListWidgetModel(this, &anime_lists[i]));
-		sort_model->setSortRole(Qt::UserRole);
-		sort_model->setSortCaseSensitivity(Qt::CaseInsensitive);
-		sort_models.push_back(sort_model);
-	}
-	if (anime_lists.size() > 0)
-		tree_view->setModel(sort_models.at(0));
-	SetColumnDefaults();
-	SetupLayout();
-}
-
-void AnimeListWidget::FreeAnimeList() {
-	while (tab_bar->count())
-		tab_bar->removeTab(0);
-	while (sort_models.size()) {
-		delete sort_models[sort_models.size()-1];
-		sort_models.pop_back();
-	}
-	for (auto& list : anime_lists) {
-		list.Clear();
-	}
-	anime_lists.clear();
-}
-
-int AnimeListWidget::GetTotalAnimeAmount() {
-	int total = 0;
-	for (auto& list : anime_lists) {
-		total += list.Size();
-	}
-	return total;
-}
-
-int AnimeListWidget::GetTotalEpisodeAmount() {
-	/* FIXME: this also needs to take into account rewatches... */
-	int total = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			total += anime.progress;
-		}
-	}
-	return total;
-}
-
-/* Returns the total watched amount in minutes. */
-int AnimeListWidget::GetTotalWatchedAmount() {
-	int total = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			total += anime.duration*anime.progress;
-		}
-	}
-	return total;
-}
-
-/* Returns the total planned amount in minutes.
-   Note that we should probably limit progress to the
-   amount of episodes, as AniList will let you
-   set episode counts up to 32768. But that should
-   rather be handled elsewhere. */
-int AnimeListWidget::GetTotalPlannedAmount() {
-	int total = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			total += anime.duration*(anime.episodes-anime.progress);
-		}
-	}
-	return total;
-}
-
-double AnimeListWidget::GetAverageScore() {
-	double avg = 0;
-	int amt = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			avg += anime.score;
-			if (anime.score != 0)
-				amt++;
-		}
-	}
-	return avg/amt;
-}
-
-double AnimeListWidget::GetScoreDeviation() {
-	double squares_sum = 0, avg = GetAverageScore();
-	int amt = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			if (anime.score != 0) {
-				squares_sum += std::pow((double)anime.score - avg, 2);
-				amt++;
-			}
-		}
-	}
-	return (amt > 0) ? std::sqrt(squares_sum / amt) : 0;
-}
-
-#include "moc_anime_list.cpp"
--- a/src/pages/now_playing.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,7 +0,0 @@
-#include "now_playing.h"
-
-NowPlayingWidget::NowPlayingWidget(QWidget* parent) : QWidget(parent) {
-	
-}
-
-#include "moc_now_playing.cpp"
\ No newline at end of file
--- a/src/pages/statistics.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,103 +0,0 @@
-#include <sstream>
-#include <QWidget>
-#include <QTimer>
-#include <QTextStream>
-#include <QString>
-#include <QTextDocument>
-#include <QVBoxLayout>
-#include "anime_list.h"
-#include "ui_utils.h"
-#include "statistics.h"
-#include "session.h"
-
-StatisticsWidget::StatisticsWidget(AnimeListWidget* listwidget, QWidget* parent)
-	: QFrame(parent) {
-	setLayout(new QVBoxLayout);
-	anime_list = listwidget;
-
-	setFrameShape(QFrame::Panel);
-	setFrameShadow(QFrame::Plain);
-
-	UiUtils::LabelledTextParagraph* anime_list_pg = new UiUtils::LabelledTextParagraph("Anime list", "Anime count:\nEpisode count:\nTime spent watching:\nTime to complete:\nAverage score:\nScore deviation:", "", this);
-	anime_list_data = anime_list_pg->GetParagraph();
-
-	UiUtils::LabelledTextParagraph* application_pg = new UiUtils::LabelledTextParagraph("Weeaboo", "Uptime:", "", this);
-	application_data = application_pg->GetParagraph();
-
-	layout()->addWidget(anime_list_pg);
-	layout()->addWidget(application_pg);
-	((QBoxLayout*)layout())->addStretch();
-
-	QPalette pal = QPalette();
-	pal.setColor(QPalette::Window, Qt::white);
-	setAutoFillBackground(true); 
-	setPalette(pal);
-
-	UpdateStatistics(); // load in statistics as soon as possible
-
-	QTimer* timer = new QTimer(this);
-	connect(timer, &QTimer::timeout, this, [this]{
-		if (isVisible())
-			UpdateStatistics();
-	});
-	timer->start(1000); // update statistics every second
-}
-
-#define ADD_TIME_SEGMENT(r, x, s, p) \
-	if (x.count() > 0) \
-		r << x.count() << ((x.count() == 1) ? s : p)
-std::string StatisticsWidget::MinutesToDateString(int minutes) {
-	/* NOTE: these duration_casts may not be needed... */
-	std::chrono::duration<int, std::ratio<60>> int_total_mins(minutes);
-	auto int_years = std::chrono::duration_cast<std::chrono::years>(int_total_mins);
-	auto int_months = std::chrono::duration_cast<std::chrono::months>(int_total_mins-int_years);
-	auto int_days = std::chrono::duration_cast<std::chrono::days>(int_total_mins-int_years-int_months);
-	auto int_hours = std::chrono::duration_cast<std::chrono::hours>(int_total_mins-int_years-int_months-int_days);
-	auto int_minutes = std::chrono::duration_cast<std::chrono::minutes>(int_total_mins-int_years-int_months-int_days-int_hours);
-	std::ostringstream return_stream;
-	ADD_TIME_SEGMENT(return_stream, int_years, " year ", " years ");
-	ADD_TIME_SEGMENT(return_stream, int_months, " month ", " months ");
-	ADD_TIME_SEGMENT(return_stream, int_days, " day ", " days ");
-	ADD_TIME_SEGMENT(return_stream, int_hours, " hour ", " hours ");
-	if (int_minutes.count() > 0 || return_stream.str().size() == 0)
-		return_stream << int_minutes.count() << ((int_minutes.count() == 1) ? " minute" : " minutes");
-	return return_stream.str();
-}
-
-std::string StatisticsWidget::SecondsToDateString(int seconds) {
-	/* this is all fairly unnecessary, but works:tm: */
-	std::chrono::duration<int, std::ratio<1>> int_total_mins(seconds);
-	auto int_years = std::chrono::duration_cast<std::chrono::years>(int_total_mins);
-	auto int_months = std::chrono::duration_cast<std::chrono::months>(int_total_mins-int_years);
-	auto int_days = std::chrono::duration_cast<std::chrono::days>(int_total_mins-int_years-int_months);
-	auto int_hours = std::chrono::duration_cast<std::chrono::hours>(int_total_mins-int_years-int_months-int_days);
-	auto int_minutes = std::chrono::duration_cast<std::chrono::minutes>(int_total_mins-int_years-int_months-int_days-int_hours);
-	auto int_seconds = std::chrono::duration_cast<std::chrono::seconds>(int_total_mins-int_years-int_months-int_days-int_hours-int_minutes);
-	std::ostringstream return_stream;
-	ADD_TIME_SEGMENT(return_stream, int_years, " year ", " years ");
-	ADD_TIME_SEGMENT(return_stream, int_months, " month ", " months ");
-	ADD_TIME_SEGMENT(return_stream, int_days, " day ", " days ");
-	ADD_TIME_SEGMENT(return_stream, int_hours, " hour ", " hours ");
-	ADD_TIME_SEGMENT(return_stream, int_minutes, " minute ", " minutes ");
-	if (int_seconds.count() > 0 || return_stream.str().size() == 0)
-		return_stream << int_seconds.count() << ((int_seconds.count() == 1) ? " second" : " seconds");
-	return return_stream.str();
-}
-#undef ADD_TIME_SEGMENT
-
-void StatisticsWidget::UpdateStatistics() {
-	/* Anime list */
-	QString string = "";
-	QTextStream ts(&string);
-	ts << anime_list->GetTotalAnimeAmount() << '\n';
-	ts << anime_list->GetTotalEpisodeAmount() << '\n';
-	ts << MinutesToDateString(anime_list->GetTotalWatchedAmount()).c_str() << '\n';
-	ts << MinutesToDateString(anime_list->GetTotalPlannedAmount()).c_str() << '\n';
-	ts << anime_list->GetAverageScore() << '\n';
-	ts << anime_list->GetScoreDeviation() << '\n';
-	UiUtils::SetPlainTextEditData(anime_list_data, string);
-
-	/* Application */
-	//UiUtils::SetPlainTextEditData(application_data, QString::number(session.uptime() / 1000));
-	UiUtils::SetPlainTextEditData(application_data, QString(SecondsToDateString(session.uptime() / 1000).c_str()));
-}
--- a/src/progress.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,39 +0,0 @@
-#include "progress.h"
-
-#include <QPainter>
-#include <QPalette>
-#include <QStyleOptionProgressBar>
-#include <QStyleOptionViewItem>
-#include <QProxyStyle>
-#include <QMessageBox>
-
-AnimeProgressBar::AnimeProgressBar() {
-#if (defined(WIN32) || defined(MACOSX))
-    auto *fusionStyle = new QProxyStyle {"fusion"};
-    fusionStyle->setParent(&dummy_progress);
-    dummy_progress.setStyle(fusionStyle);
-#endif
-}
-
-void AnimeProgressBar::paint(QPainter *painter, const QStyleOptionViewItem &option, const QString &text, const int progress, const int episodes) const {
-    QStyleOptionProgressBar styleOption;
-    styleOption.initFrom(&dummy_progress);
-
-    styleOption.maximum = episodes;
-    styleOption.minimum = 0;
-    styleOption.progress = progress;
-    styleOption.text = text;
-    styleOption.textVisible = true;
-
-    styleOption.rect = option.rect;
-    styleOption.state = option.state;
-
-    const bool enabled = option.state.testFlag(QStyle::State_Enabled);
-    styleOption.palette.setCurrentColorGroup(enabled ? QPalette::Active : QPalette::Disabled);
-
-    painter->save();
-    const QStyle *style = dummy_progress.style();
-    style->drawPrimitive(QStyle::PE_PanelItemViewItem, &option, painter, option.widget);
-    style->drawControl(QStyle::CE_ProgressBar, &styleOption, painter, &dummy_progress);
-    painter->restore();
-}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/services/anilist.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,337 @@
+#include "services/anilist.h"
+#include "core/anime.h"
+#include "core/config.h"
+#include "core/json.h"
+#include "core/session.h"
+#include "core/strings.h"
+#include <QDesktopServices>
+#include <QInputDialog>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <chrono>
+#include <curl/curl.h>
+#include <exception>
+#include <format>
+#define CLIENT_ID "13706"
+
+namespace Services::AniList {
+
+class Account {
+	public:
+		std::string Username() const { return session.anilist.username; }
+		void SetUsername(std::string const& username) { session.anilist.username = username; }
+
+		int UserId() const { return session.anilist.user_id; }
+		void SetUserId(const int id) { session.anilist.user_id = id; }
+
+		std::string AuthToken() const { return session.anilist.auth_token; }
+		void SetAuthToken(std::string const& auth_token) { session.anilist.auth_token = auth_token; }
+
+		bool Authenticated() const { return !AuthToken().empty(); }
+}
+
+static Account account;
+
+static size_t CurlWriteCallback(void* contents, size_t size, size_t nmemb, void* userdata) {
+	((std::string*)userdata)->append((char*)contents, size * nmemb);
+	return size * nmemb;
+}
+
+/* A wrapper around cURL to send requests to AniList */
+std::string SendRequest(std::string data) {
+	struct curl_slist* list = NULL;
+	std::string userdata;
+	CURL* curl = curl_easy_init();
+	if (curl) {
+		list = curl_slist_append(list, "Accept: application/json");
+		list = curl_slist_append(list, "Content-Type: application/json");
+		std::string bearer = "Authorization: Bearer " + account.AuthToken();
+		list = curl_slist_append(list, bearer.c_str());
+		curl_easy_setopt(curl, CURLOPT_URL, "https://graphql.anilist.co");
+		curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data.c_str());
+		curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
+		curl_easy_setopt(curl, CURLOPT_WRITEDATA, &userdata);
+		curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &CurlWriteCallback);
+		/* Use system certs... useful on Windows. */
+		curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA);
+		CURLcode res = curl_easy_perform(curl);
+		curl_slist_free_all(list);
+		curl_easy_cleanup(curl);
+		if (res != CURLE_OK) {
+			QMessageBox box(QMessageBox::Icon::Critical, "",
+							QString("curl_easy_perform(curl) failed!: ") + QString(curl_easy_strerror(res)));
+			box.exec();
+			return "";
+		}
+		return userdata;
+	}
+	return "";
+}
+
+/* Maps to convert string forms to our internal enums */
+
+std::map<std::string, enum AnimeWatchingStatus> StringToAnimeWatchingMap = {
+	{"CURRENT",	CURRENT  },
+	  {"PLANNING",  PLANNING },
+	  {"COMPLETED", COMPLETED},
+	{"DROPPED",	DROPPED  },
+	  {"PAUSED",	 PAUSED   },
+	  {"REPEATING", REPEATING}
+};
+
+std::map<enum AnimeWatchingStatus, std::string> AnimeWatchingToStringMap = {
+	{CURRENT,	  "CURRENT"  },
+	  {PLANNING,	 "PLANNING" },
+	  {COMPLETED, "COMPLETED"},
+	{DROPPED,	  "DROPPED"  },
+	  {PAUSED,	   "PAUSED"   },
+	  {REPEATING, "REPEATING"}
+};
+
+std::map<std::string, enum AnimeAiringStatus> StringToAnimeAiringMap = {
+	{"FINISHED",		 FINISHED		 },
+	{"RELEASING",		  RELEASING	   },
+	{"NOT_YET_RELEASED", NOT_YET_RELEASED},
+	{"CANCELLED",		  CANCELLED	   },
+	{"HIATUS",		   HIATUS			 }
+};
+
+std::map<std::string, enum AnimeSeason> StringToAnimeSeasonMap = {
+	{"WINTER", WINTER},
+	{"SPRING", SPRING},
+	{"SUMMER", SUMMER},
+	{"FALL",	 FALL	 }
+};
+
+std::map<std::string, enum AnimeFormat> StringToAnimeFormatMap = {
+	{"TV",	   TV		 },
+	  {"TV_SHORT", TV_SHORT},
+	  {"MOVIE",	MOVIE	 },
+	{"SPECIAL",	SPECIAL },
+	  {"OVA",	  OVA	 },
+	{"ONA",		ONA	   },
+	  {"MUSIC",	MUSIC	 },
+	  {"MANGA",	MANGA	 },
+	{"NOVEL",	  NOVEL   },
+	  {"ONE_SHOT", ONE_SHOT}
+};
+
+void ParseDate(const nlohmann::json& json, Date& date) {
+	if (json.contains("/year"_json_pointer) && json["/year"_json_pointer].is_number())
+		date.SetYear(JSON::GetInt(json, "/year"_json_pointer));
+	else
+		date.VoidYear();
+
+	if (json.contains("/month"_json_pointer) && json["/month"_json_pointer].is_number())
+		date.SetMonth(JSON::GetInt(json, "/month"_json_pointer));
+	else
+		date.VoidMonth();
+
+	if (json.contains("/day"_json_pointer) && json["/day"_json_pointer].is_number())
+		date.SetDay(JSON::GetInt(json, "/day"_json_pointer));
+	else
+		date.VoidDay();
+}
+
+void ParseTitle(const nlohmann::json& json, Anime::Anime& anime) {
+	anime.SetNativeTitle(JSON::GetString(json, "/native"_json_pointer));
+	anime.SetEnglishTitle(JSON::GetString(json, "/english"_json_pointer));
+	anime.SetRomajiTitle(JSON::GetString(json, "/romaji"_json_pointer));
+}
+
+int ParseMediaJson(const nlohmann::json& json) {
+	int id = JSON::GetInt(json, "/id"_json_pointer);
+	if (!id)
+		return 0;
+	Anime::Anime& anime = Anime::db.items[id];
+	anime.SetId(id);
+
+	ParseTitle(json["/title"_json_pointer], anime);
+
+	anime.SetEpisodes(JSON::GetInt(json, "/episodes"_json_pointer));
+	anime.SetFormat(AniListStringToAnimeFormatMap[JSON::GetString(json, "/format"_json_pointer)]);
+
+	anime.SetListStatus(AniListStringToAnimeAiringMap[JSON::GetString(json, "/status"_json_pointer)]);
+
+	ParseDate(json["/startDate"_json_pointer], anime.air_date);
+
+	anime.SetAudienceScore(JSON::GetInt(json, "/averageScore"_json_pointer));
+	anime.SetSeason(AniListStringToAnimeSeasonMap[JSON::GetString(json, "/season"_json_pointer)]);
+	anime.SetDuration(JSON::GetInt(json, "/duration"_json_pointer));
+	anime.SetSynopsis(StringUtils::TextifySynopsis(JSON::GetString(json, "/description"_json_pointer)));
+
+	if (json.contains("/genres"_json_pointer) && json["/genres"_json_pointer].is_array())
+		anime.SetGenres(json["/genres"_json_pointer].get<std::vector<std::string>>());
+	if (json.contains("/synonyms"_json_pointer) && json["/synonyms"_json_pointer].is_array())
+		anime.SetSynonyms(json["/synonyms"_json_pointer].get<std::vector<std::string>>());
+	return 1;
+}
+
+int ParseListItem(const nlohmann::json& json, Anime::Anime& anime) {
+	anime.SetScore(JSON::GetInt(entry.value(), "/score"_json_pointer));
+	anime.SetProgress(JSON::GetInt(entry.value(), "/progress"_json_pointer));
+	anime.SetStatus(AniListStringToAnimeWatchingMap[JSON::GetString(entry.value(), "/status"_json_pointer)]);
+	anime.SetNotes(JSON::GetString(entry.value(), "/notes"_json_pointer));
+
+	ParseDate(json["/startedAt"_json_pointer], anime.started);
+	ParseDate(json["/completedAt"_json_pointer], anime.completed);
+
+	anime.SetUpdated(JSON::GetInt(entry.value(), "/updatedAt"_json_pointer));
+
+	return ParseMediaJson(json["media"], anime);
+}
+
+int ParseList(const nlohmann::json& json) {
+	for (const auto& entry : json["entries"].items()) {
+		ParseListItem(entry.value());
+	}
+}
+
+int GetAnimeList(int id) {
+	/* NOTE: these should be in the qrc file */
+	const std::string query = "query ($id: Int) {\n"
+							  "  MediaListCollection (userId: $id, type: ANIME) {\n"
+							  "    lists {\n"
+							  "      name\n"
+							  "      entries {\n"
+							  "        score\n"
+							  "        notes\n"
+							  "        progress\n"
+							  "        startedAt {\n"
+							  "          year\n"
+							  "          month\n"
+							  "          day\n"
+							  "        }\n"
+							  "        completedAt {\n"
+							  "          year\n"
+							  "          month\n"
+							  "          day\n"
+							  "        }\n"
+							  "        updatedAt\n"
+							  "        media {\n"
+							  "          id\n"
+							  "          title {\n"
+							  "            romaji\n"
+							  "            english\n"
+							  "            native\n"
+							  "          }\n"
+							  "          format\n"
+							  "          status\n"
+							  "          averageScore\n"
+							  "          season\n"
+							  "          startDate {\n"
+							  "            year\n"
+							  "            month\n"
+							  "            day\n"
+							  "          }\n"
+							  "          genres\n"
+							  "          episodes\n"
+							  "          duration\n"
+							  "          synonyms\n"
+							  "          description(asHtml: false)\n"
+							  "        }\n"
+							  "      }\n"
+							  "    }\n"
+							  "  }\n"
+							  "}\n";
+	// clang-format off
+	nlohmann::json json = {
+		{"query", query},
+		{"variables", {
+			{"id", id}
+		}}
+	};
+	// clang-format on
+	/* TODO: do a try catch here, catch any json errors and then call
+	   Authorize() if needed */
+	auto res = nlohmann::json::parse(SendRequest(json.dump()));
+	/* TODO: make sure that we actually need the wstring converter and see
+	   if we can just get wide strings back from nlohmann::json */
+	for (const auto& list : res["data"]["MediaListCollection"]["lists"].items()) {
+
+		ParseList(list.entry());
+	}
+	return 1;
+}
+
+int UpdateAnimeEntry(const Anime& anime) {
+	/**
+	 * possible values:
+	 * 
+	 * int mediaId,
+	 * MediaListStatus status,
+	 * float score,
+	 * int scoreRaw,
+	 * int progress,
+	 * int progressVolumes,
+	 * int repeat,
+	 * int priority,
+	 * bool private,
+	 * string notes,
+	 * bool hiddenFromStatusLists,
+	 * string[] customLists,
+	 * float[] advancedScores,
+	 * Date startedAt,
+	 * Date completedAt
+	**/
+	const std::string query =
+		"mutation ($media_id: Int, $progress: Int, $status: MediaListStatus, $score: Int, $notes: String) {\n"
+		"  SaveMediaListEntry (mediaId: $media_id, progress: $progress, status: $status, scoreRaw: $score, notes: "
+		"$notes) {\n"
+		"    id\n"
+		"  }\n"
+		"}\n";
+	// clang-format off
+	nlohmann::json json = {
+		{"query", query},
+		{"variables", {
+			{"media_id", anime.id},
+			{"progress", anime.progress},
+			{"status",   AnimeWatchingToStringMap[anime.status]},
+			{"score",    anime.score},
+			{"notes",    anime.notes}
+		}}
+	};
+	// clang-format on
+	SendRequest(json.dump());
+	return 1;
+}
+
+int ParseUser(const nlohmann::json& json) {
+	account.SetUsername(JSON::GetString(json, "/name"_json_pointer));
+	account.SetUserId(JSON::GetInt(json, "/id"_json_pointer));
+	account.SetAuthenticated(true);
+}
+
+int AuthorizeUser() {
+	/* Prompt for PIN */
+	QDesktopServices::openUrl(
+		QUrl("https://anilist.co/api/v2/oauth/authorize?client_id=" CLIENT_ID "&response_type=token"));
+	bool ok;
+	QString token = QInputDialog::getText(
+		0, "Credentials needed!", "Please enter the code given to you after logging in to AniList:", QLineEdit::Normal,
+		"", &ok);
+	if (ok && !token.isEmpty())
+		account.SetAuthToken(token.toStdString());
+	else { // fail
+		account.SetAuthenticated(false);
+		return 0;
+	}
+	const std::string query = "query {\n"
+							  "  Viewer {\n"
+							  "    id\n"
+							  "    name\n"
+							  "    mediaListOptions {\n"
+							  "      scoreFormat\n"
+							  "    }\n"
+							  "  }\n"
+							  "}\n";
+	nlohmann::json json = {
+		{"query", query}
+	};
+	auto ret = nlohmann::json::parse(SendRequest(json.dump()));
+	ParseUser(json["Viewer"]) account.SetAuthenticated(true);
+	return 1;
+}
+
+} // namespace Services::AniList
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/services/services.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -0,0 +1,19 @@
+#include "session.h"
+
+namespace Services {
+
+void Synchronize() {
+	switch (session.config.service) {
+		case ANILIST: AniList::GetAnimeList(); break;
+		default: break;
+	}
+}
+
+void Authorize() {
+	switch (session.config.service) {
+		case ANILIST: AniList::AuthorizeUser(); break;
+		default: break;
+	}
+}
+
+} // namespace Services
\ No newline at end of file
--- a/src/sidebar.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,75 +0,0 @@
-#include "sidebar.h"
-#include <QListWidget>
-#include <QListWidgetItem>
-#include <QFrame>
-#include <QMouseEvent>
-#include <QMessageBox>
-
-SideBar::SideBar(QWidget *parent)
-    : QListWidget(parent)
-{
-	setObjectName("sidebar");
-	setFrameShape(QFrame::NoFrame);
-    setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-    setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-	setSelectionMode(QAbstractItemView::SingleSelection);
-	setSelectionBehavior(QAbstractItemView::SelectItems);
-	setMouseTracking(true);
-	viewport()->setAutoFillBackground(false);
-	setStyleSheet("font-size: 12px");
-	connect(this, &QListWidget::currentRowChanged, this, [this](int index){
-		emit CurrentItemChanged(RemoveSeparatorsFromIndex(index));
-	});
-}
-
-QListWidgetItem* SideBar::AddItem(QString name, QIcon icon) {
-    QListWidgetItem* item = new QListWidgetItem(this);
-    item->setText(name);
-	if (!icon.isNull())
-		item->setIcon(icon);
-	return item;
-}
-
-QListWidgetItem* SideBar::AddSeparator() {
-	QListWidgetItem* item = new QListWidgetItem(this);
-	setStyleSheet("QListWidget::item:disabled {background: transparent;}");
-	QFrame* line = new QFrame(this);
-	line->setFrameShape(QFrame::HLine);
-	line->setFrameShadow(QFrame::Sunken);
-	line->setMouseTracking(true);
-	line->setEnabled(false);
-	setItemWidget(item, line);
-	item->setFlags(Qt::NoItemFlags);
-	return item;
-}
-
-int SideBar::RemoveSeparatorsFromIndex(int index) {
-	int i, j;
-	for (i = 0, j = 0; i < index; i++) {
-		if (!IndexIsSeparator(indexFromItem(item(i)))) j++;
-	}
-	return j;
-}
-
-bool SideBar::IndexIsSeparator(QModelIndex index) const {
-	return !(index.isValid() && index.flags() & Qt::ItemIsEnabled);
-}
-
-QItemSelectionModel::SelectionFlags SideBar::selectionCommand(const QModelIndex & index,
-                                                              const QEvent * event) const {
-	if (IndexIsSeparator(index))
-		return QItemSelectionModel::NoUpdate;
-	return QItemSelectionModel::ClearAndSelect;
-	/* silence unused parameter warnings */
-	(void)event;
-}
-
-void SideBar::mouseMoveEvent(QMouseEvent *event) {
-	if (!IndexIsSeparator(indexAt(event->pos())))
-		setCursor(Qt::PointingHandCursor);
-	else
-		unsetCursor();
-	QListView::mouseMoveEvent(event);
-}
-
-#include "moc_sidebar.cpp"
--- a/src/string_utils.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,132 +0,0 @@
-/**
- * string_utils.cpp: Useful functions for manipulating strings
- *
- * Every function in here *should* have a working wstring equivalent,
- * although we don't use wstrings anymore...
-**/
-#include <vector>
-#include <string>
-#include <codecvt>
-#include <locale>
-#include "string_utils.h"
-
-std::string StringUtils::Implode(const std::vector<std::string>& vector,
-                                 const std::string& delimiter) {
-	if (vector.size() < 1)
-		return "-";
-	std::string out = "";
-	for (unsigned long long i = 0; i < vector.size(); i++) {
-		out.append(vector.at(i));
-		if (i < vector.size()-1)
-			out.append(delimiter);
-	}
-	return out;
-}
-
-std::wstring StringUtils::Implode(const std::vector<std::wstring>& vector,
-                                  const std::wstring& delimiter) {
-	std::wstring out = L"";
-	for (unsigned long long i = 0; i < vector.size(); i++) {
-		out.append(vector.at(i));
-		if (i < vector.size()-1)
-			out.append(delimiter);
-	}
-	return out;
-}
-
-std::string StringUtils::WstrToUtf8(const std::wstring& string) {
-	std::wstring_convert<std::codecvt_utf8<wchar_t>> convert;
-	return convert.to_bytes(string);
-}
-
-std::wstring StringUtils::Utf8ToWstr(const std::string& string) {
-	std::wstring_convert<std::codecvt_utf8<wchar_t>> convert;
-	return convert.from_bytes(string);
-}
-
-std::string StringUtils::ReplaceAll(const std::string& string,
-                                    const std::string& find,
-									const std::string& replace) {
-    std::string result;
-    size_t pos, find_len = find.size(), from = 0;
-    while ((pos=string.find(find,from)) != std::string::npos) {
-        result.append(string, from, pos - from);
-        result.append(replace);
-        from = pos + find_len;
-    }
-    result.append(string, from, std::string::npos);
-    return result;
-}
-
-std::wstring StringUtils::ReplaceAll(const std::wstring& string,
-                                     const std::wstring& find,
-									 const std::wstring& replace) {
-    std::wstring result;
-    size_t pos, find_len = find.size(), from = 0;
-    while ((pos=string.find(find,from)) != std::wstring::npos) {
-        result.append(string, from, pos - from);
-        result.append(replace);
-        from = pos + find_len;
-    }
-    result.append(string, from, std::wstring::npos);
-    return result;
-}
-
-/* this function probably fucks your RAM but whatevs */
-std::string StringUtils::SanitizeLineEndings(const std::string& string) {
-	std::string result(string);
-	result = ReplaceAll(result, "\r\n",   "\n");
-	result = ReplaceAll(result, "<br>",   "\n");
-	result = ReplaceAll(result, "\n\n\n", "\n\n");
-	return result;
-}
-
-std::wstring StringUtils::SanitizeLineEndings(const std::wstring& string) {
-	std::wstring result(string);
-	result = ReplaceAll(result, L"\r\n",   L"\n");
-	result = ReplaceAll(result, L"<br>",   L"\n");
-	result = ReplaceAll(result, L"\n\n\n", L"\n\n");
-	return result;
-}
-
-std::string StringUtils::RemoveHtmlTags(const std::string& string) {
-	std::string html(string);
-    while (html.find("<") != std::string::npos)
-    {
-        auto startpos = html.find("<");
-        auto endpos = html.find(">") + 1;
-
-        if (endpos != std::string::npos)
-        {
-            html.erase(startpos, endpos - startpos);
-        }
-    }
-	return html;
-}
-
-std::wstring StringUtils::RemoveHtmlTags(const std::wstring& string) {
-	std::wstring html(string);
-    while (html.find(L"<") != std::wstring::npos)
-    {
-        auto startpos = html.find(L"<");
-        auto endpos = html.find(L">") + 1;
-
-        if (endpos != std::wstring::npos)
-        {
-            html.erase(startpos, endpos - startpos);
-        }
-    }
-	return html;
-}
-
-std::string StringUtils::TextifySynopsis(const std::string& string) {
-	std::string result = SanitizeLineEndings(string);
-	result = RemoveHtmlTags(string);
-	return result;
-}
-
-std::wstring StringUtils::TextifySynopsis(const std::wstring& string) {
-	std::wstring result = SanitizeLineEndings(string);
-	result = RemoveHtmlTags(string);
-	return result;
-}
--- a/src/sys/osx/dark_theme.mm	Sat Aug 26 03:39:34 2023 -0400
+++ b/src/sys/osx/dark_theme.mm	Sun Sep 10 03:59:16 2023 -0400
@@ -1,47 +1,46 @@
 #include "sys/osx/dark_theme.h"
 #import <Cocoa/Cocoa.h>
 
-bool osx::DarkThemeAvailable()
-{
+namespace osx {
+
+bool DarkThemeAvailable() {
 	if (@available(macOS 10.14, *))
 		return true;
 	else
 		return false;
 }
 
-bool osx::IsInDarkTheme()
-{
-    if (@available(macOS 10.14, *))
-    {
-        auto appearance = [NSApp.effectiveAppearance bestMatchFromAppearancesWithNames:
-                @[ NSAppearanceNameAqua, NSAppearanceNameDarkAqua ]];
-        return [appearance isEqualToString:NSAppearanceNameDarkAqua];
-    }
-    return false;
+bool IsInDarkTheme() {
+	if (@available(macOS 10.14, *)) {
+		auto appearance =
+			[NSApp.effectiveAppearance bestMatchFromAppearancesWithNames:@[
+				NSAppearanceNameAqua, NSAppearanceNameDarkAqua
+			]];
+		return [appearance isEqualToString:NSAppearanceNameDarkAqua];
+	}
+	return false;
 }
 
-void osx::SetToDarkTheme()
-{
-   // https://stackoverflow.com/questions/55925862/how-can-i-set-my-os-x-application-theme-in-code
-   if (@available(macOS 10.14, *))
-   {
-        [NSApp setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]];
-   }
+void SetToDarkTheme() {
+	// https://stackoverflow.com/questions/55925862/how-can-i-set-my-os-x-application-theme-in-code
+	if (@available(macOS 10.14, *)) {
+		[NSApp setAppearance:[NSAppearance
+								 appearanceNamed:NSAppearanceNameDarkAqua]];
+	}
 }
 
-void osx::SetToLightTheme()
-{
-    // https://stackoverflow.com/questions/55925862/how-can-i-set-my-os-x-application-theme-in-code
-    if (__builtin_available(macOS 10.14, *))
-    {
-        [NSApp setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameAqua]];
-    }
+void SetToLightTheme() {
+	// https://stackoverflow.com/questions/55925862/how-can-i-set-my-os-x-application-theme-in-code
+	if (__builtin_available(macOS 10.14, *)) {
+		[NSApp
+			setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameAqua]];
+	}
 }
 
-void osx::SetToAutoTheme()
-{
-    if (@available(macOS 10.14, *))
-    {
-        [NSApp setAppearance:nil];
-    }
+void SetToAutoTheme() {
+	if (@available(macOS 10.14, *)) {
+		[NSApp setAppearance:nil];
+	}
 }
+
+} // namespace osx
--- a/src/sys/osx/filesystem.mm	Sat Aug 26 03:39:34 2023 -0400
+++ b/src/sys/osx/filesystem.mm	Sun Sep 10 03:59:16 2023 -0400
@@ -4,8 +4,9 @@
 namespace osx {
 
 std::string GetApplicationSupportDirectory() {
-	NSArray* strings = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true);
+	NSArray* strings = NSSearchPathForDirectoriesInDomains(
+		NSApplicationSupportDirectory, NSUserDomainMask, true);
 	return std::string([[strings objectAtIndex:0] UTF8String]);
 }
 
-}
\ No newline at end of file
+} // namespace osx
\ No newline at end of file
--- a/src/sys/win32/dark_theme.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ b/src/sys/win32/dark_theme.cpp	Sun Sep 10 03:59:16 2023 -0400
@@ -1,26 +1,27 @@
-#include <QSettings>
+#include "sys/win32/dark_theme.h"
 #include <QOperatingSystemVersion>
-#include "sys/win32/dark_theme.h"
-bool win32::DarkThemeAvailable()
-{
-    // dark mode supported Windows 10 1809 10.0.17763 onward
-    // https://stackoverflow.com/questions/53501268/win10-dark-theme-how-to-use-in-winapi
-    if ( QOperatingSystemVersion::current().majorVersion() == 10 )
-    {
-        return QOperatingSystemVersion::current().microVersion() >= 17763;
-    }
-    else if ( QOperatingSystemVersion::current().majorVersion() > 10 )
-    {
-        return true;
-    }
-    else
-    {
-        return false;
-    }
+#include <QSettings>
+
+namespace win32 {
+
+bool DarkThemeAvailable() {
+	// dark mode supported Windows 10 1809 10.0.17763 onward
+	// https://stackoverflow.com/questions/53501268/win10-dark-theme-how-to-use-in-winapi
+	if (QOperatingSystemVersion::current().majorVersion() == 10) {
+		return QOperatingSystemVersion::current().microVersion() >= 17763;
+	} else if (QOperatingSystemVersion::current().majorVersion() > 10) {
+		return true;
+	} else {
+		return false;
+	}
 }
 
-bool win32::IsInDarkTheme()
-{
-    QSettings settings( "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", QSettings::NativeFormat );
-    return settings.value( "AppsUseLightTheme", 1 ).toInt() == 0;
+bool IsInDarkTheme() {
+	QSettings settings("HKEY_CURRENT_"
+					   "USER\\Software\\Microsoft\\Windows\\CurrentVersion\\The"
+					   "mes\\Personalize",
+					   QSettings::NativeFormat);
+	return settings.value("AppsUseLightTheme", 1).toInt() == 0;
 }
+
+} // namespace win32
--- a/src/time.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,64 +0,0 @@
-#include "time_utils.h"
-#include <string>
-#include <cstdint>
-#include <cmath>
-#include <ctime>
-#include <cassert>
-
-namespace Time {
-
-Duration::Duration(int64_t l) {
-	length = l;
-}
-
-std::string Duration::AsRelativeString() {
-	std::string result;
-	
-	auto get = [](int64_t val, const std::string& s, const std::string& p) {
-		return std::to_string(val) + " " + (val == 1 ? s : p);
-	};
-	
-	if (InSeconds() < 60)
-		result = get(InSeconds(), "second", "seconds");
-	else if (InMinutes() < 60)
-		result = get(InMinutes(), "minute", "minutes");
-	else if (InHours() < 24)
-		result = get(InHours(), "hour", "hours");
-	else if (InDays() < 28)
-		result = get(InDays(), "day", "days");
-	else if (InDays() < 365)
-		result = get(InDays()/30, "month", "months");
-	else
-		result = get(InDays()/365, "year", "years");
-
-	if (length < 0)
-		result = "In " + result;
-	else
-		result += " ago";
-
-	return result;
-}
-
-int64_t Duration::InSeconds() {
-	return length;
-}
-
-int64_t Duration::InMinutes() {
-	return std::llround((double)length / 60.0);
-}
-
-int64_t Duration::InHours() {
-	return std::llround((double)length / 3600.0);
-}
-
-int64_t Duration::InDays() {
-	return std::llround((double)length / 86400.0);
-}
-
-int64_t GetSystemTime() {
-	assert(sizeof(int64_t) >= sizeof(time_t));
-	time_t t = std::time(nullptr);
-	return *reinterpret_cast<int64_t*>(&t);
-}
-
-}
\ No newline at end of file
--- a/src/ui_utils.cpp	Sat Aug 26 03:39:34 2023 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,218 +0,0 @@
-#include <QPixmap>
-#include <QLabel>
-#include <QFrame>
-#include <QVBoxLayout>
-#include <QTextBlock>
-#include "window.h"
-#include "ui_utils.h"
-#include "session.h"
-#ifdef MACOSX
-#include "sys/osx/dark_theme.h"
-#else
-#include "sys/win32/dark_theme.h"
-#endif
-
-namespace UiUtils {
-
-QIcon CreateSideBarIcon(const char* file) {
-	QPixmap pixmap(file, "PNG");
-	QIcon result;
-	result.addPixmap(pixmap, QIcon::Normal);
-	result.addPixmap(pixmap, QIcon::Selected);
-	return result;
-}
-
-bool IsInDarkMode() {
-	if (session.config.theme != OS)
-		return (session.config.theme == DARK);
-#ifdef MACOSX
-	if (osx::DarkThemeAvailable()) {
-		if (osx::IsInDarkTheme()) {
-			return true;
-		} else {
-			return false;
-		}
-	}
-#elif defined(WIN32)
-	if (win32::DarkThemeAvailable()) {
-		if (win32::IsInDarkTheme()) {
-			return true;
-		} else {
-			return false;
-		}
-	}
-#endif
-	return (session.config.theme == DARK);
-}
-
-Header::Header(QString title, QWidget* parent)
-	: QWidget(parent) {
-	setLayout(new QVBoxLayout);
-	setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
-
-	static_text_title  = new QLabel(title, this);
-	static_text_title->setTextFormat(Qt::PlainText);
-	QFont font = static_text_title->font();
-	font.setWeight(QFont::Bold);
-	static_text_title->setFont(font);
-	static_text_title->setFixedHeight(16);
-
-	static_text_line = new QFrame(this);
-	static_text_line->setFrameShape(QFrame::HLine);
-	static_text_line->setFrameShadow(QFrame::Sunken);
-	static_text_line->setFixedHeight(2);
-
-	layout()->addWidget(static_text_title);
-	layout()->addWidget(static_text_line);
-	layout()->setSpacing(0);
-	layout()->setMargin(0);
-}
-
-void Header::SetTitle(QString title) {
-	static_text_title->setText(title);
-}
-
-TextParagraph::TextParagraph(QString title, QString data, QWidget* parent)
-	: QWidget(parent) {
-	setLayout(new QVBoxLayout);
-
-	header = new Header(title, this);
-
-	QWidget* content = new QWidget(this);
-	content->setLayout(new QHBoxLayout);
-
-	paragraph = new Paragraph(data, this);
-	paragraph->setTextInteractionFlags(Qt::NoTextInteraction);
-	paragraph->setAttribute(Qt::WidgetAttribute::WA_TransparentForMouseEvents);
-	paragraph->setWordWrapMode(QTextOption::NoWrap);
-
-	content->layout()->addWidget(paragraph);
-	content->layout()->setSpacing(0);
-	content->layout()->setMargin(0);
-	content->setContentsMargins(12, 0, 0, 0);
-
-	layout()->addWidget(header);
-	layout()->addWidget(paragraph);
-	layout()->setSpacing(0);
-	layout()->setMargin(0);
-}
-
-Header* TextParagraph::GetHeader() {
-	return header;
-}
-
-Paragraph* TextParagraph::GetParagraph() {
-	return paragraph;
-}
-
-LabelledTextParagraph::LabelledTextParagraph(QString title, QString label, QString data, QWidget* parent)
-	: QWidget(parent) {
-	setLayout(new QVBoxLayout);
-
-	header = new Header(title, this);
-
-	// this is not accessible from the object because there's really
-	// no reason to make it accessible...
-	QWidget* content = new QWidget(this);
-	content->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
-
-	labels = new Paragraph(label, this);
-	labels->setTextInteractionFlags(Qt::NoTextInteraction);
-	labels->setAttribute(Qt::WidgetAttribute::WA_TransparentForMouseEvents);
-	labels->setWordWrapMode(QTextOption::NoWrap);
-	labels->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
-	labels->setFixedWidth(123);
-
-	paragraph = new Paragraph(data, this);
-	paragraph->setTextInteractionFlags(Qt::NoTextInteraction);
-	paragraph->setAttribute(Qt::WidgetAttribute::WA_TransparentForMouseEvents);
-	paragraph->setWordWrapMode(QTextOption::NoWrap);
-
-	QHBoxLayout* content_layout = new QHBoxLayout;
-	content_layout->addWidget(labels, 0, Qt::AlignTop);
-	content_layout->addWidget(paragraph, 0, Qt::AlignTop);
-	content_layout->setSpacing(0);
-	content_layout->setMargin(0);
-	content->setLayout(content_layout);
-
-	content->setContentsMargins(12, 0, 0, 0);
-
-	layout()->addWidget(header);
-	layout()->addWidget(content);
-	layout()->setSpacing(0);
-	layout()->setMargin(0);
-}
-
-Header* LabelledTextParagraph::GetHeader() {
-	return header;
-}
-
-Paragraph* LabelledTextParagraph::GetLabels() {
-	return labels;
-}
-
-Paragraph* LabelledTextParagraph::GetParagraph() {
-	return paragraph;
-}
-
-SelectableTextParagraph::SelectableTextParagraph(QString title, QString data, QWidget* parent)
-	: QWidget(parent) {
-	setLayout(new QVBoxLayout);
-
-	header = new Header(title, this);
-
-	QWidget* content = new QWidget(this);
-	content->setLayout(new QHBoxLayout);
-
-	paragraph = new Paragraph(data, content);
-
-	content->layout()->addWidget(paragraph);
-	content->layout()->setSpacing(0);
-	content->layout()->setMargin(0);
-	content->setContentsMargins(12, 0, 0, 0);
-
-	layout()->addWidget(header);
-	layout()->addWidget(content);
-	layout()->setSpacing(0);
-	layout()->setMargin(0);
-}
-
-Header* SelectableTextParagraph::GetHeader() {
-	return header;
-}
-
-Paragraph* SelectableTextParagraph::GetParagraph() {
-	return paragraph;
-}
-
-void SetPlainTextEditData(QPlainTextEdit* text_edit, QString data) {
-	QTextDocument* document = new QTextDocument(text_edit);
-	document->setDocumentLayout(new QPlainTextDocumentLayout(document));
-	document->setPlainText(data);
-	text_edit->setDocument(document);
-}
-
-/* inherits QPlainTextEdit and gives a much more reasonable minimum size */
-Paragraph::Paragraph(QString text, QWidget* parent)
-	: QPlainTextEdit(text, parent) {
-	setReadOnly(true);
-	setTextInteractionFlags(Qt::TextBrowserInteraction);
-	setFrameShape(QFrame::NoFrame);
-	setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-	setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-	setStyleSheet("background: transparent;");
-	setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
-}
-
-/* highly based upon... some stackoverflow answer for PyQt */
-QSize Paragraph::minimumSizeHint() const {
-	QTextDocument* doc = document();
-	long h = (long)(blockBoundingGeometry(doc->findBlockByNumber(doc->blockCount() - 1)).bottom() + (2 * doc->documentMargin()));
-	return QSize(QPlainTextEdit::sizeHint().width(), (long)h);
-}
-
-QSize Paragraph::sizeHint() const {
-	return minimumSizeHint();
-}
-
-} // namespace UiUtils