Repository: sepinf-inc/IPED
Pull request #2393: Support Transmission (Torrent Client) resume files 
Author: login: wladimirleite
Created: 2024-12-30T20:20:55Z | Merged: 2025-05-20T12:33:36Z

Closes #2374.

Linked issue #2374: Support Transmission (BitTorrent client) control files
This can be very useful for CSAM related cases, although this client does seem very common:
https://en.wikipedia.org/wiki/Transmission_(BitTorrent_client)

In particular, control files that may contain information about uploaded bytes (**.resume** extension), which can be associated to a torrent, through the **InfoHash**.
The format is somewhat similar to other BitTorrent clients, but not similar enough to be recognized by IPED's parser.

Diff:
diff --git a/iped-app/resources/config/conf/CategoriesConfig.json b/iped-app/resources/config/conf/CategoriesConfig.json
index 115b36ec61..b2aaa718db 100644
--- a/iped-app/resources/config/conf/CategoriesConfig.json
+++ b/iped-app/resources/config/conf/CategoriesConfig.json
@@ -108,7 +108,7 @@
       {"name": "Ares Galaxy", "mimes": ["application/x-ares-galaxy","application/x-ares-galaxy-entry"]},
       {"name": "E-Mule", "mimes": ["application/x-emule", "application/x-emule-part-met", "application/x-emule-searches", "application/x-emule-preferences-ini", "application/x-emule-preferences-dat", "application/x-emule-known-met-entry", "application/x-emule-part-met-entry"]},
       {"name": "Shareaza", "mimes": ["application/x-shareaza-searches-dat", "application/x-shareaza-library-dat", "application/x-shareaza-library-dat-entry", "application/x-shareaza-download"]},
-      {"name": "Torrent", "mimes": ["application/x-bittorrent-resume-dat", "application/x-bittorrent-resume-dat-entry", "application/x-bittorrent-settings-dat", "application/x-bittorrent"]},
+      {"name": "Torrent", "mimes": ["application/x-bittorrent-resume-dat", "application/x-bittorrent-resume-dat-entry", "application/x-bittorrent-settings-dat", "application/x-bittorrent", "application/x-transmission-resume"]},
       {"name": "Other Peer-to-peer", "mimes": ["application/x-p2p"]}
     ]},
     {"name": "Browser Artifacts", "categories":[
diff --git a/iped-app/resources/config/conf/CustomSignatures.xml b/iped-app/resources/config/conf/CustomSignatures.xml
index 14a7292622..8404197e8a 100644
--- a/iped-app/resources/config/conf/CustomSignatures.xml
+++ b/iped-app/resources/config/conf/CustomSignatures.xml
@@ -471,6 +471,13 @@
 		<glob pattern="*.torrent"/>
 	</mime-type>
 
+	<mime-type type="application/x-transmission-resume">
+		<sub-class-of type="application/x-p2p"/>
+		<magic priority="50">
+    		<match value="d13:activity-date" type="string" offset="0"/>
+		</magic>
+	</mime-type>
+
     <mime-type type="message/outlook-pst">
         <magic priority="70">
             <match value="!--PST Email Message Indexer Preview--" type="string" offset="7"/>
diff --git a/iped-app/resources/config/conf/MakePreviewConfig.txt b/iped-app/resources/config/conf/MakePreviewConfig.txt
index 336846f342..00b0e8e621 100644
--- a/iped-app/resources/config/conf/MakePreviewConfig.txt
+++ b/iped-app/resources/config/conf/MakePreviewConfig.txt
@@ -10,4 +10,4 @@ supportedMimes = application/x-whatsapp-db; application/x-whatsapp-db-f; applica
 supportedMimes = application/x-prefetch; text/x-vcard; application/x-emule-preferences-dat; application/vnd.android.package-archive; application/x-bittorrent-settings-dat
 
 # List of mimetypes which parsers insert links to other case items into preview
-supportedMimesWithLinks = application/x-emule; application/x-emule-part-met; application/x-ares-galaxy; application/x-shareaza-library-dat; application/x-shareaza-download; application/x-bittorrent-resume-dat; application/x-bittorrent-resume-dat-entry; application/x-bittorrent
\ No newline at end of file
+supportedMimesWithLinks = application/x-emule; application/x-emule-part-met; application/x-ares-galaxy; application/x-shareaza-library-dat; application/x-shareaza-download; application/x-bittorrent-resume-dat; application/x-bittorrent-resume-dat-entry; application/x-bittorrent; application/x-transmission-resume
\ No newline at end of file
diff --git a/iped-app/resources/config/conf/ParserConfig.xml b/iped-app/resources/config/conf/ParserConfig.xml
index d56a231e17..ded70750b8 100644
--- a/iped-app/resources/config/conf/ParserConfig.xml
+++ b/iped-app/resources/config/conf/ParserConfig.xml
@@ -354,6 +354,7 @@
         <parser class="iped.parsers.bittorrent.BitTorrentResumeDatEntryParser"></parser>
         <parser class="iped.parsers.bittorrent.BitTorrentGenericDatParser"></parser>
         <parser class="iped.parsers.bittorrent.TorrentFileParser"></parser>
+        <parser class="iped.parsers.bittorrent.TransmissionResumeParser"></parser>
         <parser class="iped.parsers.lnk.LNKShortcutParser"></parser>
         <parser class="iped.parsers.misc.GenericOLEParser"></parser>
         <parser class="iped.parsers.misc.OFXParser"></parser>
diff --git a/iped-app/resources/config/profiles/triage/conf/ParserConfig.xml b/iped-app/resources/config/profiles/triage/conf/ParserConfig.xml
index f5ac5bc8ea..c124838072 100644
--- a/iped-app/resources/config/profiles/triage/conf/ParserConfig.xml
+++ b/iped-app/resources/config/profiles/triage/conf/ParserConfig.xml
@@ -337,6 +337,7 @@
         <parser class="iped.parsers.bittorrent.BitTorrentResumeDatParser"></parser>
         <parser class="iped.parsers.bittorrent.BitTorrentResumeDatEntryParser"></parser>
         <parser class="iped.parsers.bittorrent.TorrentFileParser"></parser>
+        <parser class="iped.parsers.bittorrent.TransmissionResumeParser"></parser>
         <parser class="iped.parsers.lnk.LNKShortcutParser"></parser>
         <parser class="iped.parsers.misc.OFXParser"></parser>
         <parser class="iped.parsers.misc.OFCParser"></parser>
diff --git a/iped-app/resources/localization/iped-parsers-messages.properties b/iped-app/resources/localization/iped-parsers-messages.properties
index 6f5fbb61b0..b2cc2ef3c6 100644
--- a/iped-app/resources/localization/iped-parsers-messages.properties
+++ b/iped-app/resources/localization/iped-parsers-messages.properties
@@ -394,6 +394,19 @@ TorrentFileDatParser.PathInCase=Path of Item Found in the Case
 TorrentFileDatParser.Yes=Yes
 TorrentFileDatParser.ConfirmedPieces=Confirmed pieces
 TorrentFileDatParser.AtOffset=at offset
+TransmissionResumeParser.DateFormat=MM/dd/yyyy HH:mm:ss
+TransmissionResumeParser.Name=Name
+TransmissionResumeParser.Destination=Destination Directory
+TransmissionResumeParser.IncompleteDir=Incomplete Directory
+TransmissionResumeParser.Downloaded=Downloaded (bytes)
+TransmissionResumeParser.Uploaded=Uploaded (bytes)
+TransmissionResumeParser.AddedDate=Added Date
+TransmissionResumeParser.LastActivityDate=Last Activity Date
+TransmissionResumeParser.DoneDate=Completed Date
+TransmissionResumeParser.InfoHash=InfoHash
+TransmissionResumeParser.FilesFoundInCase=Files Found in the Case
+TransmissionResumeParser.TorrentFoundInCase=Torrent Found in the Case
+TransmissionResumeParser.Yes=Yes
 TelegramContact.ContactID=Contact ID:
 TelegramContact.FirstName=First Name:
 TelegramContact.LastName=Last Name:
diff --git a/iped-app/resources/localization/iped-parsers-messages_de_DE.properties b/iped-app/resources/localization/iped-parsers-messages_de_DE.properties
index 6f10e0bce2..f5fb3027f9 100644
--- a/iped-app/resources/localization/iped-parsers-messages_de_DE.properties
+++ b/iped-app/resources/localization/iped-parsers-messages_de_DE.properties
@@ -394,6 +394,19 @@ TorrentFileDatParser.PathInCase=Pfad des Elements gefunden in Fall
 TorrentFileDatParser.Yes=Ja
 TorrentFileDatParser.ConfirmedPieces=bestätigte Elemente
 TorrentFileDatParser.AtOffset=bei Offset
+TransmissionResumeParser.DateFormat=dd/MM/yyyy HH:mm:ss
+TransmissionResumeParser.Name=Name[TBT]
+TransmissionResumeParser.Destination=Destination Directory[TBT]
+TransmissionResumeParser.IncompleteDir=Incomplete Directory[TBT]
+TransmissionResumeParser.Downloaded=Downloaded (bytes)[TBT]
+TransmissionResumeParser.Uploaded=Uploaded (bytes)[TBT]
+TransmissionResumeParser.AddedDate=Added Date[TBT]
+TransmissionResumeParser.LastActivityDate=Last Activity Date[TBT]
+TransmissionResumeParser.DoneDate=Completed Date[TBT]
+TransmissionResumeParser.InfoHash=InfoHash[TBT]
+TransmissionResumeParser.FilesFoundInCase=Files Found in the Case[TBT]
+TransmissionResumeParser.TorrentFoundInCase=Torrent Found in the Case[TBT]
+TransmissionResumeParser.Yes=Ja
 TelegramContact.ContactID=Kontakt ID:
 TelegramContact.FirstName=Vorname:
 TelegramContact.LastName=Nachname:
diff --git a/iped-app/resources/localization/iped-parsers-messages_es_AR.properties b/iped-app/resources/localization/iped-parsers-messages_es_AR.properties
index d613510e3b..61ed97ddfe 100644
--- a/iped-app/resources/localization/iped-parsers-messages_es_AR.properties
+++ b/iped-app/resources/localization/iped-parsers-messages_es_AR.properties
@@ -394,7 +394,20 @@ TorrentFileDatParser.PathInCase=Ubicación del elemento encontrado en el caso
 TorrentFileDatParser.Yes=Si
 TorrentFileDatParser.ConfirmedPieces=Piezas confirmadas
 TorrentFileDatParser.AtOffset=en el desplazamiento
-TelegramContact.ContactID=ID del contacto
+TransmissionResumeParser.DateFormat=dd/MM/yyyy HH:mm:ss
+TransmissionResumeParser.Name=Name[TBT]
+TransmissionResumeParser.Destination=Destination Directory[TBT]
+TransmissionResumeParser.IncompleteDir=Incomplete Directory[TBT]
+TransmissionResumeParser.Downloaded=Downloaded (bytes)[TBT]
+TransmissionResumeParser.Uploaded=Uploaded (bytes)[TBT]
+TransmissionResumeParser.AddedDate=Added Date[TBT]
+TransmissionResumeParser.LastActivityDate=Last Activity Date[TBT]
+TransmissionResumeParser.DoneDate=Completed Date[TBT]
+TransmissionResumeParser.InfoHash=InfoHash[TBT]
+TransmissionResumeParser.FilesFoundInCase=Files Found in the Case[TBT]
+TransmissionResumeParser.TorrentFoundInCase=Torrent Found in the Case[TBT]
+TransmissionResumeParser.Yes=Si
+TelegramContact.ContactID=ID del contacto:
 TelegramContact.FirstName=Nombre de la persona:
 TelegramContact.LastName=Segundo Nombre:
 TelegramContact.Username=Nombre de usuario:
diff --git a/iped-app/resources/localization/iped-parsers-messages_fr_FR.properties b/iped-app/resources/localization/iped-parsers-messages_fr_FR.properties
index b7925c68b2..ae38559b6d 100644
--- a/iped-app/resources/localization/iped-parsers-messages_fr_FR.properties
+++ b/iped-app/resources/localization/iped-parsers-messages_fr_FR.properties
@@ -394,6 +394,19 @@ TorrentFileDatParser.PathInCase=Emplacement de l'élément trouvé dans le cas
 TorrentFileDatParser.Yes=Oui
 TorrentFileDatParser.ConfirmedPieces=Pièces confirmées
 TorrentFileDatParser.AtOffset=à l'offset
+TransmissionResumeParser.DateFormat=dd/MM/yyyy HH:mm:ss
+TransmissionResumeParser.Name=Name[TBT]
+TransmissionResumeParser.Destination=Destination Directory[TBT]
+TransmissionResumeParser.IncompleteDir=Incomplete Directory[TBT]
+TransmissionResumeParser.Downloaded=Downloaded (bytes)[TBT]
+TransmissionResumeParser.Uploaded=Uploaded (bytes)[TBT]
+TransmissionResumeParser.AddedDate=Added Date[TBT]
+TransmissionResumeParser.LastActivityDate=Last Activity Date[TBT]
+TransmissionResumeParser.DoneDate=Completed Date[TBT]
+TransmissionResumeParser.InfoHash=InfoHash[TBT]
+TransmissionResumeParser.FilesFoundInCase=Files Found in the Case[TBT]
+TransmissionResumeParser.TorrentFoundInCase=Torrent Found in the Case[TBT]
+TransmissionResumeParser.Yes=Oui
 TelegramContact.ContactID=Identifiant du contact :
 TelegramContact.FirstName=Prénom :
 TelegramContact.LastName=Nom :
diff --git a/iped-app/resources/localization/iped-parsers-messages_it_IT.properties b/iped-app/resources/localization/iped-parsers-messages_it_IT.properties
index a5ec28237c..25a0884d33 100644
--- a/iped-app/resources/localization/iped-parsers-messages_it_IT.properties
+++ b/iped-app/resources/localization/iped-parsers-messages_it_IT.properties
@@ -394,6 +394,19 @@ TorrentFileDatParser.PathInCase=Percorso dell'elemento trovato nel Caso
 TorrentFileDatParser.Yes=Sì
 TorrentFileDatParser.ConfirmedPieces=Parti confermate
 TorrentFileDatParser.AtOffset=alla posizione
+TransmissionResumeParser.DateFormat=dd/MM/yyyy HH:mm:ss
+TransmissionResumeParser.Name=Name[TBT]
+TransmissionResumeParser.Destination=Destination Directory[TBT]
+TransmissionResumeParser.IncompleteDir=Incomplete Directory[TBT]
+TransmissionResumeParser.Downloaded=Downloaded (bytes)[TBT]
+TransmissionResumeParser.Uploaded=Uploaded (bytes)[TBT]
+TransmissionResumeParser.AddedDate=Added Date[TBT]
+TransmissionResumeParser.LastActivityDate=Last Activity Date[TBT]
+TransmissionResumeParser.DoneDate=Completed Date[TBT]
+TransmissionResumeParser.InfoHash=InfoHash[TBT]
+TransmissionResumeParser.FilesFoundInCase=Files Found in the Case[TBT]
+TransmissionResumeParser.TorrentFoundInCase=Torrent Found in the Case[TBT]
+TransmissionResumeParser.Yes=Sì
 TelegramContact.ContactID=ID Contatto:
 TelegramContact.FirstName=Nome:
 TelegramContact.LastName=Cognome:
diff --git a/iped-app/resources/localization/iped-parsers-messages_pt_BR.properties b/iped-app/resources/localization/iped-parsers-messages_pt_BR.properties
index a4c178e573..55fba33cac 100644
--- a/iped-app/resources/localization/iped-parsers-messages_pt_BR.properties
+++ b/iped-app/resources/localization/iped-parsers-messages_pt_BR.properties
@@ -394,6 +394,19 @@ TorrentFileDatParser.PathInCase=Caminho do Item Localizado no Caso
 TorrentFileDatParser.Yes=Sim
 TorrentFileDatParser.ConfirmedPieces=Partes confirmadas
 TorrentFileDatParser.AtOffset=a partir do offset
+TransmissionResumeParser.DateFormat=dd/MM/yyyy HH:mm:ss
+TransmissionResumeParser.Name=Nome
+TransmissionResumeParser.Destination=Diretório Destino
+TransmissionResumeParser.IncompleteDir=Diretório Incompleto
+TransmissionResumeParser.Downloaded=Baixados (bytes)
+TransmissionResumeParser.Uploaded=Enviados (bytes)
+TransmissionResumeParser.AddedDate=Data Adicionado
+TransmissionResumeParser.LastActivityDate=Data da Última Atividade
+TransmissionResumeParser.DoneDate=Data Completo
+TransmissionResumeParser.InfoHash=InfoHash
+TransmissionResumeParser.FilesFoundInCase=Arquivos Localizados no Caso
+TransmissionResumeParser.TorrentFoundInCase=Torrent Localizado no Caso
+TransmissionResumeParser.Yes=Sim
 TelegramContact.ContactID=ID do Contato:
 TelegramContact.FirstName=Nome:
 TelegramContact.LastName=Sobrenome:
diff --git a/iped-app/src/main/java/iped/app/ui/IconManager.java b/iped-app/src/main/java/iped/app/ui/IconManager.java
index dfa6cc2934..3343673fba 100644
--- a/iped-app/src/main/java/iped/app/ui/IconManager.java
+++ b/iped-app/src/main/java/iped/app/ui/IconManager.java
@@ -198,6 +198,10 @@ private static Map<String, QualityIcon> initMimeToIconMap(int size) {
             mimeIconMap.put("application/x-bittorrent-settings-dat", icon);
             mimeIconMap.put("application/x-bittorrent", icon);
         }
+        icon = availableIconsMap.get("transmission");
+        if (icon != null) {
+            mimeIconMap.put("application/x-transmission-resume", icon);
+        }
 
         icon = availableIconsMap.get("registry");
         if (icon != null) {
diff --git a/iped-app/src/main/resources/iped/app/ui/mime/transmission.png b/iped-app/src/main/resources/iped/app/ui/mime/transmission.png
new file mode 100644
index 0000000000..c26d5e3d3e
Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/mime/transmission.png differ
diff --git a/iped-engine/src/main/java/iped/engine/core/QueuesProcessingOrder.java b/iped-engine/src/main/java/iped/engine/core/QueuesProcessingOrder.java
index d31dd34b7d..89bd9c5381 100644
--- a/iped-engine/src/main/java/iped/engine/core/QueuesProcessingOrder.java
+++ b/iped-engine/src/main/java/iped/engine/core/QueuesProcessingOrder.java
@@ -13,6 +13,7 @@
 import iped.parsers.bittorrent.BitTorrentResumeDatEntryParser;
 import iped.parsers.bittorrent.BitTorrentResumeDatParser;
 import iped.parsers.bittorrent.TorrentFileParser;
+import iped.parsers.bittorrent.TransmissionResumeParser;
 import iped.parsers.browsers.chrome.CacheIndexParser;
 import iped.parsers.discord.DiscordParser;
 import iped.parsers.emule.KnownMetParser;
@@ -83,6 +84,7 @@ private static Map<MediaType, Integer> installTypesToPostProcess() {
         mediaTypes.put(MediaType.parse(TorrentFileParser.TORRENT_FILE_MIME_TYPE), 2);
         mediaTypes.put(MediaType.parse(BitTorrentResumeDatParser.RESUME_DAT_MIME_TYPE), 3);
         mediaTypes.put(MediaType.parse(BitTorrentResumeDatEntryParser.RESUME_DAT_ENTRY_MIME_TYPE), 3);
+        mediaTypes.put(MediaType.parse(TransmissionResumeParser.TRANSMISSION_RESUME_MIME_TYPE), 3);
 
         mediaTypes.put(WhatsAppParser.WA_DB, 2);
         mediaTypes.put(WhatsAppParser.MSG_STORE, 3);
diff --git a/iped-engine/src/main/java/iped/engine/task/P2PBookmarker.java b/iped-engine/src/main/java/iped/engine/task/P2PBookmarker.java
index 5691a5f32a..afddb48a10 100644
--- a/iped-engine/src/main/java/iped/engine/task/P2PBookmarker.java
+++ b/iped-engine/src/main/java/iped/engine/task/P2PBookmarker.java
@@ -20,6 +20,7 @@
 import iped.parsers.ares.AresParser;
 import iped.parsers.bittorrent.BitTorrentResumeDatEntryParser;
 import iped.parsers.bittorrent.BitTorrentResumeDatParser;
+import iped.parsers.bittorrent.TransmissionResumeParser;
 import iped.parsers.emule.KnownMetParser;
 import iped.parsers.emule.PartMetParser;
 import iped.parsers.gdrive.GDriveCloudGraphParser;
@@ -108,6 +109,8 @@ public void createBookmarksForSharedFiles(File caseDir) {
                 new P2PProgram(torrentHashes, "Torrent", new Color(0, 160, 60)));
         p2pPrograms.put(BitTorrentResumeDatEntryParser.RESUME_DAT_ENTRY_MIME_TYPE,
                 new P2PProgram(torrentHashes, "Torrent", new Color(0, 160, 60)));
+        p2pPrograms.put(TransmissionResumeParser.TRANSMISSION_RESUME_MIME_TYPE,
+                new P2PProgram(torrentHashes, "Transmission", new Color(0, 180, 0)));
 
         P2PProgram progGDrive = new P2PProgram(HashTask.HASH.MD5.toString(), "GoogleDrive");
         p2pPrograms.put(GDriveCloudGraphParser.GDRIVE_CLOUD_GRAPH_REG.toString(), progGDrive);
diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/bittorrent/TransmissionResumeParser.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/bittorrent/TransmissionResumeParser.java
new file mode 100644
index 0000000000..5c264d848b
--- /dev/null
+++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/bittorrent/TransmissionResumeParser.java
@@ -0,0 +1,224 @@
+package iped.parsers.bittorrent;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TimeZone;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AbstractParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.XHTMLContentHandler;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import iped.data.IItemReader;
+import iped.parsers.util.IgnoreCorruptedCarved;
+import iped.parsers.util.Messages;
+import iped.parsers.util.P2PUtil;
+import iped.properties.BasicProps;
+import iped.properties.ExtraProperties;
+import iped.search.IItemSearcher;
+import iped.utils.LocalizedFormat;
+
+public class TransmissionResumeParser extends AbstractParser {
+    private static final long serialVersionUID = 2692632610374337656L;
+    private static final Set<MediaType> SUPPORTED_TYPES = Collections
+            .singleton(MediaType.application("x-transmission-resume"));
+    public static final String TRANSMISSION_RESUME_MIME_TYPE = "application/x-transmission-resume";
+
+    private static final String[] header = new String[] { 
+            Messages.getString("TransmissionResumeParser.Name"),
+            Messages.getString("TransmissionResumeParser.Destination"),
+            Messages.getString("TransmissionResumeParser.IncompleteDir"),
+            Messages.getString("TransmissionResumeParser.InfoHash"),
+            Messages.getString("TransmissionResumeParser.Downloaded"),
+            Messages.getString("TransmissionResumeParser.Uploaded"),
+            Messages.getString("TransmissionResumeParser.AddedDate"),
+            Messages.getString("TransmissionResumeParser.LastActivityDate"),
+            Messages.getString("TransmissionResumeParser.DoneDate"),
+            Messages.getString("TransmissionResumeParser.TorrentFoundInCase"),
+            Messages.getString("TransmissionResumeParser.FilesFoundInCase") };
+
+    private static final String strYes = Messages.getString("TransmissionResumeParser.Yes");
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    @Override
+    public void parse(InputStream stream, ContentHandler handler, Metadata metadata, ParseContext context)
+            throws IOException, SAXException, TikaException {
+
+        final DateFormat df = new SimpleDateFormat(Messages.getString("TransmissionResumeParser.DateFormat"));
+        df.setTimeZone(TimeZone.getTimeZone("GMT+0"));
+
+        metadata.set(HttpHeaders.CONTENT_TYPE, TRANSMISSION_RESUME_MIME_TYPE);
+        metadata.remove(TikaCoreProperties.RESOURCE_NAME_KEY);
+
+        BencodedDict dict = new BencodedDict(stream, df, (context.get(IgnoreCorruptedCarved.class) == null));
+
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata);
+        xhtml.startDocument();
+        xhtml.startElement("style");
+        xhtml.characters(".dt {border-collapse: collapse; font-family: Arial, sans-serif; } "
+                + ".a { background-color:#AAAAEE; font-weight: bold; border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; word-wrap: nowrap; } "
+                + ".b { border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; word-wrap: nowrap; } "
+                + ".h { font-weight: bold; border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; white-space: nowrap; font-family: monospace; }");
+        xhtml.endElement("style");
+        xhtml.newline();
+        try {
+            xhtml.startElement("table", "class", "dt");
+
+            IItemSearcher searcher = context.get(IItemSearcher.class);
+            IItemReader item = context.get(IItemReader.class);
+
+            int filesFoundInCase = 0;
+            IItemReader torrentItem = null;
+
+            // First check id the resume file name is the InfoHash
+            String infoHash = getInfoHashFromName(item.getName());
+            if (infoHash != null) {
+                torrentItem = P2PUtil.searchItemInCase(searcher, TorrentFileParser.TORRENT_INFO_HASH, infoHash);
+            } else {
+                // If the name is NOT a InfoHash, try to find a torrent
+                // in the same folder structure and name, like below:
+                // <folder>/resume/xyz.resume
+                // <folder>/torrent/xyz.torrent
+                String[] torrentPaths = getTorrentPathsFromResumePath(item.getPath());
+                if (torrentPaths != null) {
+                    StringBuilder query = new StringBuilder();
+                    query.append(BasicProps.PATH).append(": (");
+                    for (int i = 0; i < torrentPaths.length; i++) {
+                        if (i > 0) {
+                            query.append(' ');
+                        }
+                        query.append('"');
+                        query.append(searcher.escapeQuery(torrentPaths[i]));
+                        query.append('"');
+                    }
+                    query.append(')');
+                    Iterable<IItemReader> items = searcher.searchIterable(query.toString());
+                    for (IItemReader it : items) {
+                        torrentItem = it;
+                        if (!it.isDeleted() && !it.isCarved()) {
+                            // Prioritize non-deleted items, so stop as one is found
+                            break;
+                        }
+                    }
+                }
+            }
+
+            if (torrentItem != null) {
+                // Corresponding torrent was found
+                metadata.add(ExtraProperties.LINKED_ITEMS, BasicProps.HASH + ":" + torrentItem.getHash());
+                String[] values = torrentItem.getMetadata().getValues(ExtraProperties.LINKED_ITEMS);
+                if (values != null) {
+                    long uploaded = dict.getLong("uploaded");
+                    boolean isShared = uploaded > 0;
+                    for (String v : values) {
+                        metadata.add(ExtraProperties.LINKED_ITEMS, v);
+                        if (isShared) {
+                            int p = v.lastIndexOf(':');
+                            if (p >= 0) {
+                                v = v.substring(p + 1).trim();
+                            }
+                            metadata.add(ExtraProperties.SHARED_HASHES, v);
+                        }
+                    }
+                }
+                String v = torrentItem.getMetadata().get(TorrentFileParser.TORRENT_FILES_FOUND_IN_CASE);
+                if (v != null && !v.isBlank()) {
+                    try {
+                        filesFoundInCase = Integer.parseInt(v);
+                    } catch (NumberFormatException e) {
+                        e.printStackTrace();
+                    }
+                }
+            }
+
+            String[] rowElements = new String[] { 
+                    dict.getString("name"), 
+                    dict.getString("destination"),
+                    dict.getString("incomplete-dir"),
+                    infoHash,
+                    LocalizedFormat.format(dict.getLong("downloaded")),
+                    LocalizedFormat.format(dict.getLong("uploaded")), 
+                    dict.getDate("added-date"),
+                    dict.getDate("activity-date"), 
+                    dict.getDate("done-date"), 
+                    torrentItem != null ? strYes : "",
+                    filesFoundInCase > 0 ? String.valueOf(filesFoundInCase) : ""};
+
+            for (int i = 0; i < rowElements.length; i++) {
+                String c = rowElements[i];
+                if (c != null && !c.isBlank()) {
+                    xhtml.startElement("tr");
+
+                    xhtml.startElement("td", "class", "a");
+                    xhtml.characters(header[i]);
+                    xhtml.endElement("td");
+
+                    xhtml.startElement("td", "class", i == 3 ? "h" : "b");
+                    xhtml.characters(c);
+                    xhtml.endElement("td");
+
+                    xhtml.endElement("tr");
+                }
+            }
+
+            xhtml.endElement("table");
+        } finally {
+            xhtml.endDocument();
+        }
+    }
+
+    private static String[] getTorrentPathsFromResumePath(String path) {
+        if (path.toLowerCase().endsWith(".resume")) {
+            int p1 = path.lastIndexOf('/');
+            if (p1 > 0) {
+                int p0 = path.lastIndexOf('/', p1 - 1);
+                if (p0 > 0) {
+                    String parent = path.substring(p0 + 1, p1);
+                    if (parent.equalsIgnoreCase("resume")) {
+                        int p2 = path.lastIndexOf('.');
+                        // IN:   <folder>/resume/xyz.resume
+                        // OUT1: <folder>/torrents/xyz.torrent
+                        // OUT2: <folder>/torrents/xy.torrent
+                        // Sometimes the last character is suppressed in torrent's name
+                        String base = path.substring(0, p0 + 1) + "torrents";
+                        return new String[] { base + path.substring(p1, p2) + ".torrent",
+                                base + path.substring(p1, p2 - 1) + ".torrent" };
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    private static String getInfoHashFromName(String name) {
+        // Check if item name is <infohash - 40 hex characters>.resume
+        if (name.toLowerCase().endsWith(".resume")) {
+            int p = name.lastIndexOf('.');
+            name = name.substring(0, p);
+            if (name.length() == 40) {
+                for (int i = 0; i < name.length(); i++) {
+                    char c = name.charAt(i);
+                    if ((c < 'A' || c > 'F') && (c < 'a' || c > 'f') && (c < '0' || c > '9')) {
+                        return null;
+                    }
+                }
+                return name;
+            }
+        }
+        return null;
+    }
+}


Reviews:
user: login: marcosammoura
body: Great work, @wladimirleite ! The code is running smoothly. Due to limited test samples, I wasn't able to test a case with  "filesFoundInCase > 0". However, the code is consistent and will work for those scenarios, too.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T23:26:24Z
user: login: wladimirleite
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T19:21:37Z
user: login: marcosammoura
body: Thank you very much @wladimirleite for this new feature! Great work!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:56:01Z

Review comments:
diff_hunk: @@ -0,0 +1,222 @@
+package iped.parsers.bittorrent;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TimeZone;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AbstractParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.XHTMLContentHandler;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import iped.data.IItemReader;
+import iped.parsers.util.IgnoreCorruptedCarved;
+import iped.parsers.util.Messages;
+import iped.parsers.util.P2PUtil;
+import iped.properties.BasicProps;
+import iped.properties.ExtraProperties;
+import iped.search.IItemSearcher;
+import iped.utils.LocalizedFormat;
+
+public class TransmissionResumeParser extends AbstractParser {
+    private static final long serialVersionUID = 2692632610374337656L;
+    private static final Set<MediaType> SUPPORTED_TYPES = Collections
+            .singleton(MediaType.application("x-transmission-resume"));
+    public static final String TRANSMISSION_RESUME_MIME_TYPE = "application/x-transmission-resume";
+
+    private static final String[] header = new String[] { 
+            Messages.getString("TransmissionResumeParser.Name"),
+            Messages.getString("TransmissionResumeParser.Destination"),
+            Messages.getString("TransmissionResumeParser.IncompleteDir"),
+            Messages.getString("TransmissionResumeParser.InfoHash"),
+            Messages.getString("TransmissionResumeParser.Downloaded"),
+            Messages.getString("TransmissionResumeParser.Uploaded"),
+            Messages.getString("TransmissionResumeParser.AddedDate"),
+            Messages.getString("TransmissionResumeParser.LastActivityDate"),
+            Messages.getString("TransmissionResumeParser.DoneDate"),
+            Messages.getString("TransmissionResumeParser.TorrentFoundInCase"),
+            Messages.getString("TransmissionResumeParser.FilesFoundInCase") };
+
+    private static final String strYes = Messages.getString("TransmissionResumeParser.Yes");
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    @Override
+    public void parse(InputStream stream, ContentHandler handler, Metadata metadata, ParseContext context)
+            throws IOException, SAXException, TikaException {
+
+        final DateFormat df = new SimpleDateFormat(Messages.getString("TransmissionResumeParser.DateFormat"));
+        df.setTimeZone(TimeZone.getTimeZone("GMT+0"));
+
+        metadata.set(HttpHeaders.CONTENT_TYPE, TRANSMISSION_RESUME_MIME_TYPE);
+        metadata.remove(TikaCoreProperties.RESOURCE_NAME_KEY);
+
+        BencodedDict dict = new BencodedDict(stream, df, (context.get(IgnoreCorruptedCarved.class) == null));
+
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata);
+        xhtml.startDocument();
+        xhtml.startElement("style");
+        xhtml.characters(".dt {border-collapse: collapse; font-family: Arial, sans-serif; } "
+                + ".a { background-color:#AAAAEE; font-weight: bold; border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; word-wrap: nowrap; } "
+                + ".b { border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; word-wrap: nowrap; } "
+                + ".h { font-weight: bold; border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; white-space: nowrap; font-family: monospace; }");
+        xhtml.endElement("style");
+        xhtml.newline();
+        try {
+            xhtml.startElement("table", "class", "dt");
+
+            IItemSearcher searcher = context.get(IItemSearcher.class);
+            IItemReader item = context.get(IItemReader.class);
+
+            int filesFoundInCase = 0;
+            IItemReader torrentItem = null;
+
+            // First check id the resume file name is the InfoHash
+            String infoHash = getInfoHashFromName(item.getName());
+            if (infoHash != null) {
+                torrentItem = P2PUtil.searchItemInCase(searcher, TorrentFileParser.TORRENT_INFO_HASH, infoHash);
+            } else {
+                // If the name is NOT a InfoHash, try to find a torrent
+                // in the same folder structure and name, like below:
+                // <folder>/resume/xyz.resume
+                // <folder>/torrent/xyz.torrent
+                String[] torrentPaths = getTorrentPathsFromResumePath(item.getPath());
+                if (torrentPaths != null) {
+                    StringBuilder query = new StringBuilder();
+                    query.append(BasicProps.PATH).append(": (");
+                    for (int i = 0; i < torrentPaths.length; i++) {
+                        if (i > 0) {
+                            query.append(' ');
+                        }
+                        query.append('"');
+                        query.append(searcher.escapeQuery(torrentPaths[i]));
+                        query.append('"');
+                    }
+                    query.append(')');
+                    Iterable<IItemReader> items = searcher.searchIterable(query.toString());
+                    for (IItemReader it : items) {
+                        torrentItem = it;
+                        if (!it.isDeleted() && !it.isCarved()) {
+                            // Prioritize non-deleted items, so stop as one is found
+                            break;
+                        }
+                    }
+                }
+            }
+
+            if (torrentItem != null) {
+                // Corresponding torrent was found
+                metadata.add(ExtraProperties.LINKED_ITEMS, BasicProps.HASH + ":" + torrentItem.getHash());
+                String[] values = torrentItem.getMetadata().getValues(ExtraProperties.LINKED_ITEMS);
+                if (values != null) {
+                    long uploaded = dict.getLong("uploaded");
+                    boolean isShared = uploaded > 0;
+                    for (String v : values) {
+                        metadata.add(ExtraProperties.LINKED_ITEMS, v);
+                        if (isShared) {
+                            int p = v.lastIndexOf(':');
+                            if (p >= 0) {
+                                v = v.substring(p + 1).trim();
+                            }
+                            metadata.add(ExtraProperties.SHARED_HASHES, v);
+                        }
+                    }
+                }
+                String v = torrentItem.getMetadata().get(TorrentFileParser.TORRENT_FILES_FOUND_IN_CASE);
+                if (v != null && !v.isBlank()) {
+                    filesFoundInCase = Integer.parseInt(v);
+                }
+            }
+
+            String[] rowElements = new String[] { 
+                    dict.getString("name"), 
+                    dict.getString("destination"),
+                    dict.getString("incomplete-dir"),
+                    infoHash,
+                    LocalizedFormat.format(dict.getLong("downloaded")),
+                    LocalizedFormat.format(dict.getLong("uploaded")), 
+                    dict.getDate("added-date"),
+                    dict.getDate("activity-date"), 
+                    dict.getDate("done-date"), 
+                    torrentItem != null ? strYes : "",
+                    filesFoundInCase > 0 ? String.valueOf(filesFoundInCase) : ""};
+
+            for (int i = 0; i < rowElements.length; i++) {
+                String c = rowElements[i];
+                if (c != null && !c.isBlank()) {
+                    xhtml.startElement("tr");
+
+                    xhtml.startElement("td", "class", "a");
+                    xhtml.characters(header[i]);
+                    xhtml.endElement("td");
+
+                    xhtml.startElement("td", "class", i == 3 ? "h" : "b");
+                    xhtml.characters(c);
+                    xhtml.endElement("td");
+
+                    xhtml.endElement("tr");
+                }
+            }
+
+            xhtml.endElement("table");
+        } finally {
+            xhtml.endDocument();
+        }
+    }
+
+    private static String[] getTorrentPathsFromResumePath(String path) {
+        if (path.toLowerCase().endsWith(".resume")) {
+            int p1 = path.lastIndexOf('/');
+            if (p1 > 0) {
+                int p0 = path.lastIndexOf('/', p1 - 1);
+                if (p0 > 0) {
+                    String parent = path.substring(p0 + 1, p1);
+                    if (parent.equalsIgnoreCase("resume")) {
+                        int p2 = path.lastIndexOf('.');
+                        if (p2 > 0 && p2 > p1) {
path: iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/bittorrent/TransmissionResumeParser.java
user: login: marcosammoura
body: Since ````if (p2 > 0 && p2 > p1)```` is within the outermost top-level ````if(path.toLowerCase().endsWith(".resume"))````, which guarantees that path has ````.resume```` suffix, the tested condition concerning ````p2```` will always be true, correct?
created_at: 2025-05-15T13:56:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,222 @@
+package iped.parsers.bittorrent;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TimeZone;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AbstractParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.XHTMLContentHandler;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import iped.data.IItemReader;
+import iped.parsers.util.IgnoreCorruptedCarved;
+import iped.parsers.util.Messages;
+import iped.parsers.util.P2PUtil;
+import iped.properties.BasicProps;
+import iped.properties.ExtraProperties;
+import iped.search.IItemSearcher;
+import iped.utils.LocalizedFormat;
+
+public class TransmissionResumeParser extends AbstractParser {
+    private static final long serialVersionUID = 2692632610374337656L;
+    private static final Set<MediaType> SUPPORTED_TYPES = Collections
+            .singleton(MediaType.application("x-transmission-resume"));
+    public static final String TRANSMISSION_RESUME_MIME_TYPE = "application/x-transmission-resume";
+
+    private static final String[] header = new String[] { 
+            Messages.getString("TransmissionResumeParser.Name"),
+            Messages.getString("TransmissionResumeParser.Destination"),
+            Messages.getString("TransmissionResumeParser.IncompleteDir"),
+            Messages.getString("TransmissionResumeParser.InfoHash"),
+            Messages.getString("TransmissionResumeParser.Downloaded"),
+            Messages.getString("TransmissionResumeParser.Uploaded"),
+            Messages.getString("TransmissionResumeParser.AddedDate"),
+            Messages.getString("TransmissionResumeParser.LastActivityDate"),
+            Messages.getString("TransmissionResumeParser.DoneDate"),
+            Messages.getString("TransmissionResumeParser.TorrentFoundInCase"),
+            Messages.getString("TransmissionResumeParser.FilesFoundInCase") };
+
+    private static final String strYes = Messages.getString("TransmissionResumeParser.Yes");
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    @Override
+    public void parse(InputStream stream, ContentHandler handler, Metadata metadata, ParseContext context)
+            throws IOException, SAXException, TikaException {
+
+        final DateFormat df = new SimpleDateFormat(Messages.getString("TransmissionResumeParser.DateFormat"));
+        df.setTimeZone(TimeZone.getTimeZone("GMT+0"));
+
+        metadata.set(HttpHeaders.CONTENT_TYPE, TRANSMISSION_RESUME_MIME_TYPE);
+        metadata.remove(TikaCoreProperties.RESOURCE_NAME_KEY);
+
+        BencodedDict dict = new BencodedDict(stream, df, (context.get(IgnoreCorruptedCarved.class) == null));
+
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata);
+        xhtml.startDocument();
+        xhtml.startElement("style");
+        xhtml.characters(".dt {border-collapse: collapse; font-family: Arial, sans-serif; } "
+                + ".a { background-color:#AAAAEE; font-weight: bold; border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; word-wrap: nowrap; } "
+                + ".b { border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; word-wrap: nowrap; } "
+                + ".h { font-weight: bold; border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; white-space: nowrap; font-family: monospace; }");
+        xhtml.endElement("style");
+        xhtml.newline();
+        try {
+            xhtml.startElement("table", "class", "dt");
+
+            IItemSearcher searcher = context.get(IItemSearcher.class);
+            IItemReader item = context.get(IItemReader.class);
+
+            int filesFoundInCase = 0;
+            IItemReader torrentItem = null;
+
+            // First check id the resume file name is the InfoHash
+            String infoHash = getInfoHashFromName(item.getName());
+            if (infoHash != null) {
+                torrentItem = P2PUtil.searchItemInCase(searcher, TorrentFileParser.TORRENT_INFO_HASH, infoHash);
+            } else {
+                // If the name is NOT a InfoHash, try to find a torrent
+                // in the same folder structure and name, like below:
+                // <folder>/resume/xyz.resume
+                // <folder>/torrent/xyz.torrent
+                String[] torrentPaths = getTorrentPathsFromResumePath(item.getPath());
+                if (torrentPaths != null) {
+                    StringBuilder query = new StringBuilder();
+                    query.append(BasicProps.PATH).append(": (");
+                    for (int i = 0; i < torrentPaths.length; i++) {
+                        if (i > 0) {
+                            query.append(' ');
+                        }
+                        query.append('"');
+                        query.append(searcher.escapeQuery(torrentPaths[i]));
+                        query.append('"');
+                    }
+                    query.append(')');
+                    Iterable<IItemReader> items = searcher.searchIterable(query.toString());
+                    for (IItemReader it : items) {
+                        torrentItem = it;
+                        if (!it.isDeleted() && !it.isCarved()) {
+                            // Prioritize non-deleted items, so stop as one is found
+                            break;
+                        }
+                    }
+                }
+            }
+
+            if (torrentItem != null) {
+                // Corresponding torrent was found
+                metadata.add(ExtraProperties.LINKED_ITEMS, BasicProps.HASH + ":" + torrentItem.getHash());
+                String[] values = torrentItem.getMetadata().getValues(ExtraProperties.LINKED_ITEMS);
+                if (values != null) {
+                    long uploaded = dict.getLong("uploaded");
+                    boolean isShared = uploaded > 0;
+                    for (String v : values) {
+                        metadata.add(ExtraProperties.LINKED_ITEMS, v);
+                        if (isShared) {
+                            int p = v.lastIndexOf(':');
+                            if (p >= 0) {
+                                v = v.substring(p + 1).trim();
+                            }
+                            metadata.add(ExtraProperties.SHARED_HASHES, v);
+                        }
+                    }
+                }
+                String v = torrentItem.getMetadata().get(TorrentFileParser.TORRENT_FILES_FOUND_IN_CASE);
+                if (v != null && !v.isBlank()) {
+                    filesFoundInCase = Integer.parseInt(v);
path: iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/bittorrent/TransmissionResumeParser.java
user: login: marcosammoura
body: Maybe enclose this line within a try/catch/e.printStackTrace() block, even though this torrentItem metadata might have been set properly?
created_at: 2025-05-16T23:18:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,222 @@
+package iped.parsers.bittorrent;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TimeZone;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AbstractParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.XHTMLContentHandler;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import iped.data.IItemReader;
+import iped.parsers.util.IgnoreCorruptedCarved;
+import iped.parsers.util.Messages;
+import iped.parsers.util.P2PUtil;
+import iped.properties.BasicProps;
+import iped.properties.ExtraProperties;
+import iped.search.IItemSearcher;
+import iped.utils.LocalizedFormat;
+
+public class TransmissionResumeParser extends AbstractParser {
+    private static final long serialVersionUID = 2692632610374337656L;
+    private static final Set<MediaType> SUPPORTED_TYPES = Collections
+            .singleton(MediaType.application("x-transmission-resume"));
+    public static final String TRANSMISSION_RESUME_MIME_TYPE = "application/x-transmission-resume";
+
+    private static final String[] header = new String[] { 
+            Messages.getString("TransmissionResumeParser.Name"),
+            Messages.getString("TransmissionResumeParser.Destination"),
+            Messages.getString("TransmissionResumeParser.IncompleteDir"),
+            Messages.getString("TransmissionResumeParser.InfoHash"),
+            Messages.getString("TransmissionResumeParser.Downloaded"),
+            Messages.getString("TransmissionResumeParser.Uploaded"),
+            Messages.getString("TransmissionResumeParser.AddedDate"),
+            Messages.getString("TransmissionResumeParser.LastActivityDate"),
+            Messages.getString("TransmissionResumeParser.DoneDate"),
+            Messages.getString("TransmissionResumeParser.TorrentFoundInCase"),
+            Messages.getString("TransmissionResumeParser.FilesFoundInCase") };
+
+    private static final String strYes = Messages.getString("TransmissionResumeParser.Yes");
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    @Override
+    public void parse(InputStream stream, ContentHandler handler, Metadata metadata, ParseContext context)
+            throws IOException, SAXException, TikaException {
+
+        final DateFormat df = new SimpleDateFormat(Messages.getString("TransmissionResumeParser.DateFormat"));
+        df.setTimeZone(TimeZone.getTimeZone("GMT+0"));
+
+        metadata.set(HttpHeaders.CONTENT_TYPE, TRANSMISSION_RESUME_MIME_TYPE);
+        metadata.remove(TikaCoreProperties.RESOURCE_NAME_KEY);
+
+        BencodedDict dict = new BencodedDict(stream, df, (context.get(IgnoreCorruptedCarved.class) == null));
+
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata);
+        xhtml.startDocument();
+        xhtml.startElement("style");
+        xhtml.characters(".dt {border-collapse: collapse; font-family: Arial, sans-serif; } "
+                + ".a { background-color:#AAAAEE; font-weight: bold; border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; word-wrap: nowrap; } "
+                + ".b { border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; word-wrap: nowrap; } "
+                + ".h { font-weight: bold; border: solid; border-width: thin; padding: 3px; text-align: left; vertical-align: middle; white-space: nowrap; font-family: monospace; }");
+        xhtml.endElement("style");
+        xhtml.newline();
+        try {
+            xhtml.startElement("table", "class", "dt");
+
+            IItemSearcher searcher = context.get(IItemSearcher.class);
+            IItemReader item = context.get(IItemReader.class);
+
+            int filesFoundInCase = 0;
+            IItemReader torrentItem = null;
+
+            // First check id the resume file name is the InfoHash
+            String infoHash = getInfoHashFromName(item.getName());
+            if (infoHash != null) {
+                torrentItem = P2PUtil.searchItemInCase(searcher, TorrentFileParser.TORRENT_INFO_HASH, infoHash);
+            } else {
+                // If the name is NOT a InfoHash, try to find a torrent
+                // in the same folder structure and name, like below:
+                // <folder>/resume/xyz.resume
+                // <folder>/torrent/xyz.torrent
+                String[] torrentPaths = getTorrentPathsFromResumePath(item.getPath());
+                if (torrentPaths != null) {
+                    StringBuilder query = new StringBuilder();
+                    query.append(BasicProps.PATH).append(": (");
+                    for (int i = 0; i < torrentPaths.length; i++) {
+                        if (i > 0) {
+                            query.append(' ');
+                        }
+                        query.append('"');
+                        query.append(searcher.escapeQuery(torrentPaths[i]));
+                        query.append('"');
+                    }
+                    query.append(')');
+                    Iterable<IItemReader> items = searcher.searchIterable(query.toString());
+                    for (IItemReader it : items) {
+                        torrentItem = it;
+                        if (!it.isDeleted() && !it.isCarved()) {
+                            // Prioritize non-deleted items, so stop as one is found
+                            break;
+                        }
+                    }
+                }
+            }
+
+            if (torrentItem != null) {
+                // Corresponding torrent was found
+                metadata.add(ExtraProperties.LINKED_ITEMS, BasicProps.HASH + ":" + torrentItem.getHash());
+                String[] values = torrentItem.getMetadata().getValues(ExtraProperties.LINKED_ITEMS);
+                if (values != null) {
+                    long uploaded = dict.getLong("uploaded");
+                    boolean isShared = uploaded > 0;
+                    for (String v : values) {
+                        metadata.add(ExtraProperties.LINKED_ITEMS, v);
+                        if (isShared) {
+                            int p = v.lastIndexOf(':');
+                            if (p >= 0) {
+                                v = v.substring(p + 1).trim();
+                            }
+                            metadata.add(ExtraProperties.SHARED_HASHES, v);
+                        }
+                    }
+                }
+                String v = torrentItem.getMetadata().get(TorrentFileParser.TORRENT_FILES_FOUND_IN_CASE);
+                if (v != null && !v.isBlank()) {
+                    filesFoundInCase = Integer.parseInt(v);
+                }
+            }
+
+            String[] rowElements = new String[] { 
+                    dict.getString("name"), 
+                    dict.getString("destination"),
+                    dict.getString("incomplete-dir"),
+                    infoHash,
+                    LocalizedFormat.format(dict.getLong("downloaded")),
+                    LocalizedFormat.format(dict.getLong("uploaded")), 
+                    dict.getDate("added-date"),
+                    dict.getDate("activity-date"), 
+                    dict.getDate("done-date"), 
+                    torrentItem != null ? strYes : "",
+                    filesFoundInCase > 0 ? String.valueOf(filesFoundInCase) : ""};
+
+            for (int i = 0; i < rowElements.length; i++) {
+                String c = rowElements[i];
+                if (c != null && !c.isBlank()) {
+                    xhtml.startElement("tr");
+
+                    xhtml.startElement("td", "class", "a");
+                    xhtml.characters(header[i]);
+                    xhtml.endElement("td");
+
+                    xhtml.startElement("td", "class", i == 3 ? "h" : "b");
+                    xhtml.characters(c);
+                    xhtml.endElement("td");
+
+                    xhtml.endElement("tr");
+                }
+            }
+
+            xhtml.endElement("table");
+        } finally {
+            xhtml.endDocument();
+        }
+    }
+
+    private static String[] getTorrentPathsFromResumePath(String path) {
+        if (path.toLowerCase().endsWith(".resume")) {
+            int p1 = path.lastIndexOf('/');
+            if (p1 > 0) {
+                int p0 = path.lastIndexOf('/', p1 - 1);
+                if (p0 > 0) {
+                    String parent = path.substring(p0 + 1, p1);
+                    if (parent.equalsIgnoreCase("resume")) {
+                        int p2 = path.lastIndexOf('.');
+                        if (p2 > 0 && p2 > p1) {
path: iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/bittorrent/TransmissionResumeParser.java
user: login: wladimirleite
body: Correct!
PS: Sorry, I saw the observation but forgot to reply.
created_at: 2025-05-19T19:21:37Z
author_association: MEMBER

PR comments:
user: login: lfcnassif
created_at: 2025-05-15T16:57:14Z
body: @wladimirleite, I saw you added a new signature for the new handled artifact. What is its common file name? I'll search for it in our local cases, so @marcosammoura can test this PR with a data set different than yours, which I believe you used for development. But if you have new samples not used in your tests, we can also use them to test this.
author_association: MEMBER
user: login: lfcnassif
created_at: 2025-05-15T16:59:24Z
body: I just saw the `.resume` extension was described in the related issue, thanks! But if the handled artifact has a specific name, that would help even further.
author_association: MEMBER
user: login: marcosammoura
created_at: 2025-05-15T17:37:30Z
body: > I just saw the `.extension` was described in the related issue, but if the handled artifact has a specific name, that would help even futher.

@lfcnassif , according to code comment at line 206 in ````TransmissionResumeParser.java```` file, name is ````<infohash - 40 hex characters>.resume````
author_association: COLLABORATOR
user: login: wladimirleite
created_at: 2025-05-15T19:29:25Z
body: There are two possible names patterns:
1. <DescriptiveFileName - any length>.resume
![image](https://github.com/user-attachments/assets/1039e92b-c5a9-4ab3-9002-00309d7f74cc)
2. <InfoHash - 40 hex characters>.resume
![image](https://github.com/user-attachments/assets/5a7bb87e-a1a2-4b9a-8399-a98023da0377)

The samples that I have were used in my local tests.
In both scenarios, the ".resume" files were inside a "resume" folder, which may help to try to find samples in other cases.

I don't think this BitTorrent client is very common, but currently it is possible to overlook the presence of these artefacts. 
The project seems quite active:
https://github.com/transmission/transmission 
author_association: MEMBER
user: login: lfcnassif
created_at: 2025-05-15T22:19:14Z
body: I've found 4 possible samples after searching in hundreds of real cases. I've sent them to @marcosammoura for testing. Since I would like him to test with more samples, could you sent yours to him @wladimirleite?
author_association: MEMBER
user: login: wladimirleite
created_at: 2025-05-15T23:07:11Z
body: > Since I would like him to test with more samples, could you sent yours to him @wladimirleite?

Sure! Will do that.
author_association: MEMBER
user: login: marcosammoura
created_at: 2025-05-16T15:05:13Z
body: Thank you for the samples, @lfcnassif and @wladimirleite. 

I'm running initial tests and have found that samples from @lfcnassif  were not parsed, which then I've examined and realized that they indeed are resume files related to another torrent app, [libtorrent](https://github.com/arvidn/libtorrent). Therefore, we have an improvement opportunity concerning the subject of this PR! 

I see some possible scenarios:
1. review and close this PR, and create a new PR to tackle Libtorrent resume files
2. modify this PR to incorporate parsing Libtorrent resume files

In either case, I am willing to work toward the solution.
In the meantime, PR review as it is for now is moving on.

Please, share your ideas.
 
author_association: COLLABORATOR
user: login: wladimirleite
created_at: 2025-05-16T15:19:32Z
body: @marcosammoura, even though I'm not sure how different libtorrent artifacts are compared to Transmission, I'd go with option 1. While there are likely common elements, quite a few details seem specific to Transmission. We already have a parser for BitTorrent/uTorrent, but Transmission had crucial differences that required a separate implementation.

Later, when we address libtorrent in a separate PR, there's already some reusable code in utility classes. If necessary, we can move some common code currently in Transmission-related classes to more generic ones and use it in the libtorrent parser.
author_association: MEMBER
user: login: marcosammoura
created_at: 2025-05-16T15:37:01Z
body: > @marcosammoura, even though I'm not sure how different libtorrent artifacts are compared to Transmission, I'd go with option 1. While there are likely common elements, quite a few details seem specific to Transmission. We already have a parser for BitTorrent/uTorrent, but Transmission had crucial differences that required a separate implementation.
> 
> Later, when we address libtorrent in a separate PR, there's already some reusable code in utility classes. If necessary, we can move some common code currently in Transmission-related classes to more generic ones and use it in the libtorrent parser.

I also vote for option 1, so we may keep focus on what is already implemented, and a new feature issue could be created concerning Libtorrent.
author_association: COLLABORATOR
user: login: lfcnassif
created_at: 2025-05-19T19:05:49Z
body: > I see some possible scenarios:
> 
> 1. review and close this PR, and create a new PR to tackle Libtorrent resume files
> 2. modify this PR to incorporate parsing Libtorrent resume files

If the needed changes are not small, I vote for option 1, if they are small, I vote for 2.
author_association: MEMBER
user: login: marcosammoura
created_at: 2025-05-19T22:16:59Z
body: > > I see some possible scenarios:
> > 
> > 1. review and close this PR, and create a new PR to tackle Libtorrent resume files
> > 2. modify this PR to incorporate parsing Libtorrent resume files
> 
> If the needed changes are not small, I vote for option 1, if they are small, I vote for 2.

@lfcnassif and @wladimirleite, the decision is up to you. Once @wladimirleite commits the resolved sugestions above, this PR will be ready to go, ok.
author_association: COLLABORATOR
user: login: wladimirleite
created_at: 2025-05-20T01:38:07Z
body: @marcosammoura, I made the changes you suggested. 
I also solved the merging conflicts caused by localization files.
author_association: MEMBER
user: login: lfcnassif
created_at: 2025-05-20T02:26:35Z
body: > @lfcnassif and @wladimirleite, the decision is up to you.

My vote is just 1 while you together are 2, so I was already defeated :-). Let's go with @wladimirleite's vote, since his experience on this subject is very valuable.
author_association: MEMBER
user: login: lfcnassif
created_at: 2025-05-20T12:33:19Z
body: Thanks @wladimirleite and @marcosammoura !
author_association: MEMBER